From 878ff8dc51e2d75795a7e6a9046c03b0ba560762 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 3 Feb 2026 16:20:51 -0800 Subject: [PATCH 01/29] Enhance configuration and setup for KISS modem support - Added support for Meshcore KISS modem firmware in configuration, allowing users to set `radio_type: kiss` and configure serial port and baud rate. - Updated `config.yaml.example` to include KISS modem settings. - Modified `manage.sh` to install with hardware extras for KISS support. - Enhanced `setup-radio-config.sh` to prompt for radio type and KISS modem settings. - Updated API endpoints to handle KISS modem configurations and hardware options. - Improved error handling for missing configuration sections. This update improves flexibility for users utilizing KISS modems alongside SX1262 hardware. --- README.md | 12 +- config.yaml.example | 10 +- data/repeater.db | Bin 0 -> 352256 bytes manage.sh | 4 +- pyproject.toml | 6 +- repeater/config.py | 52 +++- .../data_acquisition/storage_collector.py | 3 +- repeater/main.py | 6 +- repeater/web/api_endpoints.py | 249 +++++++++--------- repeater/web/html/assets/Setup-CSawSnc5.js | 2 +- setup-radio-config.sh | 159 +++++++---- 11 files changed, 329 insertions(+), 174 deletions(-) create mode 100644 data/repeater.db diff --git a/README.md b/README.md index af59ad9..da5f638 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,12 @@ The repeater daemon runs continuously as a background process, forwarding LoRa p ## Supported Hardware (Out of the Box) -The following hardware is currently supported out-of-the-box: +The repeater supports two radio backends: + +- **SX1262 (SPI)** — Direct connection to LoRa modules (HATs, etc.) as listed below. +- **KISS modem** — Serial TNC using the KISS protocol. Requires a pyMC_core build with KISS support (e.g. [agessaman/pyMC_core (dev)](https://github.com/agessaman/pyMC_core/tree/dev)). Set `radio_type: kiss` in config and configure `kiss.port` and `kiss.baud_rate`. The setup script (`./setup-radio-config.sh`) offers a "KISS modem" option when configuring the repeater. + +The following SX1262 hardware is currently supported out-of-the-box: Waveshare LoRaWAN/GNSS HAT (SPI Version Only) @@ -149,6 +154,11 @@ http://:8000 pip install -e . ``` +On **macOS** (or when using only the KISS modem), the base install is enough. On **Raspberry Pi** with SX1262 hardware, install with the optional hardware extra so SPI/spidev is available: +```bash +pip install -e .[hardware] +``` + ## Configuration The configuration file is created and configured during installation at: diff --git a/config.yaml.example b/config.yaml.example index 9f96c83..e1498b3 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,4 +1,6 @@ # Default Repeater Configuration +# radio_type: sx1262 | kiss (use kiss for serial KISS TNC modem) +radio_type: sx1262 repeater: # Node name for logging and identification @@ -127,6 +129,11 @@ radio: # Use implicit header mode implicit_header: false +# KISS modem (when radio_type: kiss). Requires pyMC_core with KISS support. +# kiss: +# port: "/dev/ttyUSB0" +# baud_rate: 9600 + # SX1262 Hardware Configuration sx1262: # SPI bus and chip select @@ -212,7 +219,8 @@ mqtt: # Storage Configuration storage: - # Directory for persistent storage files (SQLite, RRD) + # Directory for persistent storage files (SQLite, RRD). + # Use a writable path for local/dev (e.g. "./var/pymc_repeater" or "~/var/pymc_repeater"). storage_dir: "/var/lib/pymc_repeater" # Data retention settings diff --git a/data/repeater.db b/data/repeater.db new file mode 100644 index 0000000000000000000000000000000000000000..48bc38a94dc656e1ba8180b8cc6cfff597a59622 GIT binary patch literal 352256 zcmeFa2Y6LQ+Bkl0Zm)+Ps$M}#1oZTRD7lp)1cZPnC7cS8kc1>O6*L433ie(=Q4xFZ z4YBvS_I2%T6?GST+21=e_Z$e`2tNOBzdyU*4G(WSb7tne@60>>&XhyP*S1(myP;`; z){^9@PL#((RVI@ZRkklhQPbeR^VbsypThw5>_B%wLLB=qKE;m$(?6py-RPf#{e6o9 zfAw|sZuZ(`zm{E8miD~mnNR(Lx*z`ixj(*hpnUW`9;&w9w3aq6tb;G9(H6BdV7_KS z?c64lzxG62D7mr?4oI9l{TBBdItn7AR-? zcow>;Xy#(8sRi`7NC$luxcKi9Xdev4UyuwTFILQn2Fm3=p5?!b*iF0eLaf8M-Hv$45AWky;(m(r z?mVq|UV)Bum;eg2W6b1*8YHebG=yWW{aws0%Nngh%;64WMuB#W8Sxc!f;;Km$*ilm zf*{m!yiO5o7r7&>SY`QxP4o{}0R!EYg%28RzL5(I$gDO!2O$Cb6RZqunENV3W7?M5YN zYFGq46YOjYUa8|0jE4Fa&1fN^kt69iXdU!!S{9iWHsv1#Yp9>QJK^lwCU6+dmQ~-r zZ2UpY$iw`Z7n+)zYj^Esn(Lc(@kyk#)ff$n>RX)nTstJQxqNEs`06Cvan8Yx_0|&j zUpsf6-tcE^d6d=EP&2Qg@ptBTlg+2bPstUpP?O369&TijxlV;3|6hp*YS$%UnQZ?cyj1_na!aV})?7*RjoTE&83cI}TrG z)z592_a|QXpDLZrMnluCDkW}gNC)$GC8^_2f?{hfYJ@%pIG{iAn%UIQ2!2azZm7o& zvEu}qn+)8q+(j@G>OJn2{;`8<)LQ24PPlo{w17kXL*p47>+d`g^liF^(b$|%yp1|K)99b+@91ynFX&I`_vydVuhTEn&(TlPkI)a& z_t3Y|H_)5t4fMtIx%BDu$@D6E1-+DRqU&gzo?JqqoD;cL2x*G&`K7Y;H9~b`!gR@Rvd>* zhO2mn-IEkwdDim3ymIZA&;79h7`Fec_9g)d7>3PZ!wASQ49i-3hG|-rP)`c10+k~S zwWD+Ae%+~AP&o!t3y@*rv!|WX?Ze6;#4c{l$SDFd1Jw*{AgBzCr5L&)F&3+_rm6}W zXN#t+OA5yul3_zhw+xl#y5;hDQOU_lO3sUUo)bY)xYQ!0F(Shu)$DF$t%n5NtV5|P zWIZ^1AiEG}ePmy*+f|hTT%ss9$G9&4i5yo>|9*DgXVzcUQO;3)bG`!Tf}!DCcvSyb zYj?k9<*@;`judrMvHhkF^k=TBOpp(9Z83w*4@=6q9lVriBJFdMXh@u@TB5*NmSBRv zQUsf26vH$bP8WDV)h*U$E!E~YMm8iK%qMYzt%|&837Tvvysm-skxg6Uz$xjRps+Tl z>8io$;HhNMhByq-l5J5}paiIbDar!R7%C%V*sRFr#T={3c~#A(GBf(Kd88dVx1*P0 zZP`LzYEQ`W%47Y1Y=P)=-@oP0d{|jg)B>qOZ9whBp71#xTOfrjfNC6SC%^(4!|oYb zZcF$a3xGEQErTIQKaFHeA=`RZn$@@5~%CM zW=QwRZN)W((``l7HH+Z|SrRx^)@8*sELLJUMP_WtGk`Eb1N2{*4U{lRn!rQTzB8~{>(!$+^co!grq`qp%y&W z|M+Ks{in7~1z6p72*4?AS%8zw z9JjI&;MkS507tK^0hnJ|4KTNI9Kh_#6u`_%6kB@bP=KkG`v9z5iDElwC34XtSE51o zfvv{^WLu8{$h6i2JfPJCxPR*mfWuo+oWokj1033l;@q!Q1vt3%0Dyy9hX5Sd3a*FR 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 f#<%=6.0.0", "cherrypy>=18.0.0", "paho-mqtt>=1.6.0", @@ -44,6 +44,10 @@ dependencies = [ [project.optional-dependencies] +# SX1262/SPI support (Linux only; required for Raspberry Pi HATs) +hardware = [ + "pymc_core[hardware]", +] dev = [ "pytest>=7.4.0", "pytest-asyncio>=0.21.0", diff --git a/repeater/config.py b/repeater/config.py index ffe9268..5bd9ffa 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -197,7 +197,9 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes: def get_radio_for_board(board_config: dict): - radio_type = board_config.get("radio_type", "sx1262").lower() + radio_type = board_config.get("radio_type", "sx1262").lower().strip() + if radio_type == "kiss-modem": + radio_type = "kiss" if radio_type == "sx1262": from pymc_core.hardware.sx1262_wrapper import SX1262Radio @@ -245,5 +247,51 @@ def get_radio_for_board(board_config: dict): return radio + elif radio_type == "kiss": + try: + from pymc_core.hardware.kiss_modem_wrapper import KissModemWrapper + except ImportError: + try: + from pymc_core.hardware.kiss_serial_wrapper import KissSerialWrapper as KissModemWrapper + except ImportError: + raise RuntimeError( + "KISS modem support requires pyMC_core with KISS support. " + "Install your fork with: pip install -e /path/to/pyMC_core" + ) from None + + kiss_config = board_config.get("kiss") + if not kiss_config: + raise ValueError("Missing 'kiss' section in configuration file for radio_type: kiss") + + port = kiss_config.get("port") + if not port: + raise ValueError("Missing 'port' in 'kiss' section (e.g. /dev/ttyUSB0)") + + baudrate = int(kiss_config.get("baud_rate", 115200)) + radio_cfg = board_config.get("radio") or {} + radio_config = { + "frequency": int(radio_cfg.get("frequency", 869618000)), + "bandwidth": int(radio_cfg.get("bandwidth", 62500)), + "spreading_factor": int(radio_cfg.get("spreading_factor", 8)), + "coding_rate": int(radio_cfg.get("coding_rate", 8)), + "tx_power": int(radio_cfg.get("tx_power", 14)), + } + radio = KissModemWrapper( + port=port, + baudrate=baudrate, + radio_config=radio_config, + auto_configure=True, + ) + + if hasattr(radio, "begin"): + try: + radio.begin() + except Exception as e: + raise RuntimeError(f"Failed to initialize KISS modem: {e}") from e + + return radio + else: - raise RuntimeError(f"Unknown radio type: {radio_type}. Supported: sx1262") + raise RuntimeError( + f"Unknown radio type: {radio_type}. Supported: sx1262, kiss (or kiss-modem)" + ) diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py index 1019415..f139151 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -19,7 +19,8 @@ class StorageCollector: def __init__(self, config: dict, local_identity=None, repeater_handler=None): self.config = config self.repeater_handler = repeater_handler - self.storage_dir = Path(config.get("storage_dir", "/var/lib/pymc_repeater")) + storage_cfg = config.get("storage", {}) + self.storage_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) self.storage_dir.mkdir(parents=True, exist_ok=True) node_name = config.get("repeater", {}).get("node_name", "unknown") diff --git a/repeater/main.py b/repeater/main.py index dd9c785..365e7f9 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -56,7 +56,11 @@ class RepeaterDaemon: logger.info("Initializing radio hardware...") try: self.radio = get_radio_for_board(self.config) - + + # KISS modem: schedule RX callbacks on the event loop for thread safety + if hasattr(self.radio, "set_event_loop"): + self.radio.set_event_loop(asyncio.get_running_loop()) + if hasattr(self.radio, 'set_custom_cad_thresholds'): # Load CAD settings from config, with defaults cad_config = self.config.get("radio", {}).get("cad", {}) diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index e3c3155..857f403 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -232,20 +232,17 @@ class APIEndpoints: def needs_setup(self): """Check if the repeater needs initial setup configuration""" try: - # Check if config has default values that indicate first-time setup config = self.config - - # Check for default node name + + # Check for default values that indicate first-time setup node_name = config.get('repeater', {}).get('node_name', '') has_default_name = node_name in ['mesh-repeater-01', ''] - - # Check for default admin password + admin_password = config.get('repeater', {}).get('security', {}).get('admin_password', '') has_default_password = admin_password in ['admin123', ''] - - # Needs setup if either condition is true + needs_setup = has_default_name or has_default_password - + return {'needs_setup': needs_setup, 'reasons': { 'default_name': has_default_name, 'default_password': has_default_password @@ -262,32 +259,35 @@ class APIEndpoints: import json # Check config-based location first, then development location - config_dir = Path(self.config.get("storage_dir", "/var/lib/pymc_repeater")) + storage_cfg = self.config.get("storage", {}) + config_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) installed_path = config_dir / 'radio-settings.json' dev_path = os.path.join(os.path.dirname(__file__), '..', '..', 'radio-settings.json') hardware_file = str(installed_path) if installed_path.exists() else dev_path - - if not os.path.exists(hardware_file): - logger.error(f"Hardware file not found. Tried: {installed_path}, {dev_path}") - return {'error': 'Hardware configuration file not found', 'hardware': []} - - with open(hardware_file, 'r') as f: - hardware_data = json.load(f) - - # Parse hardware options from the "hardware" key hardware_list = [] - hardware_configs = hardware_data.get('hardware', {}) - - for hw_key, hw_config in hardware_configs.items(): - if isinstance(hw_config, dict): - hardware_list.append({ - 'key': hw_key, - 'name': hw_config.get('name', hw_key), - 'description': hw_config.get('description', ''), - 'config': hw_config - }) - + + if os.path.exists(hardware_file): + with open(hardware_file, 'r') as f: + hardware_data = json.load(f) + hardware_configs = hardware_data.get('hardware', {}) + for hw_key, hw_config in hardware_configs.items(): + if isinstance(hw_config, dict): + hardware_list.append({ + 'key': hw_key, + 'name': hw_config.get('name', hw_key), + 'description': hw_config.get('description', ''), + 'config': hw_config + }) + + # Add MeshCore KISS modem option (serial TNC) + hardware_list.append({ + 'key': 'kiss', + 'name': 'KISS modem (serial)', + 'description': 'MeshCore KISS modem over serial – requires pyMC_core with KISS support', + 'config': {} + }) + return {'hardware': hardware_list} except Exception as e: logger.error(f"Error loading hardware options: {e}") @@ -301,7 +301,8 @@ class APIEndpoints: import json # Check config-based location first, then development location - config_dir = Path(self.config.get("storage_dir", "/var/lib/pymc_repeater")) + storage_cfg = self.config.get("storage", {}) + config_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) installed_path = config_dir / 'radio-presets.json' dev_path = os.path.join(os.path.dirname(__file__), '..', '..', 'radio-presets.json') @@ -351,105 +352,100 @@ class APIEndpoints: if not admin_password or len(admin_password) < 6: return {'success': False, 'error': 'Admin password must be at least 6 characters'} - # Load hardware configuration - check installed path first, then dev path import json - config_dir = Path(self.config.get("storage_dir", "/var/lib/pymc_repeater")) - installed_path = config_dir / 'radio-settings.json' - dev_path = os.path.join(os.path.dirname(__file__), '..', '..', 'radio-settings.json') - - hardware_file = str(installed_path) if installed_path.exists() else dev_path - - if not os.path.exists(hardware_file): - logger.error(f"Hardware file not found. Tried: {installed_path}, {dev_path}") - return {'success': False, 'error': 'Hardware configuration file not found'} - - with open(hardware_file, 'r') as f: - hardware_data = json.load(f) - - # Get hardware config from nested "hardware" key - hardware_configs = hardware_data.get('hardware', {}) - hw_config = hardware_configs.get(hardware_key, {}) - if not hw_config: - return {'success': False, 'error': f'Hardware configuration not found: {hardware_key}'} - - # Prepare configuration updates import yaml - - # Read current config + + # Read current config first so we can update it with open(self._config_path, 'r') as f: config_yaml = yaml.safe_load(f) - + # Update repeater settings if 'repeater' not in config_yaml: config_yaml['repeater'] = {} config_yaml['repeater']['node_name'] = node_name - + if 'security' not in config_yaml['repeater']: config_yaml['repeater']['security'] = {} config_yaml['repeater']['security']['admin_password'] = admin_password - - # Update radio settings - convert MHz/kHz to Hz + + # Update radio settings - convert MHz/kHz to Hz (used for both SX1262 and KISS modem) if 'radio' not in config_yaml: config_yaml['radio'] = {} - freq_mhz = float(radio_preset.get('frequency', 0)) bw_khz = float(radio_preset.get('bandwidth', 0)) - config_yaml['radio']['frequency'] = int(freq_mhz * 1000000) config_yaml['radio']['spreading_factor'] = int(radio_preset.get('spreading_factor', 7)) config_yaml['radio']['bandwidth'] = int(bw_khz * 1000) config_yaml['radio']['coding_rate'] = int(radio_preset.get('coding_rate', 5)) - - # Handle hardware-specific TX power (can be overridden by user later) - if 'tx_power' in hw_config: - config_yaml['radio']['tx_power'] = hw_config.get('tx_power', 22) - - # Handle preamble length (goes in radio section) - if 'preamble_length' in hw_config: - config_yaml['radio']['preamble_length'] = hw_config.get('preamble_length', 17) - - # Update hardware-specific settings under sx1262 section - if 'sx1262' not in config_yaml: - config_yaml['sx1262'] = {} - - # SPI configuration - if 'bus_id' in hw_config: - config_yaml['sx1262']['bus_id'] = hw_config.get('bus_id', 0) - if 'cs_id' in hw_config: - config_yaml['sx1262']['cs_id'] = hw_config.get('cs_id', 0) - - # Pin configuration - if 'reset_pin' in hw_config: - config_yaml['sx1262']['reset_pin'] = hw_config.get('reset_pin', 22) - if 'busy_pin' in hw_config: - config_yaml['sx1262']['busy_pin'] = hw_config.get('busy_pin', 17) - if 'irq_pin' in hw_config: - config_yaml['sx1262']['irq_pin'] = hw_config.get('irq_pin', 16) - if 'txen_pin' in hw_config: - config_yaml['sx1262']['txen_pin'] = hw_config.get('txen_pin', -1) - if 'rxen_pin' in hw_config: - config_yaml['sx1262']['rxen_pin'] = hw_config.get('rxen_pin', -1) - if 'cs_pin' in hw_config: - config_yaml['sx1262']['cs_pin'] = hw_config.get('cs_pin', -1) - if 'txled_pin' in hw_config: - config_yaml['sx1262']['txled_pin'] = hw_config.get('txled_pin', -1) - if 'rxled_pin' in hw_config: - config_yaml['sx1262']['rxled_pin'] = hw_config.get('rxled_pin', -1) - - # Hardware flags - if 'use_dio3_tcxo' in hw_config: - config_yaml['sx1262']['use_dio3_tcxo'] = hw_config.get('use_dio3_tcxo', False) - if 'use_dio2_rf' in hw_config: - config_yaml['sx1262']['use_dio2_rf'] = hw_config.get('use_dio2_rf', False) - if 'is_waveshare' in hw_config: - config_yaml['sx1262']['is_waveshare'] = hw_config.get('is_waveshare', False) - + + if hardware_key == 'kiss': + # KISS modem: set radio_type and kiss section (port/baud from request or defaults) + config_yaml['radio_type'] = 'kiss' + kiss_port = (data.get('kiss_port') or '').strip() or '/dev/ttyUSB0' + kiss_baud = int(data.get('kiss_baud_rate', data.get('kiss_baud', 115200))) + config_yaml['kiss'] = {'port': kiss_port, 'baud_rate': kiss_baud} + config_yaml['radio']['tx_power'] = int(radio_preset.get('tx_power', 14)) + if 'preamble_length' not in config_yaml['radio']: + config_yaml['radio']['preamble_length'] = 17 + else: + # SX1262: load hardware config from radio-settings.json + storage_cfg = self.config.get("storage", {}) + config_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) + installed_path = config_dir / 'radio-settings.json' + dev_path = os.path.join(os.path.dirname(__file__), '..', '..', 'radio-settings.json') + hardware_file = str(installed_path) if installed_path.exists() else dev_path + if not os.path.exists(hardware_file): + return {'success': False, 'error': 'Hardware configuration file not found'} + with open(hardware_file, 'r') as f: + hardware_data = json.load(f) + hardware_configs = hardware_data.get('hardware', {}) + hw_config = hardware_configs.get(hardware_key, {}) + if not hw_config: + return {'success': False, 'error': f'Hardware configuration not found: {hardware_key}'} + + config_yaml['radio_type'] = 'sx1262' + if 'tx_power' in hw_config: + config_yaml['radio']['tx_power'] = hw_config.get('tx_power', 22) + if 'preamble_length' in hw_config: + config_yaml['radio']['preamble_length'] = hw_config.get('preamble_length', 17) + + if 'sx1262' not in config_yaml: + config_yaml['sx1262'] = {} + if 'bus_id' in hw_config: + config_yaml['sx1262']['bus_id'] = hw_config.get('bus_id', 0) + if 'cs_id' in hw_config: + config_yaml['sx1262']['cs_id'] = hw_config.get('cs_id', 0) + if 'reset_pin' in hw_config: + config_yaml['sx1262']['reset_pin'] = hw_config.get('reset_pin', 22) + if 'busy_pin' in hw_config: + config_yaml['sx1262']['busy_pin'] = hw_config.get('busy_pin', 17) + if 'irq_pin' in hw_config: + config_yaml['sx1262']['irq_pin'] = hw_config.get('irq_pin', 16) + if 'txen_pin' in hw_config: + config_yaml['sx1262']['txen_pin'] = hw_config.get('txen_pin', -1) + if 'rxen_pin' in hw_config: + config_yaml['sx1262']['rxen_pin'] = hw_config.get('rxen_pin', -1) + if 'cs_pin' in hw_config: + config_yaml['sx1262']['cs_pin'] = hw_config.get('cs_pin', -1) + if 'txled_pin' in hw_config: + config_yaml['sx1262']['txled_pin'] = hw_config.get('txled_pin', -1) + if 'rxled_pin' in hw_config: + config_yaml['sx1262']['rxled_pin'] = hw_config.get('rxled_pin', -1) + if 'use_dio3_tcxo' in hw_config: + config_yaml['sx1262']['use_dio3_tcxo'] = hw_config.get('use_dio3_tcxo', False) + if 'use_dio2_rf' in hw_config: + config_yaml['sx1262']['use_dio2_rf'] = hw_config.get('use_dio2_rf', False) + if 'is_waveshare' in hw_config: + config_yaml['sx1262']['is_waveshare'] = hw_config.get('is_waveshare', False) + # Write updated config with open(self._config_path, 'w') as f: yaml.dump(config_yaml, f, default_flow_style=False, sort_keys=False) - logger.info(f"Setup wizard completed: node_name={node_name}, hardware={hardware_key}, freq={freq_mhz}MHz") - + logger.info( + f"Setup wizard completed: node_name={node_name}, hardware={hardware_key}, freq={freq_mhz}MHz" + ) + # Trigger service restart after setup import subprocess import threading @@ -467,18 +463,19 @@ class APIEndpoints: restart_thread = threading.Thread(target=delayed_restart, daemon=True) restart_thread.start() - return { - 'success': True, - 'message': 'Setup completed successfully. Service is restarting...', - 'config': { - 'node_name': node_name, - 'hardware': hardware_key, - 'frequency': freq_mhz, - 'spreading_factor': radio_preset.get('spreading_factor'), - 'bandwidth': radio_preset.get('bandwidth'), - 'coding_rate': radio_preset.get('coding_rate') - } + result_config = { + 'node_name': node_name, + 'hardware': hardware_key, + 'radio_type': config_yaml.get('radio_type', 'sx1262'), + 'frequency': freq_mhz, + 'spreading_factor': radio_preset.get('spreading_factor'), + 'bandwidth': radio_preset.get('bandwidth'), + 'coding_rate': radio_preset.get('coding_rate') } + if hardware_key == 'kiss': + result_config['kiss_port'] = config_yaml.get('kiss', {}).get('port') + result_config['kiss_baud_rate'] = config_yaml.get('kiss', {}).get('baud_rate') + return {'success': True, 'message': 'Setup completed successfully. Service is restarting...', 'config': result_config} except cherrypy.HTTPError: raise @@ -1307,15 +1304,31 @@ class APIEndpoints: return self._error("Advert interval must be 0 (off) or 1-10080 minutes") self.config["repeater"]["advert_interval_minutes"] = mins applied.append(f"advert.interval={mins}m") + + # KISS modem settings (only when radio_type is kiss) + if "kiss_port" in data or "kiss_baud_rate" in data: + if self.config.get("radio_type") != "kiss": + return self._error("KISS settings only apply when radio_type is kiss") + if "kiss" not in self.config: + self.config["kiss"] = {} + if "kiss_port" in data: + self.config["kiss"]["port"] = str(data["kiss_port"]).strip() + applied.append("kiss.port") + if "kiss_baud_rate" in data: + self.config["kiss"]["baud_rate"] = int(data["kiss_baud_rate"]) + applied.append("kiss.baud_rate") if not applied: return self._error("No valid settings provided") + live_sections = ['repeater', 'delays', 'radio'] + if "kiss" in self.config: + live_sections.append("kiss") # Save to config file and live update daemon in one operation result = self.config_manager.update_and_save( updates={}, # Updates already applied to self.config above live_update=True, - live_update_sections=['repeater', 'delays', 'radio'] + live_update_sections=live_sections ) logger.info(f"Radio config updated: {', '.join(applied)}") diff --git a/repeater/web/html/assets/Setup-CSawSnc5.js b/repeater/web/html/assets/Setup-CSawSnc5.js index 82e1677..6ce3729 100644 --- a/repeater/web/html/assets/Setup-CSawSnc5.js +++ b/repeater/web/html/assets/Setup-CSawSnc5.js @@ -1 +1 @@ -import{d as A,r as l,c as P,a as W,o as I,b as a,e,f as B,_ as Y,t as i,u as o,n as J,g as k,F as N,h as z,i as K,w as h,v as C,j as _,k as V,l as q,T,m as Q,p as n,q as E,s as X,x as Z}from"./index-sHch0610.js";const ee=A("setup",()=>{const m=l(1),r=l(5),y=l(`pyRpt${Math.floor(Math.random()*1e4).toString().padStart(4,"0")}`),b=l(null),v=l(null),x=l(""),f=l(""),w=l(!1),c=l({frequency:"915.0",spreading_factor:"7",bandwidth:"125",coding_rate:"5"}),R=l([]),j=l([]),g=l(!1),S=l(!1),u=l(null),t=P(()=>{switch(m.value){case 1:return!0;case 2:return y.value.trim().length>0;case 3:return b.value!==null;case 4:return w.value?c.value.frequency&&c.value.spreading_factor&&c.value.bandwidth&&c.value.coding_rate:v.value!==null;case 5:return x.value.length>=6&&x.value===f.value;default:return!1}}),s=P(()=>m.value>1),M=P(()=>m.value===r.value);async function F(){g.value=!0,u.value=null;try{const p=await(await fetch("/api/hardware_options")).json();if(p.error)throw new Error(p.error);R.value=p.hardware||[]}catch(d){u.value=d instanceof Error?d.message:"Failed to load hardware options",console.error("Error fetching hardware options:",d)}finally{g.value=!1}}async function H(){g.value=!0,u.value=null;try{const p=await(await fetch("/api/radio_presets")).json();if(p.error)throw new Error(p.error);j.value=p.presets||[]}catch(d){u.value=d instanceof Error?d.message:"Failed to load radio presets",console.error("Error fetching radio presets:",d)}finally{g.value=!1}}async function U(){if(!t.value)return{success:!1,error:"Please complete all required fields"};S.value=!0,u.value=null;try{const d=w.value?{title:"Custom Configuration",description:"Custom radio settings",frequency:c.value.frequency,spreading_factor:c.value.spreading_factor,bandwidth:c.value.bandwidth,coding_rate:c.value.coding_rate}:v.value,L=await(await fetch("/api/setup_wizard",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({node_name:y.value.trim(),hardware_key:b.value?.key,radio_preset:d,admin_password:x.value})})).json();if(!L.success)throw new Error(L.error||"Setup failed");return{success:!0,data:L}}catch(d){const p=d instanceof Error?d.message:"Failed to complete setup";return u.value=p,{success:!1,error:p}}finally{S.value=!1}}function O(){t.value&&m.value=1&&d<=r.value&&(m.value=d)}function D(){m.value=1,y.value=`pyRpt${Math.floor(Math.random()*1e4).toString().padStart(4,"0")}`,b.value=null,v.value=null,w.value=!1,c.value={frequency:"915.0",spreading_factor:"7",bandwidth:"125",coding_rate:"5"},x.value="",f.value="",u.value=null}return{currentStep:m,totalSteps:r,nodeName:y,selectedHardware:b,selectedRadioPreset:v,useCustomRadio:w,customRadio:c,adminPassword:x,confirmPassword:f,hardwareOptions:R,radioPresets:j,isLoading:g,isSubmitting:S,error:u,canGoNext:t,canGoBack:s,isLastStep:M,fetchHardwareOptions:F,fetchRadioPresets:H,completeSetup:U,nextStep:O,previousStep:$,goToStep:G,reset:D}}),te={class:"min-h-screen bg-background dark:bg-background overflow-hidden relative flex items-center justify-center p-4"},re={class:"absolute top-4 right-4 z-20"},oe={class:"w-full max-w-4xl relative z-10"},se={class:"mb-8"},ae={class:"flex justify-between mb-2"},ne={class:"text-content-secondary dark:text-content-muted text-sm"},de={class:"text-content-secondary dark:text-content-muted text-sm"},ie={class:"h-2 bg-stroke-subtle dark:bg-stroke/10 rounded-full overflow-hidden"},le={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[20px] p-6 sm:p-8 md:p-12"},ue={class:"flex justify-center mb-8"},ce={class:"flex gap-2"},pe={class:"mb-8"},me={class:"text-2xl sm:text-3xl font-bold text-content-primary dark:text-content-primary mb-2 text-center"},be={key:0,class:"space-y-6 mt-8"},fe={key:1,class:"space-y-6 mt-8"},xe={class:"max-w-md mx-auto"},ke={key:2,class:"space-y-6 mt-8"},ve={key:0,class:"text-center text-content-secondary dark:text-content-muted"},ge={key:1,class:"text-center text-content-secondary dark:text-content-muted"},ye={key:2,class:"grid grid-cols-1 md:grid-cols-2 gap-4 max-w-3xl mx-auto"},he=["onClick"],we={class:"font-medium text-content-primary dark:text-content-primary mb-1"},_e={class:"text-sm text-content-secondary dark:text-content-muted"},Se={key:3,class:"space-y-6 mt-8"},Ce={key:0,class:"text-center text-content-secondary dark:text-content-muted"},Re={key:1,class:"text-center text-content-secondary dark:text-content-muted"},je={key:2,class:"max-w-5xl mx-auto"},Pe={class:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4"},Me=["onClick"],Le={class:"relative z-10"},Be={class:"font-medium text-content-primary dark:text-content-primary mb-1 flex items-start justify-between gap-2"},Ne={class:"flex items-center gap-2"},ze={class:"text-2xl"},Ve={key:0,class:"text-primary flex-shrink-0"},qe={class:"text-xs text-content-secondary dark:text-content-muted mb-3"},Te={class:"grid grid-cols-2 gap-2 text-xs"},Ee={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},Fe={class:"text-content-primary dark:text-content-primary/80 font-medium"},He={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},Ue={class:"text-content-primary dark:text-content-primary/80 font-medium"},Oe={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},$e={class:"text-content-primary dark:text-content-primary/80 font-medium"},Ge={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},De={class:"text-content-primary dark:text-content-primary/80 font-medium"},Ae={class:"border-t border-stroke-subtle dark:border-stroke/10 pt-6"},We={class:"flex items-center justify-between mb-2"},Ie={key:0,class:"text-primary"},Ye={key:0,class:"mt-4 grid grid-cols-2 gap-4"},Je={key:4,class:"space-y-6 mt-8"},Ke={class:"max-w-md mx-auto space-y-4"},Qe={key:0,class:"text-red-600 dark:text-red-400 text-sm"},Xe={key:0,class:"mb-6 bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-600 dark:text-red-200"},Ze={class:"flex justify-between gap-4"},et={key:1},tt=["disabled"],rt={key:0,class:"w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"},ot={key:1},st={key:2},at={key:3},nt={key:4,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},dt={class:"flex justify-center mb-6"},it={key:0,class:"w-16 h-16 rounded-full bg-green-100 dark:bg-green-500/20 flex items-center justify-center"},lt={key:1,class:"w-16 h-16 rounded-full bg-red-100 dark:bg-red-500/20 flex items-center justify-center"},ut={class:"text-2xl font-bold text-content-primary dark:text-content-primary text-center mb-4"},ct={class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"},pt=W({name:"SetupView",__name:"Setup",setup(m){const r=ee(),y=Q(),b=l(!1),v=l(""),x=l(""),f=l("success"),w=u=>{const t=u.toLowerCase();return t.includes("australia")?"🇦🇺":t.includes("eu")||t.includes("uk")?"🇪🇺":t.includes("czech")?"🇨🇿":t.includes("new zealand")?"🇳🇿":t.includes("portugal")?"🇵🇹":t.includes("switzerland")?"🇨🇭":t.includes("usa")||t.includes("canada")?"🇺🇸":t.includes("vietnam")?"🇻🇳":"🌍"};I(async()=>{await Promise.all([r.fetchHardwareOptions(),r.fetchRadioPresets()])});const c=P(()=>r.currentStep/r.totalSteps*100);async function R(){if(r.isLastStep){const u=await r.completeSetup();u.success?(f.value="success",v.value="Setup Complete!",x.value="Your repeater has been configured successfully. The service is restarting now...",b.value=!0,setTimeout(()=>{b.value=!1,y.push("/login")},5e3)):(f.value="error",v.value="Setup Failed",x.value=u.error||"An unknown error occurred",b.value=!0)}else r.nextStep()}function j(){r.previousStep()}function g(){b.value=!1,f.value==="success"&&y.push("/login")}const S=["Welcome","Repeater Name","Hardware Selection","Radio Configuration","Security Setup"];return(u,t)=>(n(),a("div",te,[e("div",re,[B(Y)]),t[36]||(t[36]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slow -top-[79px] left-[575px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[37]||(t[37]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-75 animate-pulse-slower -top-[94px] -left-[92px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[38]||(t[38]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slowest top-[373px] left-[246px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),e("div",oe,[e("div",se,[e("div",ae,[e("span",ne,"Step "+i(o(r).currentStep)+" of "+i(o(r).totalSteps),1),e("span",de,i(Math.round(c.value))+"% Complete",1)]),e("div",ie,[e("div",{class:"h-full bg-gradient-to-r from-primary to-primary/80 transition-all duration-500",style:J({width:`${c.value}%`})},null,4)])]),e("div",le,[e("div",ue,[e("div",ce,[(n(!0),a(N,null,z(o(r).totalSteps,s=>(n(),a("div",{key:s,class:_(["w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-all",s===o(r).currentStep?"bg-primary text-white":s

Welcome to your pyMC Repeater! Let's get you set up in just a few steps.

You'll configure:

  • Repeater name and identification
  • Hardware board selection
  • Radio frequency and settings
  • Admin password for secure access
',1)]))):o(r).currentStep===2?(n(),a("div",fe,[t[12]||(t[12]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Choose a unique name for your repeater. This will be used for identification on the mesh network. ",-1)),e("div",xe,[t[10]||(t[10]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Repeater Name",-1)),h(e("input",{"onUpdate:modelValue":t[0]||(t[0]=s=>o(r).nodeName=s),type:"text",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"e.g., pyRpt0001",maxlength:"32"},null,512),[[C,o(r).nodeName]]),t[11]||(t[11]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-2"}," Use letters, numbers, hyphens, or underscores (3-32 characters) ",-1))])])):o(r).currentStep===3?(n(),a("div",ke,[t[13]||(t[13]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Select your hardware board type ",-1)),o(r).isLoading?(n(),a("div",ve," Loading hardware options... ")):o(r).hardwareOptions.length===0?(n(),a("div",ge," No hardware options available ")):(n(),a("div",ye,[(n(!0),a(N,null,z(o(r).hardwareOptions,s=>(n(),a("button",{key:s.key,onClick:M=>o(r).selectedHardware=s,class:_(["p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm",o(r).selectedHardware?.key===s.key?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",we,i(s.name),1),e("div",_e,i(s.description||s.key),1)],10,he))),128))]))])):o(r).currentStep===4?(n(),a("div",Se,[t[28]||(t[28]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Choose a radio configuration preset for your region or create a custom configuration ",-1)),o(r).isLoading?(n(),a("div",Ce," Loading radio presets... ")):o(r).radioPresets.length===0?(n(),a("div",Re," No radio presets available ")):(n(),a("div",je,[e("div",Pe,[(n(!0),a(N,null,z(o(r).radioPresets,s=>(n(),a("button",{key:s.title,onClick:M=>{o(r).selectedRadioPreset=s,o(r).useCustomRadio=!1},class:_(["p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm relative overflow-hidden",!o(r).useCustomRadio&&o(r).selectedRadioPreset?.title===s.title?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",Le,[e("div",Be,[e("span",Ne,[e("span",ze,i(w(s.title)),1),e("span",null,i(s.title),1)]),!o(r).useCustomRadio&&o(r).selectedRadioPreset?.title===s.title?(n(),a("div",Ve,t[14]||(t[14]=[e("svg",{class:"w-5 h-5",fill:"currentColor",viewBox:"0 0 20 20"},[e("path",{"fill-rule":"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z","clip-rule":"evenodd"})],-1)]))):k("",!0)]),e("div",qe,i(s.description),1),e("div",Te,[e("div",Ee,[t[15]||(t[15]=e("div",{class:"text-content-muted dark:text-content-muted"},"Freq",-1)),e("div",Fe,i(s.frequency),1)]),e("div",He,[t[16]||(t[16]=e("div",{class:"text-content-muted dark:text-content-muted"},"BW",-1)),e("div",Ue,i(s.bandwidth),1)]),e("div",Oe,[t[17]||(t[17]=e("div",{class:"text-content-muted dark:text-content-muted"},"SF",-1)),e("div",$e,i(s.spreading_factor),1)]),e("div",Ge,[t[18]||(t[18]=e("div",{class:"text-content-muted dark:text-content-muted"},"CR",-1)),e("div",De,i(s.coding_rate),1)])])])],10,Me))),128))]),e("div",Ae,[e("button",{onClick:t[1]||(t[1]=s=>{o(r).useCustomRadio=!o(r).useCustomRadio,o(r).useCustomRadio&&(o(r).selectedRadioPreset=null)}),class:_(["w-full p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm",o(r).useCustomRadio?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",We,[t[20]||(t[20]=e("div",{class:"font-medium text-content-primary dark:text-content-primary flex items-center gap-2"},[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"})]),V(" Custom Configuration ")],-1)),o(r).useCustomRadio?(n(),a("div",Ie,t[19]||(t[19]=[e("svg",{class:"w-5 h-5",fill:"currentColor",viewBox:"0 0 20 20"},[e("path",{"fill-rule":"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z","clip-rule":"evenodd"})],-1)]))):k("",!0)]),t[21]||(t[21]=e("div",{class:"text-xs text-content-secondary dark:text-content-muted"},"Manually configure frequency, bandwidth, spreading factor, and coding rate",-1))],2),B(T,{name:"slide"},{default:q(()=>[o(r).useCustomRadio?(n(),a("div",Ye,[e("div",null,[t[22]||(t[22]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Frequency (MHz)",-1)),h(e("input",{"onUpdate:modelValue":t[2]||(t[2]=s=>o(r).customRadio.frequency=s),type:"number",step:"0.1",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all",placeholder:"915.0"},null,512),[[C,o(r).customRadio.frequency]])]),e("div",null,[t[23]||(t[23]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Bandwidth (kHz)",-1)),h(e("input",{"onUpdate:modelValue":t[3]||(t[3]=s=>o(r).customRadio.bandwidth=s),type:"number",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all",placeholder:"125"},null,512),[[C,o(r).customRadio.bandwidth]])]),e("div",null,[t[25]||(t[25]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Spreading Factor",-1)),h(e("select",{"onUpdate:modelValue":t[4]||(t[4]=s=>o(r).customRadio.spreading_factor=s),class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all"},t[24]||(t[24]=[e("option",{value:"7"},"7",-1),e("option",{value:"8"},"8",-1),e("option",{value:"9"},"9",-1),e("option",{value:"10"},"10",-1),e("option",{value:"11"},"11",-1),e("option",{value:"12"},"12",-1)]),512),[[E,o(r).customRadio.spreading_factor]])]),e("div",null,[t[27]||(t[27]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Coding Rate",-1)),h(e("select",{"onUpdate:modelValue":t[5]||(t[5]=s=>o(r).customRadio.coding_rate=s),class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all"},t[26]||(t[26]=[e("option",{value:"5"},"4/5",-1),e("option",{value:"6"},"4/6",-1),e("option",{value:"7"},"4/7",-1),e("option",{value:"8"},"4/8",-1)]),512),[[E,o(r).customRadio.coding_rate]])])])):k("",!0)]),_:1})])]))])):o(r).currentStep===5?(n(),a("div",Je,[t[32]||(t[32]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Set a secure admin password to protect your repeater ",-1)),e("div",Ke,[e("div",null,[t[29]||(t[29]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Admin Password",-1)),h(e("input",{"onUpdate:modelValue":t[6]||(t[6]=s=>o(r).adminPassword=s),type:"password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"Enter password (min 6 characters)",minlength:"6"},null,512),[[C,o(r).adminPassword]])]),e("div",null,[t[30]||(t[30]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Confirm Password",-1)),h(e("input",{"onUpdate:modelValue":t[7]||(t[7]=s=>o(r).confirmPassword=s),type:"password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"Confirm password"},null,512),[[C,o(r).confirmPassword]])]),o(r).adminPassword&&o(r).confirmPassword&&o(r).adminPassword!==o(r).confirmPassword?(n(),a("div",Qe," Passwords do not match ")):k("",!0),t[31]||(t[31]=e("div",{class:"bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3 text-sm text-yellow-800 dark:text-yellow-200"},[e("strong",null,"Important:"),V(" Remember this password - you'll need it to access the dashboard. ")],-1))])])):k("",!0)]),o(r).error?(n(),a("div",Xe,i(o(r).error),1)):k("",!0),e("div",Ze,[o(r).canGoBack?(n(),a("button",{key:0,onClick:j,class:"px-6 py-3 rounded-[12px] bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 text-content-primary dark:text-content-primary hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20 transition-all duration-300 font-medium"}," Back ")):(n(),a("div",et)),e("button",{onClick:R,disabled:!o(r).canGoNext||o(r).isSubmitting,class:_(["px-8 py-3 rounded-[12px] font-semibold transition-all duration-300 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed",o(r).canGoNext&&!o(r).isSubmitting?"bg-gradient-to-r from-primary/20 to-primary/10 hover:from-primary/30 hover:to-primary/20 text-white border border-primary/30 hover:border-primary/50":"bg-background-mute dark:bg-stroke/5 text-content-muted dark:text-content-muted border border-stroke-subtle dark:border-stroke/10"])},[o(r).isSubmitting?(n(),a("div",rt)):k("",!0),o(r).isSubmitting?(n(),a("span",ot,"Setting up...")):o(r).isLastStep?(n(),a("span",st,"Complete Setup")):(n(),a("span",at,"Next")),!o(r).isSubmitting&&!o(r).isLastStep?(n(),a("svg",nt,t[33]||(t[33]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 5l7 7-7 7"},null,-1)]))):k("",!0)],10,tt)])])]),B(T,{name:"modal"},{default:q(()=>[b.value?(n(),a("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm",onClick:g},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl max-w-md w-full p-8 rounded-[24px] border border-stroke-subtle dark:border-white/20 shadow-[0_8px_32px_0_rgba(0,0,0,0.37)]",onClick:t[8]||(t[8]=X(()=>{},["stop"]))},[e("div",dt,[f.value==="success"?(n(),a("div",it,t[34]||(t[34]=[e("svg",{class:"w-8 h-8 text-green-600 dark:text-green-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"})],-1)]))):(n(),a("div",lt,t[35]||(t[35]=[e("svg",{class:"w-8 h-8 text-red-600 dark:text-red-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])))]),e("h3",ut,i(v.value),1),e("p",ct,i(x.value),1),e("button",{onClick:g,class:_(["w-full px-6 py-3 rounded-lg font-medium transition-all",f.value==="success"?"bg-gradient-to-r from-primary/20 to-primary/10 hover:from-primary/30 hover:to-primary/20 text-white":"bg-gradient-to-r from-red-500/20 to-red-500/10 hover:from-red-500/30 hover:to-red-500/20 text-white"])},i(f.value==="success"?"Continue to Login":"Close"),3)])])):k("",!0)]),_:1})]))}}),bt=Z(pt,[["__scopeId","data-v-20a8772f"]]);export{bt as default}; +import{d as A,r as l,c as P,a as W,o as I,b as a,e,f as B,_ as Y,t as i,u as o,n as J,g as k,F as N,h as z,i as K,w as h,v as C,j as _,k as V,l as q,T,m as Q,p as n,q as E,s as X,x as Z}from"./index-sHch0610.js";const ee=A("setup",()=>{const m=l(1),r=l(5),y=l(`pyRpt${Math.floor(Math.random()*1e4).toString().padStart(4,"0")}`),b=l(null),v=l(null),x=l(""),f=l(""),w=l(!1),c=l({frequency:"915.0",spreading_factor:"7",bandwidth:"125",coding_rate:"5"}),R=l([]),j=l([]),g=l(!1),S=l(!1),u=l(null),kp=l("/dev/ttyUSB0"),kb=l("115200"),t=P(()=>{switch(m.value){case 1:return!0;case 2:return y.value.trim().length>0;case 3:return b.value!==null&&(b.value?.key!=="kiss"||(kp.value&&String(kp.value).trim().length>0));case 4:return w.value?c.value.frequency&&c.value.spreading_factor&&c.value.bandwidth&&c.value.coding_rate:v.value!==null;case 5:return x.value.length>=6&&x.value===f.value;default:return!1}}),s=P(()=>m.value>1),M=P(()=>m.value===r.value);async function F(){g.value=!0,u.value=null;try{const p=await(await fetch("/api/hardware_options")).json();if(p.error)throw new Error(p.error);R.value=p.hardware||[]}catch(d){u.value=d instanceof Error?d.message:"Failed to load hardware options",console.error("Error fetching hardware options:",d)}finally{g.value=!1}}async function H(){g.value=!0,u.value=null;try{const p=await(await fetch("/api/radio_presets")).json();if(p.error)throw new Error(p.error);j.value=p.presets||[]}catch(d){u.value=d instanceof Error?d.message:"Failed to load radio presets",console.error("Error fetching radio presets:",d)}finally{g.value=!1}}async function U(){if(!t.value)return{success:!1,error:"Please complete all required fields"};S.value=!0,u.value=null;try{const d=w.value?{title:"Custom Configuration",description:"Custom radio settings",frequency:c.value.frequency,spreading_factor:c.value.spreading_factor,bandwidth:c.value.bandwidth,coding_rate:c.value.coding_rate}:v.value;const payload={node_name:y.value.trim(),hardware_key:b.value?.key,radio_preset:d,admin_password:x.value};if(b.value?.key==="kiss"){payload.kiss_port=kp.value||"/dev/ttyUSB0";payload.kiss_baud_rate=Number(kb.value)||115200;}const L=await(await fetch("/api/setup_wizard",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json();if(!L.success)throw new Error(L.error||"Setup failed");return{success:!0,data:L}}catch(d){const p=d instanceof Error?d.message:"Failed to complete setup";return u.value=p,{success:!1,error:p}}finally{S.value=!1}}function O(){t.value&&m.value=1&&d<=r.value&&(m.value=d)}function D(){m.value=1,y.value=`pyRpt${Math.floor(Math.random()*1e4).toString().padStart(4,"0")}`,b.value=null,v.value=null,w.value=!1,c.value={frequency:"915.0",spreading_factor:"7",bandwidth:"125",coding_rate:"5"},x.value="",f.value="",u.value=null,kp.value="/dev/ttyUSB0",kb.value="115200"}return{currentStep:m,totalSteps:r,nodeName:y,selectedHardware:b,selectedRadioPreset:v,useCustomRadio:w,customRadio:c,adminPassword:x,confirmPassword:f,hardwareOptions:R,radioPresets:j,isLoading:g,isSubmitting:S,error:u,canGoNext:t,canGoBack:s,isLastStep:M,fetchHardwareOptions:F,fetchRadioPresets:H,completeSetup:U,nextStep:O,previousStep:$,goToStep:G,reset:D,kissPort:kp,kissBaud:kb}}),te={class:"min-h-screen bg-background dark:bg-background overflow-hidden relative flex items-center justify-center p-4"},re={class:"absolute top-4 right-4 z-20"},oe={class:"w-full max-w-4xl relative z-10"},se={class:"mb-8"},ae={class:"flex justify-between mb-2"},ne={class:"text-content-secondary dark:text-content-muted text-sm"},de={class:"text-content-secondary dark:text-content-muted text-sm"},ie={class:"h-2 bg-stroke-subtle dark:bg-stroke/10 rounded-full overflow-hidden"},le={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[20px] p-6 sm:p-8 md:p-12"},ue={class:"flex justify-center mb-8"},ce={class:"flex gap-2"},pe={class:"mb-8"},me={class:"text-2xl sm:text-3xl font-bold text-content-primary dark:text-content-primary mb-2 text-center"},be={key:0,class:"space-y-6 mt-8"},fe={key:1,class:"space-y-6 mt-8"},xe={class:"max-w-md mx-auto"},ke={key:2,class:"space-y-6 mt-8"},ve={key:0,class:"text-center text-content-secondary dark:text-content-muted"},ge={key:1,class:"text-center text-content-secondary dark:text-content-muted"},ye={key:2,class:"grid grid-cols-1 md:grid-cols-2 gap-4 max-w-3xl mx-auto"},he=["onClick"],we={class:"font-medium text-content-primary dark:text-content-primary mb-1"},_e={class:"text-sm text-content-secondary dark:text-content-muted"},Se={key:3,class:"space-y-6 mt-8"},Ce={key:0,class:"text-center text-content-secondary dark:text-content-muted"},Re={key:1,class:"text-center text-content-secondary dark:text-content-muted"},je={key:2,class:"max-w-5xl mx-auto"},Pe={class:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4"},Me=["onClick"],Le={class:"relative z-10"},Be={class:"font-medium text-content-primary dark:text-content-primary mb-1 flex items-start justify-between gap-2"},Ne={class:"flex items-center gap-2"},ze={class:"text-2xl"},Ve={key:0,class:"text-primary flex-shrink-0"},qe={class:"text-xs text-content-secondary dark:text-content-muted mb-3"},Te={class:"grid grid-cols-2 gap-2 text-xs"},Ee={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},Fe={class:"text-content-primary dark:text-content-primary/80 font-medium"},He={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},Ue={class:"text-content-primary dark:text-content-primary/80 font-medium"},Oe={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},$e={class:"text-content-primary dark:text-content-primary/80 font-medium"},Ge={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},De={class:"text-content-primary dark:text-content-primary/80 font-medium"},Ae={class:"border-t border-stroke-subtle dark:border-stroke/10 pt-6"},We={class:"flex items-center justify-between mb-2"},Ie={key:0,class:"text-primary"},Ye={key:0,class:"mt-4 grid grid-cols-2 gap-4"},Je={key:4,class:"space-y-6 mt-8"},Ke={class:"max-w-md mx-auto space-y-4"},Qe={key:0,class:"text-red-600 dark:text-red-400 text-sm"},Xe={key:0,class:"mb-6 bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-600 dark:text-red-200"},Ze={class:"flex justify-between gap-4"},et={key:1},tt=["disabled"],rt={key:0,class:"w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"},ot={key:1},st={key:2},at={key:3},nt={key:4,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},dt={class:"flex justify-center mb-6"},it={key:0,class:"w-16 h-16 rounded-full bg-green-100 dark:bg-green-500/20 flex items-center justify-center"},lt={key:1,class:"w-16 h-16 rounded-full bg-red-100 dark:bg-red-500/20 flex items-center justify-center"},ut={class:"text-2xl font-bold text-content-primary dark:text-content-primary text-center mb-4"},ct={class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"},pt=W({name:"SetupView",__name:"Setup",setup(m){const r=ee(),y=Q(),b=l(!1),v=l(""),x=l(""),f=l("success"),w=u=>{const t=u.toLowerCase();return t.includes("australia")?"🇦🇺":t.includes("eu")||t.includes("uk")?"🇪🇺":t.includes("czech")?"🇨🇿":t.includes("new zealand")?"🇳🇿":t.includes("portugal")?"🇵🇹":t.includes("switzerland")?"🇨🇭":t.includes("usa")||t.includes("canada")?"🇺🇸":t.includes("vietnam")?"🇻🇳":"🌍"};I(async()=>{await Promise.all([r.fetchHardwareOptions(),r.fetchRadioPresets()])});const c=P(()=>r.currentStep/r.totalSteps*100);async function R(){if(r.isLastStep){const u=await r.completeSetup();u.success?(f.value="success",v.value="Setup Complete!",x.value="Your repeater has been configured successfully. The service is restarting now...",b.value=!0,setTimeout(()=>{b.value=!1,y.push("/login")},5e3)):(f.value="error",v.value="Setup Failed",x.value=u.error||"An unknown error occurred",b.value=!0)}else r.nextStep()}function j(){r.previousStep()}function g(){b.value=!1,f.value==="success"&&y.push("/login")}const S=["Welcome","Repeater Name","Hardware Selection","Radio Configuration","Security Setup"];return(u,t)=>(n(),a("div",te,[e("div",re,[B(Y)]),t[36]||(t[36]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slow -top-[79px] left-[575px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[37]||(t[37]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-75 animate-pulse-slower -top-[94px] -left-[92px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[38]||(t[38]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slowest top-[373px] left-[246px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),e("div",oe,[e("div",se,[e("div",ae,[e("span",ne,"Step "+i(o(r).currentStep)+" of "+i(o(r).totalSteps),1),e("span",de,i(Math.round(c.value))+"% Complete",1)]),e("div",ie,[e("div",{class:"h-full bg-gradient-to-r from-primary to-primary/80 transition-all duration-500",style:J({width:`${c.value}%`})},null,4)])]),e("div",le,[e("div",ue,[e("div",ce,[(n(!0),a(N,null,z(o(r).totalSteps,s=>(n(),a("div",{key:s,class:_(["w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-all",s===o(r).currentStep?"bg-primary text-white":s

Welcome to your pyMC Repeater! Let's get you set up in just a few steps.

You'll configure:

  • Repeater name and identification
  • Hardware board selection
  • Radio frequency and settings
  • Admin password for secure access
',1)]))):o(r).currentStep===2?(n(),a("div",fe,[t[12]||(t[12]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Choose a unique name for your repeater. This will be used for identification on the mesh network. ",-1)),e("div",xe,[t[10]||(t[10]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Repeater Name",-1)),h(e("input",{"onUpdate:modelValue":t[0]||(t[0]=s=>o(r).nodeName=s),type:"text",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"e.g., pyRpt0001",maxlength:"32"},null,512),[[C,o(r).nodeName]]),t[11]||(t[11]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-2"}," Use letters, numbers, hyphens, or underscores (3-32 characters) ",-1))])])):o(r).currentStep===3?(n(),a("div",ke,[t[13]||(t[13]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Select your hardware board type ",-1)),o(r).isLoading?(n(),a("div",ve," Loading hardware options... ")):o(r).hardwareOptions.length===0?(n(),a("div",ge," No hardware options available ")):(n(),a("div",ye,[(n(!0),a(N,null,z(o(r).hardwareOptions,s=>(n(),a("button",{key:s.key,onClick:M=>o(r).selectedHardware=s,class:_(["p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm",o(r).selectedHardware?.key===s.key?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",we,i(s.name),1),e("div",_e,i(s.description||s.key),1)],10,he))),128))])),o(r).selectedHardware?.key==="kiss"?(n(),a("div",{class:"mt-6 max-w-md mx-auto space-y-4 border-t border-stroke-subtle dark:border-stroke/10 pt-6"},[e("div",null,[e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"MeshCore KISS modem – Serial port",-1),h(e("input",{"onUpdate:modelValue":M=>o(r).kissPort=M,class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"/dev/ttyUSB0"},null,512),[[C,o(r).kissPort]])]),e("div",null,[e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Baud rate",-1),h(e("input",{"onUpdate:modelValue":M=>o(r).kissBaud=M,type:"number",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"115200"},null,512),[[C,o(r).kissBaud]])]) ])):k("",!0)])):o(r).currentStep===4?(n(),a("div",Se,[t[28]||(t[28]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Choose a radio configuration preset for your region or create a custom configuration ",-1)),o(r).isLoading?(n(),a("div",Ce," Loading radio presets... ")):o(r).radioPresets.length===0?(n(),a("div",Re," No radio presets available ")):(n(),a("div",je,[e("div",Pe,[(n(!0),a(N,null,z(o(r).radioPresets,s=>(n(),a("button",{key:s.title,onClick:M=>{o(r).selectedRadioPreset=s,o(r).useCustomRadio=!1},class:_(["p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm relative overflow-hidden",!o(r).useCustomRadio&&o(r).selectedRadioPreset?.title===s.title?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",Le,[e("div",Be,[e("span",Ne,[e("span",ze,i(w(s.title)),1),e("span",null,i(s.title),1)]),!o(r).useCustomRadio&&o(r).selectedRadioPreset?.title===s.title?(n(),a("div",Ve,t[14]||(t[14]=[e("svg",{class:"w-5 h-5",fill:"currentColor",viewBox:"0 0 20 20"},[e("path",{"fill-rule":"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z","clip-rule":"evenodd"})],-1)]))):k("",!0)]),e("div",qe,i(s.description),1),e("div",Te,[e("div",Ee,[t[15]||(t[15]=e("div",{class:"text-content-muted dark:text-content-muted"},"Freq",-1)),e("div",Fe,i(s.frequency),1)]),e("div",He,[t[16]||(t[16]=e("div",{class:"text-content-muted dark:text-content-muted"},"BW",-1)),e("div",Ue,i(s.bandwidth),1)]),e("div",Oe,[t[17]||(t[17]=e("div",{class:"text-content-muted dark:text-content-muted"},"SF",-1)),e("div",$e,i(s.spreading_factor),1)]),e("div",Ge,[t[18]||(t[18]=e("div",{class:"text-content-muted dark:text-content-muted"},"CR",-1)),e("div",De,i(s.coding_rate),1)])])])],10,Me))),128))]),e("div",Ae,[e("button",{onClick:t[1]||(t[1]=s=>{o(r).useCustomRadio=!o(r).useCustomRadio,o(r).useCustomRadio&&(o(r).selectedRadioPreset=null)}),class:_(["w-full p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm",o(r).useCustomRadio?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",We,[t[20]||(t[20]=e("div",{class:"font-medium text-content-primary dark:text-content-primary flex items-center gap-2"},[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"})]),V(" Custom Configuration ")],-1)),o(r).useCustomRadio?(n(),a("div",Ie,t[19]||(t[19]=[e("svg",{class:"w-5 h-5",fill:"currentColor",viewBox:"0 0 20 20"},[e("path",{"fill-rule":"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z","clip-rule":"evenodd"})],-1)]))):k("",!0)]),t[21]||(t[21]=e("div",{class:"text-xs text-content-secondary dark:text-content-muted"},"Manually configure frequency, bandwidth, spreading factor, and coding rate",-1))],2),B(T,{name:"slide"},{default:q(()=>[o(r).useCustomRadio?(n(),a("div",Ye,[e("div",null,[t[22]||(t[22]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Frequency (MHz)",-1)),h(e("input",{"onUpdate:modelValue":t[2]||(t[2]=s=>o(r).customRadio.frequency=s),type:"number",step:"0.1",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all",placeholder:"915.0"},null,512),[[C,o(r).customRadio.frequency]])]),e("div",null,[t[23]||(t[23]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Bandwidth (kHz)",-1)),h(e("input",{"onUpdate:modelValue":t[3]||(t[3]=s=>o(r).customRadio.bandwidth=s),type:"number",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all",placeholder:"125"},null,512),[[C,o(r).customRadio.bandwidth]])]),e("div",null,[t[25]||(t[25]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Spreading Factor",-1)),h(e("select",{"onUpdate:modelValue":t[4]||(t[4]=s=>o(r).customRadio.spreading_factor=s),class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all"},t[24]||(t[24]=[e("option",{value:"7"},"7",-1),e("option",{value:"8"},"8",-1),e("option",{value:"9"},"9",-1),e("option",{value:"10"},"10",-1),e("option",{value:"11"},"11",-1),e("option",{value:"12"},"12",-1)]),512),[[E,o(r).customRadio.spreading_factor]])]),e("div",null,[t[27]||(t[27]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Coding Rate",-1)),h(e("select",{"onUpdate:modelValue":t[5]||(t[5]=s=>o(r).customRadio.coding_rate=s),class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all"},t[26]||(t[26]=[e("option",{value:"5"},"4/5",-1),e("option",{value:"6"},"4/6",-1),e("option",{value:"7"},"4/7",-1),e("option",{value:"8"},"4/8",-1)]),512),[[E,o(r).customRadio.coding_rate]])])])):k("",!0)]),_:1})])]))])):o(r).currentStep===5?(n(),a("div",Je,[t[32]||(t[32]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Set a secure admin password to protect your repeater ",-1)),e("div",Ke,[e("div",null,[t[29]||(t[29]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Admin Password",-1)),h(e("input",{"onUpdate:modelValue":t[6]||(t[6]=s=>o(r).adminPassword=s),type:"password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"Enter password (min 6 characters)",minlength:"6"},null,512),[[C,o(r).adminPassword]])]),e("div",null,[t[30]||(t[30]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Confirm Password",-1)),h(e("input",{"onUpdate:modelValue":t[7]||(t[7]=s=>o(r).confirmPassword=s),type:"password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"Confirm password"},null,512),[[C,o(r).confirmPassword]])]),o(r).adminPassword&&o(r).confirmPassword&&o(r).adminPassword!==o(r).confirmPassword?(n(),a("div",Qe," Passwords do not match ")):k("",!0),t[31]||(t[31]=e("div",{class:"bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3 text-sm text-yellow-800 dark:text-yellow-200"},[e("strong",null,"Important:"),V(" Remember this password - you'll need it to access the dashboard. ")],-1))])])):k("",!0)]),o(r).error?(n(),a("div",Xe,i(o(r).error),1)):k("",!0),e("div",Ze,[o(r).canGoBack?(n(),a("button",{key:0,onClick:j,class:"px-6 py-3 rounded-[12px] bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 text-content-primary dark:text-content-primary hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20 transition-all duration-300 font-medium"}," Back ")):(n(),a("div",et)),e("button",{onClick:R,disabled:!o(r).canGoNext||o(r).isSubmitting,class:_(["px-8 py-3 rounded-[12px] font-semibold transition-all duration-300 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed",o(r).canGoNext&&!o(r).isSubmitting?"bg-gradient-to-r from-primary/20 to-primary/10 hover:from-primary/30 hover:to-primary/20 text-white border border-primary/30 hover:border-primary/50":"bg-background-mute dark:bg-stroke/5 text-content-muted dark:text-content-muted border border-stroke-subtle dark:border-stroke/10"])},[o(r).isSubmitting?(n(),a("div",rt)):k("",!0),o(r).isSubmitting?(n(),a("span",ot,"Setting up...")):o(r).isLastStep?(n(),a("span",st,"Complete Setup")):(n(),a("span",at,"Next")),!o(r).isSubmitting&&!o(r).isLastStep?(n(),a("svg",nt,t[33]||(t[33]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 5l7 7-7 7"},null,-1)]))):k("",!0)],10,tt)])])]),B(T,{name:"modal"},{default:q(()=>[b.value?(n(),a("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm",onClick:g},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl max-w-md w-full p-8 rounded-[24px] border border-stroke-subtle dark:border-white/20 shadow-[0_8px_32px_0_rgba(0,0,0,0.37)]",onClick:t[8]||(t[8]=X(()=>{},["stop"]))},[e("div",dt,[f.value==="success"?(n(),a("div",it,t[34]||(t[34]=[e("svg",{class:"w-8 h-8 text-green-600 dark:text-green-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"})],-1)]))):(n(),a("div",lt,t[35]||(t[35]=[e("svg",{class:"w-8 h-8 text-red-600 dark:text-red-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])))]),e("h3",ut,i(v.value),1),e("p",ct,i(x.value),1),e("button",{onClick:g,class:_(["w-full px-6 py-3 rounded-lg font-medium transition-all",f.value==="success"?"bg-gradient-to-r from-primary/20 to-primary/10 hover:from-primary/30 hover:to-primary/20 text-white":"bg-gradient-to-r from-red-500/20 to-red-500/10 hover:from-red-500/30 hover:to-red-500/20 text-white"])},i(f.value==="success"?"Continue to Login":"Close"),3)])])):k("",!0)]),_:1})]))}}),bt=Z(pt,[["__scopeId","data-v-20a8772f"]]);export{bt as default}; diff --git a/setup-radio-config.sh b/setup-radio-config.sh index a0141fa..f85d8bc 100644 --- a/setup-radio-config.sh +++ b/setup-radio-config.sh @@ -43,56 +43,76 @@ repeater_name=${repeater_name:-$default_name} echo "Repeater name: $repeater_name" echo "" -echo "=== Step 1: Select Hardware ===" + +# Step 0.5: Radio type (SX1262 hardware vs KISS modem) +echo "=== Step 0.5: Select Radio Type ===" echo "" +echo " 1) SX1262 hardware (SPI LoRa module - Raspberry Pi HAT, etc.)" +echo " 2) KISS modem (serial TNC - requires pyMC_core with KISS support)" +echo "" +read -p "Select radio type (1 or 2): " radio_type_sel -if [ ! -f "$HARDWARE_CONFIG" ]; then - echo "Error: Hardware configuration file not found at $HARDWARE_CONFIG" - exit 1 -fi +if [ "$radio_type_sel" = "2" ]; then + RADIO_TYPE="kiss" + hw_key="kiss" + hw_name="KISS modem" + echo "Selected: $hw_name" + echo "" +else + RADIO_TYPE="sx1262" + echo "Selected: SX1262 hardware" + echo "" + echo "=== Step 1: Select Hardware ===" + echo "" -# Parse hardware options from radio-settings.json -hw_index=0 -declare -a hw_keys -declare -a hw_names - -# Extract hardware keys and names using grep and sed -hw_data=$(grep -o '"[^"]*":\s*{' "$HARDWARE_CONFIG" | grep -v hardware | sed 's/"\([^"]*\)".*/\1/' | while read hw_key; do - hw_name=$(grep -A 1 "\"$hw_key\"" "$HARDWARE_CONFIG" | grep "\"name\"" | sed 's/.*"name":\s*"\([^"]*\)".*/\1/') - if [ -n "$hw_name" ]; then - echo "$hw_key|$hw_name" + if [ ! -f "$HARDWARE_CONFIG" ]; then + echo "Error: Hardware configuration file not found at $HARDWARE_CONFIG" + exit 1 fi -done) -while IFS='|' read -r hw_key hw_name; do - if [ -n "$hw_key" ] && [ -n "$hw_name" ]; then - echo " $((hw_index + 1))) $hw_name ($hw_key)" - hw_keys[$hw_index]="$hw_key" - hw_names[$hw_index]="$hw_name" - ((hw_index++)) + # Parse hardware options from radio-settings.json + hw_index=0 + declare -a hw_keys + declare -a hw_names + + # Extract hardware keys and names using grep and sed + hw_data=$(grep -o '"[^"]*":\s*{' "$HARDWARE_CONFIG" | grep -v hardware | sed 's/"\([^"]*\)".*/\1/' | while read hw_key; do + hw_name=$(grep -A 1 "\"$hw_key\"" "$HARDWARE_CONFIG" | grep "\"name\"" | sed 's/.*"name":\s*"\([^"]*\)".*/\1/') + if [ -n "$hw_name" ]; then + echo "$hw_key|$hw_name" + fi + done) + + while IFS='|' read -r hw_key hw_name; do + if [ -n "$hw_key" ] && [ -n "$hw_name" ]; then + echo " $((hw_index + 1))) $hw_name ($hw_key)" + hw_keys[$hw_index]="$hw_key" + hw_names[$hw_index]="$hw_name" + ((hw_index++)) + fi + done <<< "$hw_data" + + if [ "$hw_index" -eq 0 ]; then + echo "Error: No hardware configurations found" + exit 1 fi -done <<< "$hw_data" -if [ "$hw_index" -eq 0 ]; then - echo "Error: No hardware configurations found" - exit 1 + echo "" + read -p "Select hardware (1-$hw_index): " hw_selection + + if ! [ "$hw_selection" -ge 1 ] 2>/dev/null || [ "$hw_selection" -gt "$hw_index" ]; then + echo "Error: Invalid selection" + exit 1 + fi + + selected_hw=$((hw_selection - 1)) + hw_key="${hw_keys[$selected_hw]}" + hw_name="${hw_names[$selected_hw]}" + + echo "Selected: $hw_name" + echo "" fi -echo "" -read -p "Select hardware (1-$hw_index): " hw_selection - -if ! [ "$hw_selection" -ge 1 ] 2>/dev/null || [ "$hw_selection" -gt "$hw_index" ]; then - echo "Error: Invalid selection" - exit 1 -fi - -selected_hw=$((hw_selection - 1)) -hw_key="${hw_keys[$selected_hw]}" -hw_name="${hw_names[$selected_hw]}" - -echo "Selected: $hw_name" -echo "" - # Step 2: Radio Settings Selection echo "=== Step 2: Select Radio Settings ===" echo "" @@ -179,14 +199,44 @@ echo "Selected: $title" echo "Frequency: ${freq}MHz, SF: $sf, BW: $bw, CR: $cr" echo "" -# Update config.yaml +# KISS modem: prompt for serial port and baud rate +if [ "$RADIO_TYPE" = "kiss" ]; then + echo "=== KISS Modem Settings ===" + echo "" + default_port="/dev/ttyUSB0" + read -p "Serial port [$default_port]: " kiss_port + kiss_port=${kiss_port:-$default_port} + default_baud="9600" + read -p "Baud rate [$default_baud]: " kiss_baud + kiss_baud=${kiss_baud:-$default_baud} + echo "KISS: port=$kiss_port, baud_rate=$kiss_baud" + echo "" +fi + +# Ensure config file exists (create from example if missing) if [ ! -f "$CONFIG_FILE" ]; then - echo "Error: Config file not found at $CONFIG_FILE" - exit 1 + if [ -f "$CONFIG_DIR/config.yaml.example" ]; then + cp "$CONFIG_DIR/config.yaml.example" "$CONFIG_FILE" + echo "Created $CONFIG_FILE from config.yaml.example" + elif [ -f "$SCRIPT_DIR/config.yaml.example" ]; then + cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_FILE" + echo "Created $CONFIG_FILE from $SCRIPT_DIR/config.yaml.example" + else + echo "Error: Config file not found at $CONFIG_FILE" + echo "Copy config.yaml.example to config.yaml or run from a directory that has it." + exit 1 + fi fi echo "Updating configuration..." +# Radio type (sx1262 or kiss) +if grep -q "^radio_type:" "$CONFIG_FILE"; then + sed "${SED_OPTS[@]}" "s/^radio_type:.*/radio_type: $RADIO_TYPE/" "$CONFIG_FILE" +else + { echo "radio_type: $RADIO_TYPE"; cat "$CONFIG_FILE"; } > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE" +fi + # Repeater name sed "${SED_OPTS[@]}" "s/^ node_name:.*/ node_name: \"$repeater_name\"/" "$CONFIG_FILE" @@ -196,7 +246,18 @@ sed "${SED_OPTS[@]}" "s/^ spreading_factor:.*/ spreading_factor: $sf/" "$CONFI sed "${SED_OPTS[@]}" "s/^ bandwidth:.*/ bandwidth: $bw_hz/" "$CONFIG_FILE" sed "${SED_OPTS[@]}" "s/^ coding_rate:.*/ coding_rate: $cr/" "$CONFIG_FILE" -# Extract hardware-specific settings from radio-settings.json +# KISS modem: update kiss section +if [ "$RADIO_TYPE" = "kiss" ]; then + if grep -q "^kiss:" "$CONFIG_FILE"; then + sed "${SED_OPTS[@]}" "s/^ port:.*/ port: \"$kiss_port\"/" "$CONFIG_FILE" + sed "${SED_OPTS[@]}" "s/^ baud_rate:.*/ baud_rate: $kiss_baud/" "$CONFIG_FILE" + else + printf '\nkiss:\n port: "%s"\n baud_rate: %s\n' "$kiss_port" "$kiss_baud" >> "$CONFIG_FILE" + fi +fi + +# Extract hardware-specific settings from radio-settings.json (SX1262 only) +if [ "$RADIO_TYPE" = "sx1262" ]; then echo "Extracting hardware configuration from $HARDWARE_CONFIG..." # Use jq to extract all fields from the selected hardware @@ -285,6 +346,7 @@ else fi fi fi +fi # Cleanup rm -f /tmp/radio_*_* "$CONFIG_FILE.bak" @@ -293,14 +355,19 @@ echo "Configuration updated successfully!" echo "" echo "Applied Configuration:" echo " Repeater Name: $repeater_name" +echo " Radio Type: $RADIO_TYPE" echo " Hardware: $hw_name ($hw_key)" echo " Frequency: ${freq}MHz (${freq_hz}Hz)" echo " Spreading Factor: $sf" echo " Bandwidth: ${bw}kHz (${bw_hz}Hz)" echo " Coding Rate: $cr" +if [ "$RADIO_TYPE" = "kiss" ]; then + echo " KISS Port: $kiss_port" + echo " KISS Baud Rate: $kiss_baud" +fi echo "" echo "Hardware GPIO Configuration:" -if [ -n "$bus_id" ]; then +if [ "$RADIO_TYPE" = "sx1262" ] && [ -n "$bus_id" ]; then echo " Bus ID: $bus_id" echo " Chip Select: $cs_id (pin $cs_pin)" echo " Reset Pin: $reset_pin" From f2badfa0cb4277e6e5db276e2ec3ef981e68084b Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 3 Feb 2026 16:23:01 -0800 Subject: [PATCH 02/29] Remove repeater database file from the project. --- data/repeater.db | Bin 352256 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 data/repeater.db diff --git a/data/repeater.db b/data/repeater.db deleted file mode 100644 index 48bc38a94dc656e1ba8180b8cc6cfff597a59622..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 352256 zcmeFa2Y6LQ+Bkl0Zm)+Ps$M}#1oZTRD7lp)1cZPnC7cS8kc1>O6*L433ie(=Q4xFZ z4YBvS_I2%T6?GST+21=e_Z$e`2tNOBzdyU*4G(WSb7tne@60>>&XhyP*S1(myP;`; z){^9@PL#((RVI@ZRkklhQPbeR^VbsypThw5>_B%wLLB=qKE;m$(?6py-RPf#{e6o9 zfAw|sZuZ(`zm{E8miD~mnNR(Lx*z`ixj(*hpnUW`9;&w9w3aq6tb;G9(H6BdV7_KS z?c64lzxG62D7mr?4oI9l{TBBdItn7AR-? zcow>;Xy#(8sRi`7NC$luxcKi9Xdev4UyuwTFILQn2Fm3=p5?!b*iF0eLaf8M-Hv$45AWky;(m(r z?mVq|UV)Bum;eg2W6b1*8YHebG=yWW{aws0%Nngh%;64WMuB#W8Sxc!f;;Km$*ilm zf*{m!yiO5o7r7&>SY`QxP4o{}0R!EYg%28RzL5(I$gDO!2O$Cb6RZqunENV3W7?M5YN zYFGq46YOjYUa8|0jE4Fa&1fN^kt69iXdU!!S{9iWHsv1#Yp9>QJK^lwCU6+dmQ~-r zZ2UpY$iw`Z7n+)zYj^Esn(Lc(@kyk#)ff$n>RX)nTstJQxqNEs`06Cvan8Yx_0|&j zUpsf6-tcE^d6d=EP&2Qg@ptBTlg+2bPstUpP?O369&TijxlV;3|6hp*YS$%UnQZ?cyj1_na!aV})?7*RjoTE&83cI}TrG z)z592_a|QXpDLZrMnluCDkW}gNC)$GC8^_2f?{hfYJ@%pIG{iAn%UIQ2!2azZm7o& zvEu}qn+)8q+(j@G>OJn2{;`8<)LQ24PPlo{w17kXL*p47>+d`g^liF^(b$|%yp1|K)99b+@91ynFX&I`_vydVuhTEn&(TlPkI)a& z_t3Y|H_)5t4fMtIx%BDu$@D6E1-+DRqU&gzo?JqqoD;cL2x*G&`K7Y;H9~b`!gR@Rvd>* zhO2mn-IEkwdDim3ymIZA&;79h7`Fec_9g)d7>3PZ!wASQ49i-3hG|-rP)`c10+k~S zwWD+Ae%+~AP&o!t3y@*rv!|WX?Ze6;#4c{l$SDFd1Jw*{AgBzCr5L&)F&3+_rm6}W zXN#t+OA5yul3_zhw+xl#y5;hDQOU_lO3sUUo)bY)xYQ!0F(Shu)$DF$t%n5NtV5|P zWIZ^1AiEG}ePmy*+f|hTT%ss9$G9&4i5yo>|9*DgXVzcUQO;3)bG`!Tf}!DCcvSyb zYj?k9<*@;`judrMvHhkF^k=TBOpp(9Z83w*4@=6q9lVriBJFdMXh@u@TB5*NmSBRv zQUsf26vH$bP8WDV)h*U$E!E~YMm8iK%qMYzt%|&837Tvvysm-skxg6Uz$xjRps+Tl z>8io$;HhNMhByq-l5J5}paiIbDar!R7%C%V*sRFr#T={3c~#A(GBf(Kd88dVx1*P0 zZP`LzYEQ`W%47Y1Y=P)=-@oP0d{|jg)B>qOZ9whBp71#xTOfrjfNC6SC%^(4!|oYb zZcF$a3xGEQErTIQKaFHeA=`RZn$@@5~%CM zW=QwRZN)W((``l7HH+Z|SrRx^)@8*sELLJUMP_WtGk`Eb1N2{*4U{lRn!rQTzB8~{>(!$+^co!grq`qp%y&W z|M+Ks{in7~1z6p72*4?AS%8zw z9JjI&;MkS507tK^0hnJ|4KTNI9Kh_#6u`_%6kB@bP=KkG`v9z5iDElwC34XtSE51o zfvv{^WLu8{$h6i2JfPJCxPR*mfWuo+oWokj1033l;@q!Q1vt3%0Dyy9hX5Sd3a*FR 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 f#<% Date: Tue, 3 Feb 2026 16:54:41 -0800 Subject: [PATCH 03/29] Add data directory to .gitignore and include rrdtool dependency in pyproject.toml --- .gitignore | 3 +++ pyproject.toml | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/.gitignore b/.gitignore index 1769786..012cf78 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,9 @@ htmlcov/ config.yaml identity.json +# Data +data/ + # Logs *.log .DS_Store diff --git a/pyproject.toml b/pyproject.toml index 7bfc376..4d52e7c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,10 @@ dependencies = [ hardware = [ "pymc_core[hardware]", ] +# RRD metrics (Performance Metrics chart); system librrd required (e.g. apt install rrdtool) +rrd = [ + "rrdtool", +] dev = [ "pytest>=7.4.0", "pytest-asyncio>=0.21.0", From 15299bf374fef0a5e2a83490642cc6795cde6072 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 13 Feb 2026 16:07:43 -0800 Subject: [PATCH 04/29] Add companion module and API integration - Add repeater/companion with frame server and constants - Extend config, sqlite_handler, mesh_cli, packet_router for companion - Update api_endpoints and auth_endpoints; adjust main entry Co-authored-by: Cursor --- config.yaml.example | 18 +- repeater/companion/__init__.py | 28 + repeater/companion/constants.py | 135 ++++ repeater/companion/frame_server.py | 729 ++++++++++++++++++++ repeater/config_manager.py | 36 +- repeater/data_acquisition/sqlite_handler.py | 218 +++++- repeater/handler_helpers/mesh_cli.py | 43 +- repeater/main.py | 147 ++++ repeater/packet_router.py | 71 +- repeater/web/api_endpoints.py | 54 +- repeater/web/auth_endpoints.py | 3 +- 11 files changed, 1408 insertions(+), 74 deletions(-) create mode 100644 repeater/companion/__init__.py create mode 100644 repeater/companion/constants.py create mode 100644 repeater/companion/frame_server.py diff --git a/config.yaml.example b/config.yaml.example index e1498b3..39b7130 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -100,7 +100,23 @@ identities: # longitude: 0.0 # admin_password: "social_admin_123" # guest_password: "social_guest_123" - + + # Companion Identities + # Each companion exposes the MeshCore frame protocol over TCP for standard clients. + # One TCP client per companion at a time. Clients connect to repeater-ip:tcp_port. + companions: + # - name: "RepeaterCompanion" + # identity_key: "your_companion_identity_key_hex_here" + # settings: + # node_name: "RepeaterCompanion" + # tcp_port: 5000 + # bind_address: "0.0.0.0" + # - name: "BotCompanion" + # identity_key: "another_companion_identity_key_hex" + # settings: + # node_name: "meshcore-bot" + # tcp_port: 5001 + radio: # Frequency in Hz (869.618 MHz for EU) frequency: 869618000 diff --git a/repeater/companion/__init__.py b/repeater/companion/__init__.py new file mode 100644 index 0000000..ecfa313 --- /dev/null +++ b/repeater/companion/__init__.py @@ -0,0 +1,28 @@ +"""Companion identity support for pyMC Repeater. + +Exposes the MeshCore companion frame protocol over TCP for standard clients. +""" + +from .frame_server import CompanionFrameServer +from .constants import ( + CMD_APP_START, + CMD_GET_CONTACTS, + CMD_SEND_TXT_MSG, + CMD_SYNC_NEXT_MESSAGE, + CMD_SEND_LOGIN, + RESP_CODE_OK, + RESP_CODE_ERR, + PUSH_CODE_MSG_WAITING, +) + +__all__ = [ + "CompanionFrameServer", + "CMD_APP_START", + "CMD_GET_CONTACTS", + "CMD_SEND_TXT_MSG", + "CMD_SYNC_NEXT_MESSAGE", + "CMD_SEND_LOGIN", + "RESP_CODE_OK", + "RESP_CODE_ERR", + "PUSH_CODE_MSG_WAITING", +] diff --git a/repeater/companion/constants.py b/repeater/companion/constants.py new file mode 100644 index 0000000..3ae73be --- /dev/null +++ b/repeater/companion/constants.py @@ -0,0 +1,135 @@ +"""Companion frame protocol constants (MeshCore Companion Radio Protocol).""" + +# Commands (app -> radio) +CMD_APP_START = 1 +CMD_SEND_TXT_MSG = 2 +CMD_SEND_CHANNEL_TXT_MSG = 3 +CMD_GET_CONTACTS = 4 +CMD_GET_DEVICE_TIME = 5 +CMD_SET_DEVICE_TIME = 6 +CMD_SEND_SELF_ADVERT = 7 +CMD_SET_ADVERT_NAME = 8 +CMD_ADD_UPDATE_CONTACT = 9 +CMD_SYNC_NEXT_MESSAGE = 10 +CMD_SET_RADIO_PARAMS = 11 +CMD_SET_RADIO_TX_POWER = 12 +CMD_RESET_PATH = 13 +CMD_SET_ADVERT_LATLON = 14 +CMD_REMOVE_CONTACT = 15 +CMD_SHARE_CONTACT = 16 +CMD_EXPORT_CONTACT = 17 +CMD_IMPORT_CONTACT = 18 +CMD_REBOOT = 19 +CMD_GET_BATT_AND_STORAGE = 20 +CMD_SET_TUNING_PARAMS = 21 +CMD_DEVICE_QUERY = 22 +CMD_EXPORT_PRIVATE_KEY = 23 +CMD_IMPORT_PRIVATE_KEY = 24 +CMD_SEND_RAW_DATA = 25 +CMD_SEND_LOGIN = 26 +CMD_SEND_STATUS_REQ = 27 +CMD_HAS_CONNECTION = 28 +CMD_LOGOUT = 29 +CMD_GET_CONTACT_BY_KEY = 30 +CMD_GET_CHANNEL = 31 +CMD_SET_CHANNEL = 32 +CMD_SIGN_START = 33 +CMD_SIGN_DATA = 34 +CMD_SIGN_FINISH = 35 +CMD_SEND_TRACE_PATH = 36 +CMD_SET_DEVICE_PIN = 37 +CMD_SET_OTHER_PARAMS = 38 +CMD_SEND_TELEMETRY_REQ = 39 +CMD_GET_CUSTOM_VARS = 40 +CMD_SET_CUSTOM_VAR = 41 +CMD_GET_ADVERT_PATH = 42 +CMD_GET_TUNING_PARAMS = 43 +CMD_SEND_BINARY_REQ = 50 +CMD_FACTORY_RESET = 51 +CMD_SEND_PATH_DISCOVERY_REQ = 52 +CMD_SET_FLOOD_SCOPE = 54 +CMD_SEND_CONTROL_DATA = 55 +CMD_GET_STATS = 56 +CMD_SEND_ANON_REQ = 57 +CMD_SET_AUTOADD_CONFIG = 58 +CMD_GET_AUTOADD_CONFIG = 59 + +# Response codes (radio -> app) +RESP_CODE_OK = 0 +RESP_CODE_ERR = 1 +RESP_CODE_CONTACTS_START = 2 +RESP_CODE_CONTACT = 3 +RESP_CODE_END_OF_CONTACTS = 4 +RESP_CODE_SELF_INFO = 5 +RESP_CODE_SENT = 6 +RESP_CODE_CONTACT_MSG_RECV = 7 +RESP_CODE_CHANNEL_MSG_RECV = 8 +RESP_CODE_CURR_TIME = 9 +RESP_CODE_NO_MORE_MESSAGES = 10 +RESP_CODE_EXPORT_CONTACT = 11 +RESP_CODE_BATT_AND_STORAGE = 12 +RESP_CODE_DEVICE_INFO = 13 # CMD_DEVICE_QUERY response +RESP_CODE_PRIVATE_KEY = 14 +RESP_CODE_DISABLED = 15 +RESP_CODE_CONTACT_MSG_RECV_V3 = 16 +RESP_CODE_CHANNEL_MSG_RECV_V3 = 17 +RESP_CODE_CHANNEL_INFO = 18 +RESP_CODE_SIGN_START = 19 +RESP_CODE_SIGNATURE = 20 +RESP_CODE_CUSTOM_VARS = 21 +RESP_CODE_ADVERT_PATH = 22 +RESP_CODE_TUNING_PARAMS = 23 +RESP_CODE_STATS = 24 +RESP_CODE_AUTOADD_CONFIG = 25 + +# Push codes (radio -> app, unsolicited) +PUSH_CODE_ADVERT = 0x80 +PUSH_CODE_PATH_UPDATED = 0x81 +PUSH_CODE_SEND_CONFIRMED = 0x82 +PUSH_CODE_MSG_WAITING = 0x83 +PUSH_CODE_RAW_DATA = 0x84 +PUSH_CODE_LOGIN_SUCCESS = 0x85 +PUSH_CODE_LOGIN_FAIL = 0x86 +PUSH_CODE_STATUS_RESPONSE = 0x87 +PUSH_CODE_LOG_RX_DATA = 0x88 +PUSH_CODE_TRACE_DATA = 0x89 +PUSH_CODE_NEW_ADVERT = 0x8A +PUSH_CODE_TELEMETRY_RESPONSE = 0x8B +PUSH_CODE_BINARY_RESPONSE = 0x8C +PUSH_CODE_PATH_DISCOVERY_RESPONSE = 0x8D +PUSH_CODE_CONTROL_DATA = 0x8E +PUSH_CODE_CONTACT_DELETED = 0x8F +PUSH_CODE_CONTACTS_FULL = 0x90 + +# Error codes +ERR_CODE_UNSUPPORTED_CMD = 1 +ERR_CODE_NOT_FOUND = 2 +ERR_CODE_TABLE_FULL = 3 +ERR_CODE_BAD_STATE = 4 +ERR_CODE_FILE_IO_ERROR = 5 +ERR_CODE_ILLEGAL_ARG = 6 + +# Stats sub-types +STATS_TYPE_CORE = 0 +STATS_TYPE_RADIO = 1 +STATS_TYPE_PACKETS = 2 + +# Frame delimiters (USB/TCP: > = outbound, < = inbound) +FRAME_OUTBOUND_PREFIX = 0x3E # '>' +FRAME_INBOUND_PREFIX = 0x3C # '<' +MAX_FRAME_SIZE = 512 +PUB_KEY_SIZE = 32 +MAX_PATH_SIZE = 64 + +# ADV types +ADV_TYPE_CHAT = 1 +ADV_TYPE_REPEATER = 2 +ADV_TYPE_ROOM = 3 +ADV_TYPE_SENSOR = 4 + +# Default Public channel PSK (from firmware) +PUBLIC_GROUP_PSK = b"izOH6cXN6mrJ5e26oRXNcg==" + +# Default public channel secret (hex) - used for channel 0 when no channels loaded +# Matches MeshCore firmware default for new radios +DEFAULT_PUBLIC_CHANNEL_SECRET = bytes.fromhex("8b3387e9c5cdea6ac9e5edbaa115cd72") diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py new file mode 100644 index 0000000..91ef2b1 --- /dev/null +++ b/repeater/companion/frame_server.py @@ -0,0 +1,729 @@ +""" +Companion frame protocol TCP server. + +Implements the MeshCore Companion Radio Protocol over TCP for standard clients. +Frame format: outbound '>' + 2-byte len (LE) + data; inbound '<' + 2-byte len + data. +""" + +import asyncio +import base64 +import logging +import struct +import time +from typing import Optional + +from pymc_core.companion.constants import ADV_TYPE_CHAT +from pymc_core.companion.models import Contact, QueuedMessage + +from .constants import ( + RESP_CODE_DEVICE_INFO, + CMD_ADD_UPDATE_CONTACT, + CMD_GET_CHANNEL, + CMD_SET_CHANNEL, + CMD_SET_FLOOD_SCOPE, + CMD_APP_START, + CMD_DEVICE_QUERY, + CMD_GET_BATT_AND_STORAGE, + CMD_GET_CONTACTS, + CMD_GET_STATS, + CMD_IMPORT_CONTACT, + CMD_REMOVE_CONTACT, + CMD_RESET_PATH, + CMD_SEND_CHANNEL_TXT_MSG, + CMD_SEND_LOGIN, + CMD_SEND_SELF_ADVERT, + CMD_SEND_TXT_MSG, + CMD_SET_ADVERT_LATLON, + CMD_SET_ADVERT_NAME, + CMD_SYNC_NEXT_MESSAGE, + ERR_CODE_BAD_STATE, + ERR_CODE_ILLEGAL_ARG, + ERR_CODE_NOT_FOUND, + ERR_CODE_UNSUPPORTED_CMD, + FRAME_INBOUND_PREFIX, + FRAME_OUTBOUND_PREFIX, + MAX_FRAME_SIZE, + MAX_PATH_SIZE, + PUB_KEY_SIZE, + PUSH_CODE_ADVERT, + PUSH_CODE_MSG_WAITING, + PUSH_CODE_PATH_UPDATED, + PUSH_CODE_SEND_CONFIRMED, + RESP_CODE_BATT_AND_STORAGE, + RESP_CODE_CHANNEL_INFO, + RESP_CODE_CHANNEL_MSG_RECV, + RESP_CODE_CHANNEL_MSG_RECV_V3, + RESP_CODE_CONTACT, + RESP_CODE_CONTACT_MSG_RECV_V3, + RESP_CODE_CONTACT_MSG_RECV, + RESP_CODE_CONTACTS_START, + RESP_CODE_END_OF_CONTACTS, + RESP_CODE_ERR, + RESP_CODE_NO_MORE_MESSAGES, + RESP_CODE_OK, + RESP_CODE_SELF_INFO, + RESP_CODE_SENT, + RESP_CODE_STATS, + STATS_TYPE_CORE, + STATS_TYPE_PACKETS, + STATS_TYPE_RADIO, +) + +logger = logging.getLogger("CompanionFrameServer") + + +class CompanionFrameServer: + """TCP server for the MeshCore companion frame protocol. + + One client per companion at a time. Listens on configured port. + """ + + def __init__( + self, + bridge, + companion_hash: str, + port: int = 5000, + bind_address: str = "0.0.0.0", + sqlite_handler=None, + ): + self.bridge = bridge + self.companion_hash = companion_hash + self.port = port + self.bind_address = bind_address + self.sqlite_handler = sqlite_handler + self._server: Optional[asyncio.Server] = None + self._client_writer: Optional[asyncio.StreamWriter] = None + self._client_reader: Optional[asyncio.StreamReader] = None + self._app_target_ver = 0 + + async def start(self) -> None: + """Start the TCP server.""" + self._server = await asyncio.start_server( + self._handle_client, + self.bind_address, + self.port, + ) + addr = self._server.sockets[0].getsockname() if self._server.sockets else (self.bind_address, self.port) + logger.info(f"Companion frame server listening on {addr[0]}:{addr[1]} (hash=0x{int(self.companion_hash):02x})") + + async def stop(self) -> None: + """Stop the TCP server and disconnect any client.""" + if self._client_writer: + try: + self._client_writer.close() + await self._client_writer.wait_closed() + except Exception: + pass + self._client_writer = None + self._client_reader = None + if self._server: + self._server.close() + await self._server.wait_closed() + self._server = None + logger.info(f"Companion frame server stopped (port={self.port})") + + def _setup_push_callbacks(self) -> None: + """Subscribe to bridge events and send PUSH frames to connected client.""" + + def _write_push(data: bytes) -> None: + if self._client_writer and not self._client_writer.is_closing(): + try: + frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack("= 32: + _write_push(bytes([PUSH_CODE_ADVERT]) + pubkey[:32]) + + async def on_contact_path_updated(pub_key, path_len, path): + if isinstance(pub_key, bytes) and len(pub_key) >= 32: + _write_push(bytes([PUSH_CODE_PATH_UPDATED]) + pub_key[:32]) + + async def on_channel_message_received(channel_name, sender_name, message_text, timestamp, path_len=0): + channel_idx = 0 + max_ch = getattr( + getattr(self.bridge, "channels", None), "max_channels", 40 + ) + for idx in range(max_ch): + ch = self.bridge.get_channel(idx) + if ch and ch.name == channel_name: + channel_idx = idx + break + if self.sqlite_handler: + self.sqlite_handler.companion_push_message( + self.companion_hash, + { + "sender_key": b"", + "text": message_text, + "timestamp": timestamp, + "txt_type": 0, + "is_channel": True, + "channel_idx": channel_idx, + "path_len": path_len, + }, + ) + _write_push(bytes([PUSH_CODE_MSG_WAITING])) + + self.bridge.on_message_received(on_message_received) + self.bridge.on_channel_message_received(on_channel_message_received) + self.bridge.on_send_confirmed(on_send_confirmed) + self.bridge.on_advert_received(on_advert_received) + self.bridge.on_contact_path_updated(on_contact_path_updated) + + async def _drain_writer(self) -> None: + if self._client_writer: + try: + await self._client_writer.drain() + except Exception: + pass + + def _write_frame(self, data: bytes) -> None: + """Send a frame to the connected client (outbound format).""" + if self._client_writer and not self._client_writer.is_closing(): + frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack(" None: + self._write_frame(bytes([RESP_CODE_OK])) + + def _write_err(self, err_code: int) -> None: + self._write_frame(bytes([RESP_CODE_ERR, err_code])) + + def _save_contacts(self) -> None: + """Persist contacts to SQLite.""" + if not self.sqlite_handler: + return + contacts = self.bridge.get_contacts() + dicts = [] + for c in contacts: + pk = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key) + dicts.append({ + "pubkey": pk, + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "out_path": c.out_path if isinstance(c.out_path, bytes) else (bytes.fromhex(c.out_path) if c.out_path else b""), + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + "sync_since": c.sync_since, + }) + self.sqlite_handler.companion_save_contacts(self.companion_hash, dicts) + + async def _handle_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + """Handle a new client connection. One client at a time.""" + if self._client_writer: + logger.warning("Companion already has a client; rejecting new connection") + writer.close() + await writer.wait_closed() + return + + self._client_reader = reader + self._client_writer = writer + self._setup_push_callbacks() + logger.info(f"Companion client connected (port={self.port})") + + try: + while True: + prefix = await reader.read(1) + if not prefix: + break + if prefix[0] != FRAME_INBOUND_PREFIX: + logger.warning(f"Invalid frame prefix: 0x{prefix[0]:02x}") + continue + len_bytes = await reader.readexactly(2) + frame_len = struct.unpack(" MAX_FRAME_SIZE: + logger.warning(f"Frame too long: {frame_len}") + break + payload = await reader.readexactly(frame_len) + await self._handle_cmd(payload) + except asyncio.IncompleteReadError: + pass + except ConnectionResetError: + pass + except Exception as e: + logger.error(f"Client handler error: {e}", exc_info=True) + finally: + self._client_writer = None + self._client_reader = None + logger.info(f"Companion client disconnected (port={self.port})") + + async def _handle_cmd(self, payload: bytes) -> None: + """Dispatch command to handler.""" + if not payload: + return + cmd = payload[0] + data = payload[1:] + if cmd in (CMD_GET_CHANNEL, CMD_SET_CHANNEL): + logger.debug(f"Companion cmd 0x{cmd:02x} ({'GET_CHANNEL' if cmd == CMD_GET_CHANNEL else 'SET_CHANNEL'}), payload_len={len(payload)}") + + try: + if cmd == CMD_APP_START: + await self._cmd_app_start(data) + elif cmd == CMD_DEVICE_QUERY: + await self._cmd_device_query(data) + elif cmd == CMD_GET_CONTACTS: + await self._cmd_get_contacts(data) + elif cmd == CMD_SEND_TXT_MSG: + await self._cmd_send_txt_msg(data) + elif cmd == CMD_SEND_CHANNEL_TXT_MSG: + await self._cmd_send_channel_txt_msg(data) + elif cmd == CMD_SYNC_NEXT_MESSAGE: + await self._cmd_sync_next_message(data) + elif cmd == CMD_SEND_LOGIN: + await self._cmd_send_login(data) + elif cmd == CMD_SEND_SELF_ADVERT: + await self._cmd_send_self_advert(data) + elif cmd == CMD_SET_ADVERT_NAME: + await self._cmd_set_advert_name(data) + elif cmd == CMD_SET_ADVERT_LATLON: + await self._cmd_set_advert_latlon(data) + elif cmd == CMD_ADD_UPDATE_CONTACT: + await self._cmd_add_update_contact(data) + elif cmd == CMD_REMOVE_CONTACT: + await self._cmd_remove_contact(data) + elif cmd == CMD_RESET_PATH: + await self._cmd_reset_path(data) + elif cmd == CMD_GET_BATT_AND_STORAGE: + await self._cmd_get_batt_and_storage(data) + elif cmd == CMD_GET_STATS: + await self._cmd_get_stats(data) + elif cmd == CMD_IMPORT_CONTACT: + await self._cmd_import_contact(data) + elif cmd == CMD_GET_CHANNEL: + await self._cmd_get_channel(data) + elif cmd == CMD_SET_CHANNEL: + await self._cmd_set_channel(data) + elif cmd == CMD_SET_FLOOD_SCOPE: + # App sends this on connect; no-op for repeater companion (no radio scope) + self._write_ok() + else: + self._write_err(ERR_CODE_UNSUPPORTED_CMD) + except Exception as e: + logger.error(f"Cmd 0x{cmd:02x} error: {e}", exc_info=True) + self._write_err(ERR_CODE_ILLEGAL_ARG) + + async def _cmd_app_start(self, data: bytes) -> None: + if len(data) >= 1: + self._app_target_ver = data[0] + # RESP_CODE_SELF_INFO - name is varchar (remainder of frame) + # Send name without null terminator; client displays remainder of frame as-is + prefs = self.bridge.get_self_info() + pubkey = self.bridge.get_public_key() + name = prefs.node_name.encode("utf-8", errors="replace") + lat = int(getattr(prefs, "latitude", 0) * 1e6) + lon = int(getattr(prefs, "longitude", 0) * 1e6) + frame = ( + bytes([RESP_CODE_SELF_INFO, ADV_TYPE_CHAT, prefs.tx_power_dbm, 22]) + + pubkey + + struct.pack(" None: + if len(data) >= 1: + self._app_target_ver = data[0] + firmware_ver = 8 + # Protocol: max_contacts_div_2 and max_channels are bytes (ver 3+) + max_contacts = getattr( + getattr(self.bridge, "contacts", None), "max_contacts", 1000 + ) + max_channels_val = getattr( + getattr(self.bridge, "channels", None), "max_channels", 40 + ) + max_contacts_div_2 = min(max_contacts // 2, 255) + max_channels = min(max_channels_val, 255) + ble_pin = 0 + build_date = b"13 Feb 2026\x00"[:12].ljust(12, b"\x00") + model = b"pyMC-Repeater-Companion\x00"[:40].ljust(40, b"\x00") + version = b"1.0.0\x00"[:20].ljust(20, b"\x00") + frame = ( + bytes([RESP_CODE_DEVICE_INFO, firmware_ver, max_contacts_div_2, max_channels]) + + struct.pack(" None: + since = struct.unpack("= 4 else 0 + contacts = self.bridge.get_contacts(since=since) + self._write_frame(bytes([RESP_CODE_CONTACTS_START]) + struct.pack(" 0xFF, else 0-255 + opl = c.out_path_len if hasattr(c, "out_path_len") else -1 + opl_byte = 0xFF if opl < 0 else min(opl, 255) + frame = ( + bytes([RESP_CODE_CONTACT]) + + pubkey + + bytes([c.adv_type if hasattr(c, "adv_type") else 0, c.flags if hasattr(c, "flags") else 0]) + + bytes([opl_byte]) + + (c.out_path[:MAX_PATH_SIZE] if hasattr(c, "out_path") and c.out_path else b"").ljust(MAX_PATH_SIZE, b"\x00") + + name + + struct.pack(" None: + if len(data) < 12: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + txt_type = data[0] + attempt = data[1] + sender_ts = struct.unpack_from(" None: + if len(data) < 6: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + # Protocol: txt_type(1) + channel_idx(1) + sender_timestamp(4) + text + channel_idx = data[1] + sender_ts = struct.unpack_from(" None: + msg = self.bridge.sync_next_message() + if msg is None and self.sqlite_handler: + msg_dict = self.sqlite_handler.companion_pop_message(self.companion_hash) + if msg_dict: + msg = QueuedMessage( + sender_key=msg_dict.get("sender_key", b""), + txt_type=msg_dict.get("txt_type", 0), + timestamp=msg_dict.get("timestamp", 0), + text=msg_dict.get("text", ""), + is_channel=bool(msg_dict.get("is_channel", False)), + channel_idx=msg_dict.get("channel_idx", 0), + path_len=msg_dict.get("path_len", 0), + ) + if msg is None: + self._write_frame(bytes([RESP_CODE_NO_MORE_MESSAGES])) + return + if msg.is_channel: + path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF + txt_type = 0 # TXT_TYPE_PLAIN + text_bytes = msg.text.encode("utf-8", errors="replace") + if self._app_target_ver >= 3: + # RESP_CODE_CHANNEL_MSG_RECV_V3: code + snr + reserved(2) + channel_idx + path_len + txt_type + timestamp + text + frame = bytes([ + RESP_CODE_CHANNEL_MSG_RECV_V3, + 0, # snr (we don't have it when popping from queue) + 0, 0, # reserved + msg.channel_idx, + path_len_byte, + txt_type, + ]) + struct.pack("= 6 else msg.sender_key.ljust(6, b"\x00") + path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF + text_bytes = msg.text.encode("utf-8", errors="replace") + if self._app_target_ver >= 3: + frame = bytes([ + RESP_CODE_CONTACT_MSG_RECV_V3, + 0, 0, 0, # snr + reserved + ]) + prefix + bytes([path_len_byte, msg.txt_type]) + struct.pack(" None: + if len(data) < 33: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[:32] + password = data[32:].decode("utf-8", errors="replace").rstrip("\x00") + self._write_frame(bytes([RESP_CODE_SENT, 1]) + struct.pack(" None: + flood = len(data) >= 1 and data[0] == 1 + ok = await self.bridge.advertise(flood=flood) + self._write_ok() if ok else self._write_err(ERR_CODE_BAD_STATE) + + async def _cmd_set_advert_name(self, data: bytes) -> None: + name = data.decode("utf-8", errors="replace").rstrip("\x00") + self.bridge.set_advert_name(name) + self._write_ok() + + async def _cmd_set_advert_latlon(self, data: bytes) -> None: + if len(data) < 8: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + lat, lon = struct.unpack_from(" None: + if len(data) < 73: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[0:32] + adv_type = data[32] + flags = data[33] + out_path_len = data[34] + out_path = data[35:35 + MAX_PATH_SIZE] + name_raw = data[35 + MAX_PATH_SIZE:35 + MAX_PATH_SIZE + 32] + name = name_raw.split(b"\x00")[0].decode("utf-8", errors="replace") + last_advert = struct.unpack_from("= 35 + MAX_PATH_SIZE + 32 + 12: + gps_lat = struct.unpack_from(" None: + if len(data) < 32: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[:32] + ok = self.bridge.remove_contact(pubkey) + if ok and self.sqlite_handler: + self._save_contacts() + self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) + + async def _cmd_reset_path(self, data: bytes) -> None: + if len(data) < 32: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[:32] + ok = self.bridge.reset_path(pubkey) + self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) + + async def _cmd_get_batt_and_storage(self, data: bytes) -> None: + millivolts = 0 + used_kb = 0 + total_kb = 0 + frame = bytes([RESP_CODE_BATT_AND_STORAGE]) + struct.pack(" None: + stats_type = data[0] if len(data) >= 1 else STATS_TYPE_PACKETS + stats = self.bridge.get_stats(stats_type) + frame = bytes([RESP_CODE_STATS, stats_type]) + if stats_type == STATS_TYPE_CORE: + frame += struct.pack(" None: + ok = self.bridge.import_contact(data) + self._write_ok() if ok else self._write_err(ERR_CODE_ILLEGAL_ARG) + + async def _cmd_get_channel(self, data: bytes) -> None: + # Payload: channel index (1 byte), or empty for "get full list" (some apps send + # one request with no payload to receive all channels in one go). + channel_idx = data[0] if len(data) >= 1 else 0 + get_full_list = len(data) == 0 + max_channels_val = getattr( + getattr(self.bridge, "channels", None), "max_channels", 40 + ) + logger.debug( + f"CMD_GET_CHANNEL: idx={channel_idx}, data_len={len(data)}, get_full_list={get_full_list}" + ) + + def _channel_info_frame(idx: int, ch, include_idx: bool = True) -> bytes: + if ch is None: + name = b"\x00" * 32 + secret = b"\x00" * 32 + else: + name = ch.name.encode("utf-8", errors="replace")[:32].ljust( + 32, b"\x00" + ) + secret = ( + ch.secret[:32].ljust(32, b"\x00") if ch.secret else b"\x00" * 32 + ) + if include_idx: + return bytes([RESP_CODE_CHANNEL_INFO, idx]) + name + secret + # Some apps expect code + name(32) + secret(32) only (no index byte) + return bytes([RESP_CODE_CHANNEL_INFO]) + name + secret + + if get_full_list: + for idx in range(max_channels_val): + ch = self.bridge.get_channel(idx) + frame = _channel_info_frame(idx, ch, include_idx=True) + self._write_frame(frame) + logger.debug(f"CMD_GET_CHANNEL: sent full list ({max_channels_val} slots)") + return + + if channel_idx < 0 or channel_idx >= max_channels_val: + logger.debug(f"CMD_GET_CHANNEL: channel {channel_idx} out of range") + self._write_err(ERR_CODE_NOT_FOUND) + return + ch = self.bridge.get_channel(channel_idx) + if ch is None: + logger.debug(f"CMD_GET_CHANNEL: returning empty slot {channel_idx}") + else: + logger.debug(f"CMD_GET_CHANNEL: returning {ch.name!r}, secret_len=32") + # Send code + name(32) + secret(32) without channel_idx so the app sees + # name at offset 1 (avoids app treating channel_idx as first byte of name). + frame = _channel_info_frame(channel_idx, ch, include_idx=False) + self._write_frame(frame) + + async def _cmd_set_channel(self, data: bytes) -> None: + # MeshCore format: channel_idx(1) + name(32) + secret(32) or secret_hex(64) + logger.debug(f"CMD_SET_CHANNEL: data_len={len(data)}, data_hex={data[:50].hex()}...") + if len(data) < 34: # minimum: idx + name(32) + at least 1 byte secret + logger.debug(f"CMD_SET_CHANNEL: rejected (len {len(data)} < 34)") + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + channel_idx = data[0] + name_raw = data[1:33] + name = name_raw.split(b"\x00")[0].decode("utf-8", errors="replace").strip() + if len(data) >= 97: + # Hex secret: 64 hex chars = 32 bytes + try: + secret = bytes.fromhex(data[33:97].decode("ascii")) + logger.debug(f"CMD_SET_CHANNEL: parsed hex secret, len={len(secret)}") + except (ValueError, UnicodeDecodeError) as e: + logger.debug(f"CMD_SET_CHANNEL: hex secret parse failed: {e}") + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + elif len(data) >= 65: + # Binary secret: 32 bytes (MeshCore DataStore format) + secret = data[33:65] + logger.debug(f"CMD_SET_CHANNEL: parsed 32-byte binary secret") + elif len(data) >= 49: + # Legacy: 16-byte binary secret + secret = data[33:49] + logger.debug(f"CMD_SET_CHANNEL: parsed 16-byte binary secret") + else: + logger.debug(f"CMD_SET_CHANNEL: rejected (len {len(data)} not in 49/65/97)") + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + logger.debug(f"CMD_SET_CHANNEL: idx={channel_idx}, name={name!r}, secret_len={len(secret)}") + ok = self.bridge.set_channel(channel_idx, name, secret) + if ok and self.sqlite_handler: + self._save_channels() + logger.debug(f"CMD_SET_CHANNEL: set_channel ok={ok}") + + self._write_ok() if ok else self._write_err(ERR_CODE_TABLE_FULL) + + def _save_channels(self) -> None: + """Persist channels to SQLite.""" + if not self.sqlite_handler: + return + channels = [] + max_ch = getattr( + getattr(self.bridge, "channels", None), "max_channels", 40 + ) + for idx in range(max_ch): + ch = self.bridge.get_channel(idx) + if ch is not None: + channels.append({ + "channel_idx": idx, + "name": ch.name, + "secret": ch.secret, + }) + self.sqlite_handler.companion_save_channels(self.companion_hash, channels) diff --git a/repeater/config_manager.py b/repeater/config_manager.py index b4c13f8..e2457a3 100644 --- a/repeater/config_manager.py +++ b/repeater/config_manager.py @@ -1,7 +1,9 @@ +from __future__ import annotations + import logging import os import yaml -from typing import Optional, Dict, Any, List +from typing import Any, Dict, List, Optional logger = logging.getLogger("ConfigManager") @@ -22,32 +24,35 @@ class ConfigManager: self.config = config self.daemon = daemon_instance - def save_to_file(self) -> bool: + def save_to_file(self) -> tuple[bool, str]: """ Save current config to YAML file. - + Returns: - True if successful, False otherwise + (True, "") if successful, (False, error_message) otherwise """ try: - os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + dirpath = os.path.dirname(self.config_path) + if dirpath: + os.makedirs(dirpath, exist_ok=True) with open(self.config_path, 'w') as f: # Use safe_dump with explicit width to prevent line wrapping # Setting width to a very large number prevents truncation of long strings like identity keys yaml.safe_dump( - self.config, - f, - default_flow_style=False, - indent=2, + self.config, + f, + default_flow_style=False, + indent=2, width=1000000, # Very large width to prevent any line wrapping sort_keys=False, allow_unicode=True ) logger.info(f"Configuration saved to {self.config_path}") - return True + return True, "" except Exception as e: - logger.error(f"Failed to save config to {self.config_path}: {e}", exc_info=True) - return False + msg = f"Failed to save config to {self.config_path}: {e}" + logger.error(msg, exc_info=True) + return False, str(e) def live_update_daemon(self, sections: Optional[List[str]] = None) -> bool: """ @@ -140,10 +145,11 @@ class ConfigManager: self.config[section] = values # Save to file - result["saved"] = self.save_to_file() - + saved, err = self.save_to_file() + result["saved"] = saved + if not result["saved"]: - result["error"] = "Failed to save config to file" + result["error"] = err or "Failed to save config to file" return result # Live update daemon if requested diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 0f198c9..21ebca5 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -249,9 +249,75 @@ class SQLiteHandler: (migration_name, time.time()) ) logger.info(f"Migration '{migration_name}' applied successfully") - + + # Migration 4: Add companion tables for companion identity persistence + migration_name = "add_companion_tables" + existing = conn.execute( + "SELECT migration_name FROM migrations WHERE migration_name = ?", + (migration_name,) + ).fetchone() + + if not existing: + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='companion_contacts'" + ) + if not cursor.fetchone(): + conn.execute(""" + CREATE TABLE companion_contacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + companion_hash TEXT NOT NULL, + pubkey BLOB NOT NULL, + name TEXT NOT NULL, + adv_type INTEGER NOT NULL DEFAULT 0, + flags INTEGER NOT NULL DEFAULT 0, + out_path_len INTEGER NOT NULL DEFAULT -1, + out_path BLOB, + last_advert_timestamp INTEGER NOT NULL DEFAULT 0, + lastmod INTEGER NOT NULL DEFAULT 0, + gps_lat REAL NOT NULL DEFAULT 0, + gps_lon REAL NOT NULL DEFAULT 0, + sync_since INTEGER NOT NULL DEFAULT 0, + updated_at REAL NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE companion_channels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + companion_hash TEXT NOT NULL, + channel_idx INTEGER NOT NULL, + name TEXT NOT NULL, + secret BLOB NOT NULL, + updated_at REAL NOT NULL + ) + """) + conn.execute(""" + CREATE TABLE companion_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + companion_hash TEXT NOT NULL, + sender_key BLOB NOT NULL, + txt_type INTEGER NOT NULL DEFAULT 0, + timestamp INTEGER NOT NULL DEFAULT 0, + text TEXT NOT NULL, + is_channel INTEGER NOT NULL DEFAULT 0, + channel_idx INTEGER NOT NULL DEFAULT 0, + path_len INTEGER NOT NULL DEFAULT 0, + created_at REAL NOT NULL + ) + """) + conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_contacts_hash ON companion_contacts(companion_hash)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_contacts_pubkey ON companion_contacts(companion_hash, pubkey)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_channels_hash ON companion_channels(companion_hash)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_messages_hash ON companion_messages(companion_hash)") + logger.info("Created companion_contacts, companion_channels, companion_messages tables") + + conn.execute( + "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", + (migration_name, time.time()) + ) + logger.info(f"Migration '{migration_name}' applied successfully") + conn.commit() - + except Exception as e: logger.error(f"Failed to run migrations: {e}") @@ -1344,3 +1410,151 @@ class SQLiteHandler: except Exception as e: logger.error(f"Failed to cleanup old messages: {e}") return 0 + + # Companion persistence methods + def companion_load_contacts(self, companion_hash: str) -> List[Dict]: + """Load contacts for a companion from storage.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT pubkey, name, adv_type, flags, out_path_len, out_path, + last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since + FROM companion_contacts WHERE companion_hash = ? + """, (companion_hash,)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Failed to load companion contacts: {e}") + return [] + + def companion_save_contacts(self, companion_hash: str, contacts: List[Dict]) -> bool: + """Replace all contacts for a companion in storage.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.execute("DELETE FROM companion_contacts WHERE companion_hash = ?", (companion_hash,)) + now = time.time() + for c in contacts: + conn.execute(""" + INSERT INTO companion_contacts + (companion_hash, pubkey, name, adv_type, flags, out_path_len, out_path, + last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + companion_hash, + c.get("pubkey", b""), + c.get("name", ""), + c.get("adv_type", 0), + c.get("flags", 0), + c.get("out_path_len", -1), + c.get("out_path", b""), + c.get("last_advert_timestamp", 0), + c.get("lastmod", 0), + c.get("gps_lat", 0.0), + c.get("gps_lon", 0.0), + c.get("sync_since", 0), + now, + )) + conn.commit() + return True + except Exception as e: + logger.error(f"Failed to save companion contacts: {e}") + return False + + def companion_load_channels(self, companion_hash: str) -> List[Dict]: + """Load channels for a companion from storage.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT channel_idx, name, secret FROM companion_channels + WHERE companion_hash = ? ORDER BY channel_idx + """, (companion_hash,)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Failed to load companion channels: {e}") + return [] + + def companion_save_channels(self, companion_hash: str, channels: List[Dict]) -> bool: + """Replace all channels for a companion in storage.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.execute("DELETE FROM companion_channels WHERE companion_hash = ?", (companion_hash,)) + now = time.time() + for ch in channels: + conn.execute(""" + INSERT INTO companion_channels + (companion_hash, channel_idx, name, secret, updated_at) + VALUES (?, ?, ?, ?, ?) + """, ( + companion_hash, + ch.get("channel_idx", 0), + ch.get("name", ""), + ch.get("secret", b""), + now, + )) + conn.commit() + return True + except Exception as e: + logger.error(f"Failed to save companion channels: {e}") + return False + + def companion_load_messages(self, companion_hash: str, limit: int = 100) -> List[Dict]: + """Load queued messages for a companion (oldest first for queue order).""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len + FROM companion_messages WHERE companion_hash = ? + ORDER BY created_at ASC LIMIT ? + """, (companion_hash, limit)) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Failed to load companion messages: {e}") + return [] + + def companion_push_message(self, companion_hash: str, msg: Dict) -> bool: + """Append a message to the companion's queue.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.execute(""" + INSERT INTO companion_messages + (companion_hash, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + companion_hash, + msg.get("sender_key", b""), + msg.get("txt_type", 0), + msg.get("timestamp", 0), + msg.get("text", ""), + int(msg.get("is_channel", False)), + msg.get("channel_idx", 0), + msg.get("path_len", 0), + time.time(), + )) + conn.commit() + return True + except Exception as e: + logger.error(f"Failed to push companion message: {e}") + return False + + def companion_pop_message(self, companion_hash: str) -> Optional[Dict]: + """Remove and return the oldest message from the companion's queue.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute(""" + SELECT id, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len + FROM companion_messages WHERE companion_hash = ? + ORDER BY created_at ASC LIMIT 1 + """, (companion_hash,)) + row = cursor.fetchone() + if not row: + return None + msg = dict(row) + conn.execute("DELETE FROM companion_messages WHERE id = ?", (msg["id"],)) + conn.commit() + return {k: v for k, v in msg.items() if k != "id"} + except Exception as e: + logger.error(f"Failed to pop companion message: {e}") + return None diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py index 63600c0..2f7dd12 100644 --- a/repeater/handler_helpers/mesh_cli.py +++ b/repeater/handler_helpers/mesh_cli.py @@ -197,7 +197,10 @@ class MeshCLI: # Save config and live update try: - self.config_manager.save_to_file() + saved, err = self.config_manager.save_to_file() + if not saved: + logger.error(f"Failed to save password: {err}") + return f"Error: Failed to save config: {err}" self.config_manager.live_update_daemon(['security']) return f"password now: {new_password}" except Exception as e: @@ -337,32 +340,32 @@ class MeshCLI: try: if key == "af": self.repeater_config['airtime_factor'] = float(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" elif key == "name": self.repeater_config['node_name'] = value - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" elif key == "repeat": disabled = value.lower() == "off" self.repeater_config['disable_forward'] = disabled - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return f"OK - repeat is now {'OFF' if disabled else 'ON'}" elif key == "lat": self.repeater_config['latitude'] = float(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" elif key == "lon": self.repeater_config['longitude'] = float(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" @@ -379,7 +382,7 @@ class MeshCLI: self.config['radio']['bandwidth'] = float(radio_parts[1]) self.config['radio']['spreading_factor'] = int(radio_parts[2]) self.config['radio']['coding_rate'] = int(radio_parts[3]) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['radio']) return "OK - restart repeater to apply" @@ -387,7 +390,7 @@ class MeshCLI: if 'radio' not in self.config: self.config['radio'] = {} self.config['radio']['frequency'] = float(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['radio']) return "OK - restart repeater to apply" @@ -395,7 +398,7 @@ class MeshCLI: if 'radio' not in self.config: self.config['radio'] = {} self.config['radio']['tx_power'] = int(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['radio']) return "OK" @@ -403,7 +406,7 @@ class MeshCLI: if 'security' not in self.config: self.config['security'] = {} self.config['security']['guest_password'] = value - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['security']) return "OK" @@ -411,7 +414,7 @@ class MeshCLI: if 'security' not in self.config: self.config['security'] = {} self.config['security']['allow_read_only'] = value.lower() == "on" - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['security']) return "OK" @@ -420,7 +423,7 @@ class MeshCLI: if mins > 0 and (mins < 60 or mins > 240): return "Error: interval range is 60-240 minutes" self.repeater_config['advert_interval_minutes'] = mins - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" @@ -429,7 +432,7 @@ class MeshCLI: if (hours > 0 and hours < 3) or hours > 48: return "Error: interval range is 3-48 hours" self.repeater_config['flood_advert_interval_hours'] = hours - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" @@ -438,7 +441,7 @@ class MeshCLI: if max_val > 64: return "Error: max 64" self.repeater_config['max_flood_hops'] = max_val - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" @@ -447,7 +450,7 @@ class MeshCLI: if delay < 0: return "Error: cannot be negative" self.repeater_config['rx_delay_base'] = delay - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater', 'delays']) return "OK" @@ -456,7 +459,7 @@ class MeshCLI: if delay < 0: return "Error: cannot be negative" self.repeater_config['tx_delay_factor'] = delay - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater', 'delays']) return "OK" @@ -465,19 +468,19 @@ class MeshCLI: if delay < 0: return "Error: cannot be negative" self.repeater_config['direct_tx_delay_factor'] = delay - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater', 'delays']) return "OK" elif key == "multi.acks": self.repeater_config['multi_acks'] = int(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" elif key == "int.thresh": self.repeater_config['interference_threshold'] = int(value) - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return "OK" @@ -486,7 +489,7 @@ class MeshCLI: # Round to nearest multiple of 4 rounded = (interval // 4) * 4 self.repeater_config['agc_reset_interval'] = rounded - self.config_manager.save_to_file() + saved, _ = self.config_manager.save_to_file() self.config_manager.live_update_daemon(['repeater']) return f"OK - interval rounded to {rounded}" diff --git a/repeater/main.py b/repeater/main.py index 365e7f9..41cb33e 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -36,6 +36,8 @@ class RepeaterDaemon: self.protocol_request_helper = None self.acl = None self.router = None + self.companion_bridges: dict[int, object] = {} + self.companion_frame_servers: list = [] log_level = config.get("logging", {}).get("level", "INFO") @@ -256,6 +258,9 @@ class RepeaterDaemon: ) logger.info("Protocol request handler initialized") + # Load companion identities (CompanionBridge + frame server per companion) + await self._load_companion_identities() + except Exception as e: logger.error(f"Failed to initialize dispatcher: {e}") raise @@ -319,6 +324,136 @@ class RepeaterDaemon: total_identities = len(self.identity_manager.list_identities()) logger.info(f"Identity manager loaded {total_identities} total identities") + async def _load_companion_identities(self) -> None: + """Load companion identities from config and create CompanionBridge + frame server for each.""" + from pymc_core import LocalIdentity + from pymc_core.companion import CompanionBridge + from pymc_core.companion.models import Contact, Channel + + from repeater.companion import CompanionFrameServer + + companions_config = self.config.get("identities", {}).get("companions") or [] + if not companions_config: + return + + sqlite_handler = None + if self.repeater_handler and self.repeater_handler.storage: + sqlite_handler = self.repeater_handler.storage.sqlite_handler + + radio_config = self.repeater_handler.radio_config if self.repeater_handler else self.config.get("radio", {}) + + for comp_config in companions_config: + try: + name = comp_config.get("name") + identity_key = comp_config.get("identity_key") + settings = comp_config.get("settings") or {} + + if not name or not identity_key: + logger.warning("Skipping companion config: missing name or identity_key") + continue + + if isinstance(identity_key, str): + try: + identity_key_bytes = bytes.fromhex(identity_key) + except ValueError as e: + logger.error(f"Companion '{name}' identity_key invalid hex: {e}") + continue + elif isinstance(identity_key, bytes): + identity_key_bytes = identity_key + else: + logger.error(f"Companion '{name}' identity_key has unknown type") + continue + + if len(identity_key_bytes) not in (32, 64): + logger.error( + f"Companion '{name}' identity_key must be 32 bytes (hex) or 64 bytes (MeshCore firmware key)" + ) + continue + + identity = LocalIdentity(seed=identity_key_bytes) + pubkey = identity.get_public_key() + companion_hash = pubkey[0] + companion_hash_str = f"{companion_hash:02x}" + + node_name = settings.get("node_name", name) + tcp_port = settings.get("tcp_port", 5000) + bind_address = settings.get("bind_address", "0.0.0.0") + + bridge = CompanionBridge( + identity=identity, + packet_injector=self.router.inject_packet, + node_name=node_name, + radio_config=radio_config, + ) + + # Load contacts from SQLite + if sqlite_handler: + contact_rows = sqlite_handler.companion_load_contacts(companion_hash_str) + if contact_rows: + records = [] + for row in contact_rows: + d = dict(row) + d["public_key"] = d.pop("pubkey", d.get("public_key", b"")) + records.append(d) + bridge.contacts.load_from_dicts(records) + + # Load channels from SQLite + channel_rows = sqlite_handler.companion_load_channels(companion_hash_str) + for row in channel_rows: + ch = Channel( + name=row.get("name", ""), + secret=row.get("secret", b"") if isinstance(row.get("secret"), bytes) else (bytes.fromhex(row.get("secret", "")) if row.get("secret") else b""), + ) + bridge.channels.set(row.get("channel_idx", 0), ch) + + # Preload queued messages from SQLite into bridge + for msg_dict in sqlite_handler.companion_load_messages(companion_hash_str): + from pymc_core.companion.models import QueuedMessage + sk = msg_dict.get("sender_key", b"") + if isinstance(sk, str): + sk = bytes.fromhex(sk) + bridge.message_queue.push(QueuedMessage( + sender_key=sk, + txt_type=msg_dict.get("txt_type", 0), + timestamp=msg_dict.get("timestamp", 0), + text=msg_dict.get("text", ""), + is_channel=bool(msg_dict.get("is_channel", False)), + channel_idx=msg_dict.get("channel_idx", 0), + path_len=msg_dict.get("path_len", 0), + )) + + # Ensure public channel (0) exists with default key for new companions + from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET + if bridge.get_channel(0) is None: + bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET) + + self.companion_bridges[companion_hash] = bridge + + frame_server = CompanionFrameServer( + bridge=bridge, + companion_hash=companion_hash_str, + port=tcp_port, + bind_address=bind_address, + sqlite_handler=sqlite_handler, + ) + await frame_server.start() + self.companion_frame_servers.append(frame_server) + + self.identity_manager.register_identity( + name=name, + identity=identity, + config=comp_config, + identity_type="companion", + ) + + logger.info( + f"Loaded companion '{name}': hash=0x{companion_hash:02x}, " + f"port={tcp_port}, bind={bind_address}" + ) + + except Exception as e: + logger.error(f"Failed to load companion '{name}': {e}", exc_info=True) + def _register_identity_everywhere( self, name: str, @@ -509,6 +644,18 @@ class RepeaterDaemon: await self.dispatcher.run_forever() except KeyboardInterrupt: logger.info("Shutting down...") + for frame_server in getattr(self, "companion_frame_servers", []): + try: + await frame_server.stop() + except Exception as e: + logger.debug(f"Companion frame server stop: {e}") + if hasattr(self, "companion_bridges"): + for bridge in self.companion_bridges.values(): + if hasattr(bridge, "stop"): + try: + await bridge.stop() + except Exception as e: + logger.debug(f"Companion bridge stop: {e}") if self.router: await self.router.stop() if self.http_server: diff --git a/repeater/packet_router.py b/repeater/packet_router.py index 385e3b4..785c429 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -8,7 +8,8 @@ from pymc_core.node.handlers.login_server import LoginServerHandler from pymc_core.node.handlers.text import TextMessageHandler from pymc_core.node.handlers.path import PathHandler from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler - +from pymc_core.node.handlers.group_text import GroupTextHandler +from pymc_core.node.handlers.protocol_response import ProtocolResponseHandler logger = logging.getLogger("PacketRouter") class PacketRouter: @@ -93,36 +94,72 @@ class PacketRouter: rssi = getattr(packet, "rssi", 0) snr = getattr(packet, "snr", 0.0) await self.daemon.advert_helper.process_advert_packet(packet, rssi, snr) - + # Also feed adverts to companion bridges (for contact/path updates) + for bridge in getattr(self.daemon, "companion_bridges", {}).values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge advert error: {e}") + elif payload_type == LoginServerHandler.payload_type(): - # Process ANON_REQ login packet for all identities - if self.daemon.login_helper: + # Route to companion if dest is a companion; else to login_helper + dest_hash = packet.payload[0] if packet.payload else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + if dest_hash is not None and dest_hash in companion_bridges: + await companion_bridges[dest_hash].process_received_packet(packet) + processed_by_injection = True + elif self.daemon.login_helper: handled = await self.daemon.login_helper.process_login_packet(packet) - # Only skip forwarding if we actually handled it if handled: processed_by_injection = True - + elif payload_type == TextMessageHandler.payload_type(): - # Process TXT_MSG packet for all identities - if self.daemon.text_helper: + dest_hash = packet.payload[0] if packet.payload else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + if dest_hash is not None and dest_hash in companion_bridges: + await companion_bridges[dest_hash].process_received_packet(packet) + processed_by_injection = True + elif self.daemon.text_helper: handled = await self.daemon.text_helper.process_text_packet(packet) - # Only skip forwarding if we actually handled it if handled: processed_by_injection = True - + elif payload_type == PathHandler.payload_type(): - # Process PATH packet to update client out_path for direct routing - if self.daemon.path_helper: + dest_hash = packet.payload[0] if packet.payload else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + if dest_hash is not None and dest_hash in companion_bridges: + await companion_bridges[dest_hash].process_received_packet(packet) + processed_by_injection = True + elif self.daemon.path_helper: await self.daemon.path_helper.process_path_packet(packet) - # Note: process_path_packet returns False to allow forwarding - + + elif payload_type == ProtocolResponseHandler.payload_type(): + dest_hash = packet.payload[0] if packet.payload else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + if dest_hash is not None and dest_hash in companion_bridges: + await companion_bridges[dest_hash].process_received_packet(packet) + processed_by_injection = True + elif payload_type == ProtocolRequestHandler.payload_type(): - # Process protocol request packet (status, telemetry, neighbors, etc.) - if self.daemon.protocol_request_helper: + dest_hash = packet.payload[0] if packet.payload else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + if dest_hash is not None and dest_hash in companion_bridges: + await companion_bridges[dest_hash].process_received_packet(packet) + processed_by_injection = True + elif self.daemon.protocol_request_helper: handled = await self.daemon.protocol_request_helper.process_request_packet(packet) if handled: processed_by_injection = True - + + elif payload_type == GroupTextHandler.payload_type(): + # GRP_TXT: pass to all companions (they filter by channel); still forward + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + for bridge in companion_bridges.values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge GRP_TXT error: {e}") + # Only pass to repeater engine if not already processed by injection if self.daemon.repeater_handler and not processed_by_injection: metadata = { diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 857f403..0ed865e 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -627,12 +627,15 @@ class APIEndpoints: live_update=True, live_update_sections=['duty_cycle'] ) - + + if not result.get("saved", False): + return self._error(result.get("error", "Failed to save configuration to file")) + logger.info(f"Duty cycle config updated: {', '.join(applied)}") - + return self._success({ "applied": applied, - "persisted": result.get("saved", False), + "persisted": True, "live_update": result.get("live_updated", False), "restart_required": False, "message": "Duty cycle settings applied immediately." @@ -1126,8 +1129,10 @@ class APIEndpoints: self.config["radio"]["cad"]["min_threshold"] = min_val config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml') - self.config_manager.save_to_file() - + saved, err = self.config_manager.save_to_file() + if not saved: + return self._error(err or "Failed to save configuration to file") + logger.info(f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%") return { "success": True, @@ -1330,12 +1335,15 @@ class APIEndpoints: live_update=True, live_update_sections=live_sections ) - + + if not result.get("saved", False): + return self._error(result.get("error", "Failed to save configuration to file")) + logger.info(f"Radio config updated: {', '.join(applied)}") - + return self._success({ "applied": applied, - "persisted": result.get("saved", False), + "persisted": True, "live_update": result.get("live_updated", False), "restart_required": not result.get("live_updated", False), "message": "Settings applied immediately." if result.get("live_updated") else "Settings saved. Restart service to apply changes." @@ -1649,8 +1657,12 @@ class APIEndpoints: # Update the configuration file using ConfigManager try: - self.config_manager.save_to_file() - logger.info(f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}") + saved, err = self.config_manager.save_to_file() + if saved: + logger.info(f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}") + else: + logger.error(f"Failed to save global flood policy to file: {err}") + return self._error(err or "Failed to save configuration to file") except Exception as e: logger.error(f"Failed to save global flood policy to file: {e}") return self._error(f"Failed to save configuration to file: {e}") @@ -2006,8 +2018,10 @@ class APIEndpoints: self.config["identities"]["room_servers"] = room_servers # Save to file - self.config_manager.save_to_file() - + saved, err = self.config_manager.save_to_file() + if not saved: + return self._error(err or "Failed to save configuration to file") + logger.info(f"Created new identity: {name} (type: {identity_type}){' with auto-generated key' if key_was_generated else ''}") # Hot reload - register identity immediately @@ -2152,9 +2166,11 @@ class APIEndpoints: # Save to config room_servers[identity_index] = identity self.config["identities"]["room_servers"] = room_servers - - self.config_manager.save_to_file() - + + saved, err = self.config_manager.save_to_file() + if not saved: + return self._error(err or "Failed to save configuration to file") + logger.info(f"Updated identity: {name}") # Hot reload - re-register identity if key changed or name changed @@ -2249,9 +2265,11 @@ class APIEndpoints: # Update config self.config["identities"]["room_servers"] = room_servers - - self.config_manager.save_to_file() - + + saved, err = self.config_manager.save_to_file() + if not saved: + return self._error(err or "Failed to save configuration to file") + logger.info(f"Deleted identity: {name}") unregister_success = False diff --git a/repeater/web/auth_endpoints.py b/repeater/web/auth_endpoints.py index 2ceb572..acf8f51 100644 --- a/repeater/web/auth_endpoints.py +++ b/repeater/web/auth_endpoints.py @@ -435,7 +435,8 @@ class AuthEndpoints: # Save to config file using ConfigManager if self.config_manager: - if self.config_manager.save_to_file(): + saved, _ = self.config_manager.save_to_file() + if saved: logger.info(f"Admin password changed successfully by user {user['username']}") return json.dumps({ 'success': True, From c0dec9e80a16ad1968f7a8b6537b6acbe3e8e32e Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 13 Feb 2026 22:26:05 -0800 Subject: [PATCH 05/29] Implement companion client communication and statistics retrieval - Added raw RX and trace completion handlers to push data to connected companion clients. - Enhanced PacketRouter to deliver ACK packets to all companion bridges. - Introduced methods for retrieving companion statistics based on different types. - Updated constants for handling new commands and responses in the companion protocol. This update improves the interaction between the repeater and companion clients, enabling better data flow and monitoring capabilities. --- repeater/companion/constants.py | 11 +- repeater/companion/frame_server.py | 324 ++++++++++++++++++++++++----- repeater/handler_helpers/trace.py | 9 + repeater/main.py | 94 +++++++++ repeater/packet_router.py | 24 ++- 5 files changed, 397 insertions(+), 65 deletions(-) diff --git a/repeater/companion/constants.py b/repeater/companion/constants.py index 3ae73be..4fdbd7c 100644 --- a/repeater/companion/constants.py +++ b/repeater/companion/constants.py @@ -1,5 +1,7 @@ """Companion frame protocol constants (MeshCore Companion Radio Protocol).""" +import base64 + # Commands (app -> radio) CMD_APP_START = 1 CMD_SEND_TXT_MSG = 2 @@ -127,9 +129,10 @@ ADV_TYPE_REPEATER = 2 ADV_TYPE_ROOM = 3 ADV_TYPE_SENSOR = 4 -# Default Public channel PSK (from firmware) +# Default Public channel PSK (from firmware MeshCore/examples/companion_radio/MyMesh.cpp) +# Base64-encoded; decode to get the 16-byte secret used for MAC/AES PUBLIC_GROUP_PSK = b"izOH6cXN6mrJ5e26oRXNcg==" -# Default public channel secret (hex) - used for channel 0 when no channels loaded -# Matches MeshCore firmware default for new radios -DEFAULT_PUBLIC_CHANNEL_SECRET = bytes.fromhex("8b3387e9c5cdea6ac9e5edbaa115cd72") +# Default public channel secret: base64-decode PUBLIC_GROUP_PSK so we match firmware +# (firmware uses decode_base64(psk) -> 16 bytes; HMAC key is that + 16 zero bytes) +DEFAULT_PUBLIC_CHANNEL_SECRET = base64.b64decode(PUBLIC_GROUP_PSK) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index 91ef2b1..8864317 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -23,15 +23,18 @@ from .constants import ( CMD_SET_FLOOD_SCOPE, CMD_APP_START, CMD_DEVICE_QUERY, + CMD_GET_ADVERT_PATH, CMD_GET_BATT_AND_STORAGE, CMD_GET_CONTACTS, CMD_GET_STATS, CMD_IMPORT_CONTACT, CMD_REMOVE_CONTACT, CMD_RESET_PATH, + CMD_SEND_BINARY_REQ, CMD_SEND_CHANNEL_TXT_MSG, CMD_SEND_LOGIN, CMD_SEND_SELF_ADVERT, + CMD_SEND_TRACE_PATH, CMD_SEND_TXT_MSG, CMD_SET_ADVERT_LATLON, CMD_SET_ADVERT_NAME, @@ -46,9 +49,14 @@ from .constants import ( MAX_PATH_SIZE, PUB_KEY_SIZE, PUSH_CODE_ADVERT, + PUSH_CODE_BINARY_RESPONSE, + PUSH_CODE_NEW_ADVERT, + PUSH_CODE_LOG_RX_DATA, + PUSH_CODE_TRACE_DATA, PUSH_CODE_MSG_WAITING, PUSH_CODE_PATH_UPDATED, PUSH_CODE_SEND_CONFIRMED, + RESP_CODE_ADVERT_PATH, RESP_CODE_BATT_AND_STORAGE, RESP_CODE_CHANNEL_INFO, RESP_CODE_CHANNEL_MSG_RECV, @@ -85,12 +93,16 @@ class CompanionFrameServer: port: int = 5000, bind_address: str = "0.0.0.0", sqlite_handler=None, + local_hash: Optional[int] = None, + stats_getter=None, ): self.bridge = bridge self.companion_hash = companion_hash self.port = port self.bind_address = bind_address self.sqlite_handler = sqlite_handler + self.local_hash = local_hash # Repeater's node hash; if path ends with this, we are final hop + self.stats_getter = stats_getter # Optional (stats_type: int) -> dict for companion stats self._server: Optional[asyncio.Server] = None self._client_writer: Optional[asyncio.StreamWriter] = None self._client_reader: Optional[asyncio.StreamReader] = None @@ -161,43 +173,120 @@ class CompanionFrameServer: pubkey = bytes.fromhex(pubkey) else: pubkey = getattr(contact, "public_key", getattr(contact, "pub_key", b"")) - if len(pubkey) >= 32: - _write_push(bytes([PUSH_CODE_ADVERT]) + pubkey[:32]) + if isinstance(pubkey, str): + pubkey = bytes.fromhex(pubkey) + if len(pubkey) < 32: + return + _write_push(bytes([PUSH_CODE_ADVERT]) + pubkey[:32]) + # Full contact push (PUSH_CODE_NEW_ADVERT) so app gets NEW_CONTACT and can add to list + if not isinstance(contact, dict) and hasattr(contact, "name") and contact.name: + pubkey_b = pubkey[:32] if isinstance(pubkey, bytes) else bytes.fromhex(str(pubkey))[:32] + name_b = (contact.name.encode("utf-8")[:32] if isinstance(contact.name, str) else contact.name[:32]).ljust(32, b"\x00") + opl = getattr(contact, "out_path_len", -1) + opl_byte = 0xFF if opl < 0 else min(opl, 255) + out_path = getattr(contact, "out_path", b"") or b"" + if isinstance(out_path, str): + out_path = bytes.fromhex(out_path) if out_path else b"" + elif isinstance(out_path, (list, bytearray)): + out_path = bytes(out_path) + out_path = out_path[:MAX_PATH_SIZE].ljust(MAX_PATH_SIZE, b"\x00") + adv_type = getattr(contact, "adv_type", 0) + flags = getattr(contact, "flags", 0) + last_advert = getattr(contact, "last_advert_timestamp", 0) + gps_lat = getattr(contact, "gps_lat", 0.0) + gps_lon = getattr(contact, "gps_lon", 0.0) + lastmod = getattr(contact, "lastmod", 0) + frame = ( + bytes([PUSH_CODE_NEW_ADVERT]) + + pubkey_b + + bytes([adv_type, flags, opl_byte]) + + out_path + + name_b + + struct.pack("= 32: _write_push(bytes([PUSH_CODE_PATH_UPDATED]) + pub_key[:32]) async def on_channel_message_received(channel_name, sender_name, message_text, timestamp, path_len=0): - channel_idx = 0 - max_ch = getattr( - getattr(self.bridge, "channels", None), "max_channels", 40 - ) - for idx in range(max_ch): - ch = self.bridge.get_channel(idx) - if ch and ch.name == channel_name: - channel_idx = idx - break - if self.sqlite_handler: - self.sqlite_handler.companion_push_message( - self.companion_hash, - { - "sender_key": b"", - "text": message_text, - "timestamp": timestamp, - "txt_type": 0, - "is_channel": True, - "channel_idx": channel_idx, - "path_len": path_len, - }, - ) + # Message is already in bridge.message_queue; do not push to sqlite or the same + # message would be delivered twice (once from queue, once from sqlite on next sync). _write_push(bytes([PUSH_CODE_MSG_WAITING])) + async def on_binary_response(tag_bytes, response_data, parsed=None, request_type=None): + # PUSH_CODE_BINARY_RESPONSE: 0x8C + reserved(1) + tag(4) + response_payload + frame = ( + bytes([PUSH_CODE_BINARY_RESPONSE, 0]) + + (tag_bytes if isinstance(tag_bytes, bytes) else struct.pack(" None: + """Push PUSH_CODE_TRACE_DATA (0x89) to client. Matches firmware onTraceRecv() frame format.""" + if not self._client_writer or self._client_writer.is_closing(): + return + # Firmware: code(1) + reserved(1) + path_len(1) + flags(1) + tag(4) + auth(4) + path_hashes + path_snrs + final_snr(1) + path_sz = flags & 0x03 + expected_snr_len = path_len >> path_sz + if len(path_snrs) != expected_snr_len: + logger.debug("push_trace_data: path_snrs len %s != expected %s", len(path_snrs), expected_snr_len) + return + data = ( + bytes([PUSH_CODE_TRACE_DATA, 0, path_len, flags]) + + struct.pack(" None: + """Push raw RX packet to client (PUSH_CODE_LOG_RX_DATA 0x88). Matches firmware logRxRaw() so client can track repeats by packet hash.""" + if not self._client_writer or self._client_writer.is_closing(): + logger.debug("push_rx_raw: no client connected (companion %s)", self.companion_hash) + return + # Firmware: code(1) + snr(1) + rssi(1) + raw; snr = (int8)(snr*4), rssi = (int8)rssi + snr_byte = max(-128, min(127, int(round(snr * 4)))) + rssi_byte = max(-128, min(127, int(rssi))) + if snr_byte < 0: + snr_byte += 256 + if rssi_byte < 0: + rssi_byte += 256 + payload_len = min(len(raw), MAX_FRAME_SIZE - 3) + data = bytes([PUSH_CODE_LOG_RX_DATA, snr_byte & 0xFF, rssi_byte & 0xFF]) + raw[:payload_len] + try: + frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack(" None: if self._client_writer: @@ -322,12 +411,18 @@ class CompanionFrameServer: await self._cmd_get_batt_and_storage(data) elif cmd == CMD_GET_STATS: await self._cmd_get_stats(data) + elif cmd == CMD_GET_ADVERT_PATH: + await self._cmd_get_advert_path(data) elif cmd == CMD_IMPORT_CONTACT: await self._cmd_import_contact(data) elif cmd == CMD_GET_CHANNEL: await self._cmd_get_channel(data) elif cmd == CMD_SET_CHANNEL: await self._cmd_set_channel(data) + elif cmd == CMD_SEND_BINARY_REQ: + await self._cmd_send_binary_req(data) + elif cmd == CMD_SEND_TRACE_PATH: + await self._cmd_send_trace_path(data) elif cmd == CMD_SET_FLOOD_SCOPE: # App sends this on connect; no-op for repeater companion (no radio scope) self._write_ok() @@ -449,12 +544,93 @@ class CompanionFrameServer: if len(data) < 6: self._write_err(ERR_CODE_ILLEGAL_ARG) return - # Protocol: txt_type(1) + channel_idx(1) + sender_timestamp(4) + text + # Protocol: txt_type(1) + channel_idx(1) + sender_timestamp(4) + text (matches firmware/meshcore_py) + txt_type = data[0] channel_idx = data[1] sender_ts = struct.unpack_from(" None: + # CMD_SEND_BINARY_REQ: pubkey(32) + req_data (request_type(1) + optional payload) + if len(data) < 33: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[:32] + req_data = data[32:] + send_binary_req = getattr(self.bridge, "send_binary_req", None) + if not send_binary_req: + self._write_err(ERR_CODE_UNSUPPORTED_CMD) + return + try: + result = await send_binary_req(pubkey, req_data) + except Exception as e: + logger.error(f"send_binary_req error: {e}", exc_info=True) + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + if not result.success: + self._write_err(ERR_CODE_NOT_FOUND) + return + # RESP_CODE_SENT: 0x06 + flood(1) + tag(4 LE) + timeout(4 LE) + tag = result.expected_ack if result.expected_ack is not None else 0 + timeout_ms = result.timeout_ms if result.timeout_ms is not None else 10000 + frame = bytes([RESP_CODE_SENT, 1 if result.is_flood else 0]) + struct.pack(" None: + # CMD_SEND_TRACE_PATH: tag(4) + auth(4) + flags(1) + path_bytes (firmware MyMesh.cpp) + if len(data) < 10: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + tag = struct.unpack_from("> path_sz) <= MAX_PATH_SIZE and path_len % (1 << path_sz) == 0 + if (path_len >> path_sz) > MAX_PATH_SIZE or (path_len % (1 << path_sz)) != 0: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + send_raw = getattr(self.bridge, "send_trace_path_raw", None) + if not send_raw: + self._write_err(ERR_CODE_UNSUPPORTED_CMD) + return + try: + ok = await send_raw(tag, auth_code, flags, path_bytes) + except Exception as e: + logger.error(f"send_trace_path error: {e}", exc_info=True) + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + if not ok: + self._write_err(ERR_CODE_TABLE_FULL) + return + # RESP_CODE_SENT + 0 (not flood) + tag(4) + est_timeout(4) = 10 bytes (firmware) + est_timeout_ms = 5000 + (path_len * 200) + frame = bytes([RESP_CODE_SENT, 0]) + struct.pack("> path_sz + path_snrs = bytes(snr_len) # no RX SNR when we're the sender + final_snr_byte = 0 + self.push_trace_data( + path_len, flags, tag, auth_code, path_bytes, path_snrs, final_snr_byte + ) async def _cmd_sync_next_message(self, data: bytes) -> None: msg = self.bridge.sync_next_message() @@ -474,20 +650,22 @@ class CompanionFrameServer: self._write_frame(bytes([RESP_CODE_NO_MORE_MESSAGES])) return if msg.is_channel: + # Layout must match meshcore_py reader.py (PacketType.CHANNEL_MSG_RECV and type 17) + # so client can group repeats by (channel_idx, sender_timestamp, text); path_len differs per repeat. path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF txt_type = 0 # TXT_TYPE_PLAIN - text_bytes = msg.text.encode("utf-8", errors="replace") + text_bytes = (msg.text or "").rstrip("\x00").encode("utf-8", errors="replace") if self._app_target_ver >= 3: - # RESP_CODE_CHANNEL_MSG_RECV_V3: code + snr + reserved(2) + channel_idx + path_len + txt_type + timestamp + text + # V3: code(1) + snr(1) + reserved(2) + channel_idx(1) + path_len(1) + txt_type(1) + timestamp(4) + text frame = bytes([ RESP_CODE_CHANNEL_MSG_RECV_V3, - 0, # snr (we don't have it when popping from queue) - 0, 0, # reserved + 0, 0, 0, # snr + reserved msg.channel_idx, path_len_byte, txt_type, ]) + struct.pack(" None: if len(data) < 32: self._write_err(ERR_CODE_ILLEGAL_ARG) + await self._drain_writer() return pubkey = data[:32] ok = self.bridge.remove_contact(pubkey) if ok and self.sqlite_handler: self._save_contacts() self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) + await self._drain_writer() async def _cmd_reset_path(self, data: bytes) -> None: if len(data) < 32: @@ -599,22 +780,58 @@ class CompanionFrameServer: self._write_frame(frame) async def _cmd_get_stats(self, data: bytes) -> None: + # CMD_GET_STATS (56): data[0] = stats_type (0=core, 1=radio, 2=packets). Firmware MyMesh.cpp + meshcore_py reader. stats_type = data[0] if len(data) >= 1 else STATS_TYPE_PACKETS - stats = self.bridge.get_stats(stats_type) + if stats_type not in (STATS_TYPE_CORE, STATS_TYPE_RADIO, STATS_TYPE_PACKETS): + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + stats = (self.stats_getter(stats_type) if self.stats_getter else None) or self.bridge.get_stats(stats_type) frame = bytes([RESP_CODE_STATS, stats_type]) if stats_type == STATS_TYPE_CORE: - frame += struct.pack(") + battery_mv = int(stats.get("battery_mv", 0)) + uptime_secs = int(stats.get("uptime_secs", 0)) + errors = int(stats.get("errors", 0)) + queue_len = min(255, max(0, int(stats.get("queue_len", 0)))) + frame += struct.pack(" None: + # CMD_GET_ADVERT_PATH (42): reserved(1) + pub_key(32). Return inbound path from advert_paths (path_cache). + # Firmware: RESP_CODE_ADVERT_PATH(1) + recv_timestamp(4 LE) + path_len(1) + path(path_len) + if len(data) < 1 + PUB_KEY_SIZE: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pub_key = data[1 : 1 + PUB_KEY_SIZE] + prefix = pub_key[:7] + found = self.bridge.get_advert_path(prefix) if getattr(self.bridge, "get_advert_path", None) else None + if not found: + self._write_err(ERR_CODE_NOT_FOUND) + return + path_bytes = getattr(found, "path", None) or b"" + if not isinstance(path_bytes, bytes): + path_bytes = bytes(path_bytes) + path_len = min(len(path_bytes), MAX_PATH_SIZE) + recv_ts = getattr(found, "recv_timestamp", 0) + frame = bytes([RESP_CODE_ADVERT_PATH]) + struct.pack(" None: @@ -633,26 +850,23 @@ class CompanionFrameServer: f"CMD_GET_CHANNEL: idx={channel_idx}, data_len={len(data)}, get_full_list={get_full_list}" ) - def _channel_info_frame(idx: int, ch, include_idx: bool = True) -> bytes: + # Frame format per firmware & meshcore_py: code(1) + channel_idx(1) + name(32) + secret(16) + def _channel_info_frame(idx: int, ch) -> bytes: if ch is None: name = b"\x00" * 32 - secret = b"\x00" * 32 + secret = b"\x00" * 16 else: name = ch.name.encode("utf-8", errors="replace")[:32].ljust( 32, b"\x00" ) - secret = ( - ch.secret[:32].ljust(32, b"\x00") if ch.secret else b"\x00" * 32 - ) - if include_idx: - return bytes([RESP_CODE_CHANNEL_INFO, idx]) + name + secret - # Some apps expect code + name(32) + secret(32) only (no index byte) - return bytes([RESP_CODE_CHANNEL_INFO]) + name + secret + # Firmware and meshcore_py use 16-byte (128-bit) secret in the frame + secret = (ch.secret[:16] if ch.secret else b"\x00" * 16).ljust(16, b"\x00") + return bytes([RESP_CODE_CHANNEL_INFO, idx]) + name + secret if get_full_list: for idx in range(max_channels_val): ch = self.bridge.get_channel(idx) - frame = _channel_info_frame(idx, ch, include_idx=True) + frame = _channel_info_frame(idx, ch) self._write_frame(frame) logger.debug(f"CMD_GET_CHANNEL: sent full list ({max_channels_val} slots)") return @@ -665,10 +879,8 @@ class CompanionFrameServer: if ch is None: logger.debug(f"CMD_GET_CHANNEL: returning empty slot {channel_idx}") else: - logger.debug(f"CMD_GET_CHANNEL: returning {ch.name!r}, secret_len=32") - # Send code + name(32) + secret(32) without channel_idx so the app sees - # name at offset 1 (avoids app treating channel_idx as first byte of name). - frame = _channel_info_frame(channel_idx, ch, include_idx=False) + logger.debug(f"CMD_GET_CHANNEL: returning {ch.name!r}, secret_len=16") + frame = _channel_info_frame(channel_idx, ch) self._write_frame(frame) async def _cmd_set_channel(self, data: bytes) -> None: @@ -708,7 +920,7 @@ class CompanionFrameServer: self._save_channels() logger.debug(f"CMD_SET_CHANNEL: set_channel ok={ok}") - self._write_ok() if ok else self._write_err(ERR_CODE_TABLE_FULL) + self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) def _save_channels(self) -> None: """Persist channels to SQLite.""" diff --git a/repeater/handler_helpers/trace.py b/repeater/handler_helpers/trace.py index d9da49d..6b38658 100644 --- a/repeater/handler_helpers/trace.py +++ b/repeater/handler_helpers/trace.py @@ -38,6 +38,9 @@ class TraceHelper: # Ping callback system - track pending ping requests by tag self.pending_pings = {} # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}} + # Optional: when trace reaches final node, call this (packet, parsed_data) to push 0x89 to companions + self.on_trace_complete = None # async (packet, parsed_data) -> None + # Create TraceHandler internally as a parsing utility self.trace_handler = TraceHandler(log_fn=log_fn or logger.info) @@ -107,6 +110,12 @@ class TraceHelper: else: # This is the final destination or can't forward - just log and record self._log_no_forward_reason(packet, trace_path, trace_path_len) + # When trace completed (reached end of path), push PUSH_CODE_TRACE_DATA (0x89) to companions (firmware onTraceRecv) + if packet.path_len >= trace_path_len and self.on_trace_complete: + try: + await self.on_trace_complete(packet, parsed_data) + except Exception as e: + logger.debug("on_trace_complete error: %s", e) except Exception as e: logger.error(f"Error processing trace packet: {e}") diff --git a/repeater/main.py b/repeater/main.py index 41cb33e..fc10c54 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -2,6 +2,7 @@ import asyncio import logging import os import sys +import time from repeater.config import get_radio_for_board, load_config from repeater.config_manager import ConfigManager @@ -261,6 +262,17 @@ class RepeaterDaemon: # Load companion identities (CompanionBridge + frame server per companion) await self._load_companion_identities() + # Subscribe to raw RX in pyMC_core so we can push PUSH_CODE_LOG_RX_DATA to companion clients + self.dispatcher.add_raw_rx_subscriber(self._on_raw_rx_for_companions) + n = len(getattr(self, "companion_frame_servers", [])) + logger.info( + "Raw RX subscriber registered (%s companion frame server(s)). Connect a client to see rx_log (0x88).", + n, + ) + + # When trace reaches final node, push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv) + self.trace_helper.on_trace_complete = self._on_trace_complete_for_companions + except Exception as e: logger.error(f"Failed to initialize dispatcher: {e}") raise @@ -435,6 +447,8 @@ class RepeaterDaemon: port=tcp_port, bind_address=bind_address, sqlite_handler=sqlite_handler, + local_hash=self.local_hash, + stats_getter=self._get_companion_stats, ) await frame_server.start() self.companion_frame_servers.append(frame_server) @@ -454,6 +468,38 @@ class RepeaterDaemon: except Exception as e: logger.error(f"Failed to load companion '{name}': {e}", exc_info=True) + async def _on_raw_rx_for_companions(self, data: bytes, rssi: int, snr: float) -> None: + """Raw RX subscriber: push PUSH_CODE_LOG_RX_DATA (0x88) to connected companion clients.""" + servers = getattr(self, "companion_frame_servers", []) + if not servers: + return + for fs in servers: + try: + fs.push_rx_raw(snr, rssi, data) + except Exception as e: + logger.debug("Push RX raw to companion: %s", e) + + async def _on_trace_complete_for_companions(self, packet, parsed_data) -> None: + """Trace completed at this node: push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv).""" + path_len = len(parsed_data.get("trace_path", [])) + if path_len == 0: + return + path_hashes = bytes(parsed_data["trace_path"]) + flags = parsed_data.get("flags", 0) + tag = parsed_data.get("tag", 0) + auth_code = parsed_data.get("auth_code", 0) + # path_snrs: exactly path_len bytes = (path_len-1) from forwarding hops + 1 (our receive SNR) + snr_scaled = max(-128, min(127, int(round(packet.get_snr() * 4)))) + snr_byte = snr_scaled if snr_scaled >= 0 else (256 + snr_scaled) + path_snrs = bytes(packet.path)[: path_len - 1] + bytes([snr_byte]) + for fs in getattr(self, "companion_frame_servers", []): + try: + fs.push_trace_data( + path_len, flags, tag, auth_code, path_hashes, path_snrs, snr_byte + ) + except Exception as e: + logger.debug("Push trace data to companion: %s", e) + def _register_identity_everywhere( self, name: str, @@ -553,6 +599,54 @@ class RepeaterDaemon: return stats + def _get_companion_stats(self, stats_type: int) -> dict: + """Return stats dict for companion CMD_GET_STATS (format expected by frame_server + meshcore_py).""" + from repeater.companion.constants import STATS_TYPE_CORE, STATS_TYPE_RADIO, STATS_TYPE_PACKETS + if not self.repeater_handler: + return {} + engine = self.repeater_handler + airtime = engine.airtime_mgr.get_stats() + uptime_secs = int(time.time() - engine.start_time) + queue_len = 0 + for bridge in getattr(self, "companion_bridges", {}).values(): + queue_len += getattr(getattr(bridge, "message_queue", None), "count", 0) or 0 + if stats_type == STATS_TYPE_CORE: + return { + "battery_mv": 0, + "uptime_secs": uptime_secs, + "errors": 0, + "queue_len": min(255, queue_len), + } + if stats_type == STATS_TYPE_RADIO: + noise_floor = int(engine.get_noise_floor() or 0) + radio = getattr(self, "dispatcher", None) and getattr(self.dispatcher, "radio", None) + if radio: + _r = getattr(radio, "get_last_rssi", lambda: 0) + _s = getattr(radio, "get_last_snr", lambda: 0.0) + last_rssi = _r() if callable(_r) else _r + last_snr = _s() if callable(_s) else _s + else: + last_rssi, last_snr = 0, 0.0 + tx_air_secs = int(airtime.get("total_airtime_ms", 0) / 1000) + return { + "noise_floor": noise_floor, + "last_rssi": int(last_rssi) if last_rssi is not None else 0, + "last_snr": float(last_snr) if last_snr is not None else 0.0, + "tx_air_secs": tx_air_secs, + "rx_air_secs": 0, + } + if stats_type == STATS_TYPE_PACKETS: + return { + "recv": getattr(engine, "rx_count", 0), + "sent": getattr(engine, "forwarded_count", 0), + "flood_tx": getattr(engine, "forwarded_count", 0), + "direct_tx": 0, + "flood_rx": getattr(engine, "rx_count", 0), + "direct_rx": 0, + "recv_errors": getattr(engine, "dropped_count", 0), + } + return {} + async def send_advert(self) -> bool: if not self.dispatcher or not self.local_identity: diff --git a/repeater/packet_router.py b/repeater/packet_router.py index 785c429..e0042d3 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -4,6 +4,7 @@ import logging from pymc_core.node.handlers.trace import TraceHandler from pymc_core.node.handlers.control import ControlHandler from pymc_core.node.handlers.advert import AdvertHandler +from pymc_core.node.handlers.ack import AckHandler from pymc_core.node.handlers.login_server import LoginServerHandler from pymc_core.node.handlers.text import TextMessageHandler from pymc_core.node.handlers.path import PathHandler @@ -43,17 +44,20 @@ class PacketRouter: try: metadata = { "rssi": getattr(packet, "rssi", 0), - "snr": getattr(packet, "snr", 0.0), + "snr": getattr(packet, "snr", 0.0), "timestamp": getattr(packet, "timestamp", 0), } - + # Use local_transmission=True to bypass forwarding logic await self.daemon.repeater_handler(packet, metadata, local_transmission=True) - + + # Enqueue so router can deliver to companion(s): TXT_MSG -> dest bridge, ACK -> all bridges (sender sees ACK) + await self.enqueue(packet) + packet_len = len(packet.payload) if packet.payload else 0 logger.debug(f"Injected packet processed by engine as local transmission ({packet_len} bytes)") return True - + except Exception as e: logger.error(f"Error injecting packet through engine: {e}") return False @@ -73,7 +77,7 @@ class PacketRouter: payload_type = packet.get_payload_type() processed_by_injection = False - + # Route to specific handlers for parsing only if payload_type == TraceHandler.payload_type(): # Process trace packet @@ -113,6 +117,16 @@ class PacketRouter: if handled: processed_by_injection = True + elif payload_type == AckHandler.payload_type(): + # ACK has no dest in payload (4-byte CRC only); deliver to all bridges so sender sees send_confirmed + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + for bridge in companion_bridges.values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge ACK error: {e}") + processed_by_injection = True + elif payload_type == TextMessageHandler.payload_type(): dest_hash = packet.payload[0] if packet.payload else None companion_bridges = getattr(self.daemon, "companion_bridges", {}) From 49f412acb0f134bf134b6df7a8167de644c9e9dc Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 14 Feb 2026 20:24:00 -0800 Subject: [PATCH 06/29] Enhance control data delivery and discovery handling - Added a new method `deliver_control_data` in `RepeaterDaemon` to push CONTROL payloads to companion clients. - Updated `PacketRouter` to invoke the new delivery method for control data packets. - Introduced `push_control_data` in `CompanionFrameServer` to handle the sending of control data to clients. - Enhanced `DiscoveryHelper` to support optional debug logging for control handling. These changes improve the communication and control flow between the repeater and companion clients, facilitating better discovery response handling. --- repeater/companion/frame_server.py | 387 +++++++++++++++++++++++--- repeater/handler_helpers/discovery.py | 10 +- repeater/main.py | 29 ++ repeater/packet_router.py | 43 ++- 4 files changed, 430 insertions(+), 39 deletions(-) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index 8864317..80b1eea 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -19,6 +19,7 @@ from .constants import ( RESP_CODE_DEVICE_INFO, CMD_ADD_UPDATE_CONTACT, CMD_GET_CHANNEL, + CMD_GET_CONTACT_BY_KEY, CMD_SET_CHANNEL, CMD_SET_FLOOD_SCOPE, CMD_APP_START, @@ -31,9 +32,13 @@ from .constants import ( CMD_REMOVE_CONTACT, CMD_RESET_PATH, CMD_SEND_BINARY_REQ, + CMD_SEND_PATH_DISCOVERY_REQ, + CMD_SEND_CONTROL_DATA, CMD_SEND_CHANNEL_TXT_MSG, CMD_SEND_LOGIN, CMD_SEND_SELF_ADVERT, + CMD_SEND_STATUS_REQ, + CMD_SEND_TELEMETRY_REQ, CMD_SEND_TRACE_PATH, CMD_SEND_TXT_MSG, CMD_SET_ADVERT_LATLON, @@ -42,6 +47,7 @@ from .constants import ( ERR_CODE_BAD_STATE, ERR_CODE_ILLEGAL_ARG, ERR_CODE_NOT_FOUND, + ERR_CODE_TABLE_FULL, ERR_CODE_UNSUPPORTED_CMD, FRAME_INBOUND_PREFIX, FRAME_OUTBOUND_PREFIX, @@ -50,12 +56,16 @@ from .constants import ( PUB_KEY_SIZE, PUSH_CODE_ADVERT, PUSH_CODE_BINARY_RESPONSE, - PUSH_CODE_NEW_ADVERT, + PUSH_CODE_LOGIN_FAIL, + PUSH_CODE_LOGIN_SUCCESS, PUSH_CODE_LOG_RX_DATA, + PUSH_CODE_NEW_ADVERT, PUSH_CODE_TRACE_DATA, PUSH_CODE_MSG_WAITING, PUSH_CODE_PATH_UPDATED, PUSH_CODE_SEND_CONFIRMED, + PUSH_CODE_STATUS_RESPONSE, + PUSH_CODE_TELEMETRY_RESPONSE, RESP_CODE_ADVERT_PATH, RESP_CODE_BATT_AND_STORAGE, RESP_CODE_CHANNEL_INFO, @@ -75,6 +85,8 @@ from .constants import ( STATS_TYPE_CORE, STATS_TYPE_PACKETS, STATS_TYPE_RADIO, + PUSH_CODE_PATH_DISCOVERY_RESPONSE, + PUSH_CODE_CONTROL_DATA, ) logger = logging.getLogger("CompanionFrameServer") @@ -95,6 +107,7 @@ class CompanionFrameServer: sqlite_handler=None, local_hash: Optional[int] = None, stats_getter=None, + control_handler=None, ): self.bridge = bridge self.companion_hash = companion_hash @@ -103,6 +116,7 @@ class CompanionFrameServer: self.sqlite_handler = sqlite_handler self.local_hash = local_hash # Repeater's node hash; if path ends with this, we are final hop self.stats_getter = stats_getter # Optional (stats_type: int) -> dict for companion stats + self._control_handler = control_handler # Optional; used to register/clear discovery callbacks so "No callback waiting" is not logged self._server: Optional[asyncio.Server] = None self._client_writer: Optional[asyncio.StreamWriter] = None self._client_reader: Optional[asyncio.StreamReader] = None @@ -134,6 +148,13 @@ class CompanionFrameServer: self._server = None logger.info(f"Companion frame server stopped (port={self.port})") + def _persist_companion_message(self, msg_dict: dict) -> None: + """Persist a message to SQLite and remove it from the bridge queue so it is delivered once from SQLite.""" + if not self.sqlite_handler: + return + self.sqlite_handler.companion_push_message(self.companion_hash, msg_dict) + self.bridge.message_queue.pop_last() + def _setup_push_callbacks(self) -> None: """Subscribe to bridge events and send PUSH frames to connected client.""" @@ -147,19 +168,16 @@ class CompanionFrameServer: logger.debug(f"Push write error: {e}") async def on_message_received(sender_key, text, timestamp, txt_type): - if self.sqlite_handler: - self.sqlite_handler.companion_push_message( - self.companion_hash, - { - "sender_key": sender_key, - "text": text, - "timestamp": timestamp, - "txt_type": txt_type, - "is_channel": False, - "channel_idx": 0, - "path_len": 0, - }, - ) + msg_dict = { + "sender_key": sender_key, + "text": text, + "timestamp": timestamp, + "txt_type": txt_type, + "is_channel": False, + "channel_idx": 0, + "path_len": 0, + } + self._persist_companion_message(msg_dict) _write_push(bytes([PUSH_CODE_MSG_WAITING])) async def on_send_confirmed(crc): @@ -213,9 +231,19 @@ class CompanionFrameServer: if isinstance(pub_key, bytes) and len(pub_key) >= 32: _write_push(bytes([PUSH_CODE_PATH_UPDATED]) + pub_key[:32]) - async def on_channel_message_received(channel_name, sender_name, message_text, timestamp, path_len=0): - # Message is already in bridge.message_queue; do not push to sqlite or the same - # message would be delivered twice (once from queue, once from sqlite on next sync). + async def on_channel_message_received( + channel_name, sender_name, message_text, timestamp, path_len=0, channel_idx=0 + ): + msg_dict = { + "sender_key": b"", + "text": message_text, + "timestamp": timestamp, + "txt_type": 0, + "is_channel": True, + "channel_idx": channel_idx, + "path_len": path_len, + } + self._persist_companion_message(msg_dict) _write_push(bytes([PUSH_CODE_MSG_WAITING])) async def on_binary_response(tag_bytes, response_data, parsed=None, request_type=None): @@ -227,12 +255,28 @@ class CompanionFrameServer: ) _write_push(frame) + async def on_path_discovery_response(tag_bytes, contact_pubkey, out_path, in_path): + # PUSH_CODE_PATH_DISCOVERY_RESPONSE 0x8D: reserved(1) + pub_key_prefix(6) + out_path_len(1) + out_path + in_path_len(1) + in_path + pub_key_prefix = (contact_pubkey if isinstance(contact_pubkey, bytes) else bytes.fromhex(contact_pubkey))[:6] + out_path = out_path if isinstance(out_path, bytes) else bytes(out_path) + in_path = in_path if isinstance(in_path, bytes) else bytes(in_path) + frame = ( + bytes([PUSH_CODE_PATH_DISCOVERY_RESPONSE, 0]) + + pub_key_prefix + + bytes([len(out_path)]) + + out_path + + bytes([len(in_path)]) + + in_path + ) + _write_push(frame) + self.bridge.on_message_received(on_message_received) self.bridge.on_channel_message_received(on_channel_message_received) self.bridge.on_send_confirmed(on_send_confirmed) self.bridge.on_advert_received(on_advert_received) self.bridge.on_contact_path_updated(on_contact_path_updated) self.bridge.on_binary_response(on_binary_response) + self.bridge.on_path_discovery_response(on_path_discovery_response) def push_trace_data( self, @@ -288,6 +332,55 @@ class CompanionFrameServer: except Exception as e: logger.debug("Push RX raw error: %s", e) + async def push_control_data( + self, + snr: float, + rssi: int, + path_len: int, + path_bytes: bytes, + payload: bytes, + ) -> None: + """Push CONTROL packet to client (PUSH_CODE_CONTROL_DATA 0x8E). Spec: code, SNR*4, RSSI (signed), path_len, payload (no path bytes). Frame layout matches meshcore_py reader (PacketType.CONTROL_DATA) and firmware MyMesh::onControlDataRecv. See docs/companion-discovery.md for discovery payload layout.""" + if not self._client_writer or self._client_writer.is_closing(): + logger.warning("Push control data skipped: no client connection") + return + # Discovery response (0x90): clear the no-op callback we registered for this tag + if self._control_handler and len(payload) >= 6 and (payload[0] & 0xF0) == 0x90: + tag = struct.unpack(" None: if self._client_writer: try: @@ -377,6 +470,8 @@ class CompanionFrameServer: return cmd = payload[0] data = payload[1:] + # Log every command at INFO so discovery (52) and unsupported are visible in logs + logger.info("Companion cmd 0x%02x (%s) len=%s", cmd, cmd, len(payload)) if cmd in (CMD_GET_CHANNEL, CMD_SET_CHANNEL): logger.debug(f"Companion cmd 0x{cmd:02x} ({'GET_CHANNEL' if cmd == CMD_GET_CHANNEL else 'SET_CHANNEL'}), payload_len={len(payload)}") @@ -387,6 +482,8 @@ class CompanionFrameServer: await self._cmd_device_query(data) elif cmd == CMD_GET_CONTACTS: await self._cmd_get_contacts(data) + elif cmd == CMD_GET_CONTACT_BY_KEY: + await self._cmd_get_contact_by_key(data) elif cmd == CMD_SEND_TXT_MSG: await self._cmd_send_txt_msg(data) elif cmd == CMD_SEND_CHANNEL_TXT_MSG: @@ -395,6 +492,10 @@ class CompanionFrameServer: await self._cmd_sync_next_message(data) elif cmd == CMD_SEND_LOGIN: await self._cmd_send_login(data) + elif cmd == CMD_SEND_STATUS_REQ: + await self._cmd_send_status_req(data) + elif cmd == CMD_SEND_TELEMETRY_REQ: + await self._cmd_send_telemetry_req(data) elif cmd == CMD_SEND_SELF_ADVERT: await self._cmd_send_self_advert(data) elif cmd == CMD_SET_ADVERT_NAME: @@ -421,12 +522,20 @@ class CompanionFrameServer: await self._cmd_set_channel(data) elif cmd == CMD_SEND_BINARY_REQ: await self._cmd_send_binary_req(data) + elif cmd == CMD_SEND_PATH_DISCOVERY_REQ: + await self._cmd_send_path_discovery_req(data) + elif cmd == CMD_SEND_CONTROL_DATA: + await self._cmd_send_control_data(data) elif cmd == CMD_SEND_TRACE_PATH: await self._cmd_send_trace_path(data) elif cmd == CMD_SET_FLOOD_SCOPE: # App sends this on connect; no-op for repeater companion (no radio scope) self._write_ok() else: + logger.warning( + "Companion unsupported cmd 0x%02x (%s) len=%s (expected 52 for path discovery)", + cmd, cmd, len(payload), + ) self._write_err(ERR_CODE_UNSUPPORTED_CMD) except Exception as e: logger.error(f"Cmd 0x{cmd:02x} error: {e}", exc_info=True) @@ -511,6 +620,35 @@ class CompanionFrameServer: most_recent = max((c.lastmod for c in contacts), default=0) self._write_frame(bytes([RESP_CODE_END_OF_CONTACTS]) + struct.pack(" None: + """Handle CMD_GET_CONTACT_BY_KEY (0x1e): lookup by 32-byte pubkey, respond with RESP_CODE_CONTACT or ERR.""" + if len(data) < PUB_KEY_SIZE: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[:PUB_KEY_SIZE] + contact = self.bridge.contacts.get_by_key(pubkey) if hasattr(self.bridge.contacts, "get_by_key") else None + if not contact: + self._write_err(ERR_CODE_NOT_FOUND) + return + c = contact + pubkey_b = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key) + name = (c.name.encode("utf-8")[:32] if isinstance(c.name, str) else c.name[:32]).ljust(32, b"\x00") + opl = c.out_path_len if hasattr(c, "out_path_len") else -1 + opl_byte = 0xFF if opl < 0 else min(opl, 255) + frame = ( + bytes([RESP_CODE_CONTACT]) + + pubkey_b + + bytes([c.adv_type if hasattr(c, "adv_type") else 0, c.flags if hasattr(c, "flags") else 0]) + + bytes([opl_byte]) + + (c.out_path[:MAX_PATH_SIZE] if hasattr(c, "out_path") and c.out_path else b"").ljust(MAX_PATH_SIZE, b"\x00") + + name + + struct.pack(" None: if len(data) < 12: self._write_err(ERR_CODE_ILLEGAL_ARG) @@ -590,6 +728,59 @@ class CompanionFrameServer: frame = bytes([RESP_CODE_SENT, 1 if result.is_flood else 0]) + struct.pack(" None: + # CMD_SEND_CONTROL_DATA (55): first byte is flags/type (0x80 = DISCOVER_REQ). Firmware: (cmd_frame[1] & 0x80) != 0. + if len(data) < 2: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + if (data[0] & 0x80) == 0: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + # Discovery request: register a no-op response callback so ControlHandler won't log "No callback waiting" + if self._control_handler and len(data) >= 6 and (data[0] & 0xF0) == 0x80: + tag = struct.unpack(" None: + # CMD_SEND_PATH_DISCOVERY_REQ (52): reserved(1) + pub_key(32). Firmware: cmd_frame[1]==0, cmd_frame[2:34]=pub_key. + logger.info("Path discovery request received (cmd 52), data_len=%s", len(data)) + if len(data) < 33: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pub_key = data[1:33] + send_req = getattr(self.bridge, "send_path_discovery_req", None) + if not send_req: + self._write_err(ERR_CODE_UNSUPPORTED_CMD) + return + try: + result = await send_req(pub_key) + except Exception as e: + logger.error("send_path_discovery_req error: %s", e, exc_info=True) + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + if not result.success: + self._write_err(ERR_CODE_NOT_FOUND) + return + tag = result.expected_ack if result.expected_ack is not None else 0 + timeout_ms = result.timeout_ms if result.timeout_ms is not None else 10000 + frame = bytes([RESP_CODE_SENT, 1 if result.is_flood else 0]) + struct.pack(" None: # CMD_SEND_TRACE_PATH: tag(4) + auth(4) + flags(1) + path_bytes (firmware MyMesh.cpp) if len(data) < 10: @@ -683,11 +874,11 @@ class CompanionFrameServer: self._write_frame(frame) async def _cmd_send_login(self, data: bytes) -> None: - if len(data) < 33: + if len(data) < 32: self._write_err(ERR_CODE_ILLEGAL_ARG) return pubkey = data[:32] - password = data[32:].decode("utf-8", errors="replace").rstrip("\x00") + password = data[32:].decode("utf-8", errors="replace").rstrip("\x00") if len(data) > 32 else "" self._write_frame(bytes([RESP_CODE_SENT, 1]) + struct.pack(" None: + # CMD_SEND_STATUS_REQ (27): pub_key(32). + # Firmware: cmd_frame[0]=CMD, pub_key = &cmd_frame[1]; len >= 1+PUB_KEY_SIZE + # data here is payload[1:] so data = pub_key(32). No reserved byte. + if len(data) < 32: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[0:32] + # Immediate RESP_CODE_SENT so client knows the request was dispatched + self._write_frame(bytes([RESP_CODE_SENT, 0]) + struct.pack("= 16 else raw_bytes.hex()}" + ) + self._write_frame( + bytes([PUSH_CODE_STATUS_RESPONSE, 0]) + + pubkey[:6] + + raw_bytes + ) + + async def _cmd_send_telemetry_req(self, data: bytes) -> None: + # CMD_SEND_TELEMETRY_REQ (39): reserved(3) + pub_key(32) + optional flags(1). + # Firmware: cmd_frame[0]=CMD, reserved(3), pub_key = &cmd_frame[4]; len >= 4+PUB_KEY_SIZE + # data here is payload[1:] so data = reserved(3) + pub_key(32) + optional flags. + if len(data) < 35: + self._write_err(ERR_CODE_ILLEGAL_ARG) + return + pubkey = data[3:35] + # The 3 reserved bytes (data[0..2]) are unused by firmware. + # Default to requesting all telemetry categories. + flags = 0x07 # request all: base + location + environment + want_base = bool(flags & 0x01) + want_location = bool(flags & 0x02) + want_environment = bool(flags & 0x04) + # Immediate RESP_CODE_SENT so client knows the request was dispatched + self._write_frame(bytes([RESP_CODE_SENT, 0]) + struct.pack(" None: flood = len(data) >= 1 and data[0] == 1 ok = await self.bridge.advertise(flood=flood) @@ -719,38 +1004,78 @@ class CompanionFrameServer: self._write_ok() async def _cmd_add_update_contact(self, data: bytes) -> None: - if len(data) < 73: + # Match meshcore minimum: 36 bytes (pubkey 32 + adv_type 1 + flags 1 + out_path_len 1). + if len(data) < 36: self._write_err(ERR_CODE_ILLEGAL_ARG) + await self._drain_writer() return pubkey = data[0:32] adv_type = data[32] flags = data[33] - out_path_len = data[34] - out_path = data[35:35 + MAX_PATH_SIZE] - name_raw = data[35 + MAX_PATH_SIZE:35 + MAX_PATH_SIZE + 32] + out_path_len = struct.unpack_from("= out_path_end: + out_path = data[35:out_path_end].rstrip(b"\x00") + else: + out_path = data[35:len(data)].rstrip(b"\x00") if len(data) > 35 else b"" + name_start = 35 + MAX_PATH_SIZE + name_end = name_start + 32 + if len(data) >= name_end: + name_raw = data[name_start:name_end] + elif len(data) > name_start: + name_raw = data[name_start:len(data)].ljust(32, b"\x00") + else: + name_raw = b"\x00" * 32 name = name_raw.split(b"\x00")[0].decode("utf-8", errors="replace") - last_advert = struct.unpack_from("= name_end + 4: + last_advert = struct.unpack_from("= 35 + MAX_PATH_SIZE + 32 + 12: - gps_lat = struct.unpack_from("= name_end + 4 + 8: + gps_lat = struct.unpack_from("= name_end + 4 + 12: + lastmod = struct.unpack_from(" 255 else out_path_len + out_path_padded = (out_path[:MAX_PATH_SIZE] if out_path else b"").ljust(MAX_PATH_SIZE, b"\x00") + name_padded = (name.encode("utf-8")[:32] if isinstance(name, str) else name[:32]).ljust(32, b"\x00") + contact_frame = ( + bytes([RESP_CODE_CONTACT]) + + pubkey + + bytes([adv_type, flags, opl_byte]) + + out_path_padded + + name_padded + + struct.pack(" None: if len(data) < 32: diff --git a/repeater/handler_helpers/discovery.py b/repeater/handler_helpers/discovery.py index 8174bbc..e48d50d 100644 --- a/repeater/handler_helpers/discovery.py +++ b/repeater/handler_helpers/discovery.py @@ -21,6 +21,7 @@ class DiscoveryHelper: packet_injector=None, node_type: int = 2, log_fn=None, + debug_log_fn=None, ): """ Initialize the discovery helper. @@ -30,13 +31,18 @@ class DiscoveryHelper: packet_injector: Callable to inject new packets into the router for sending node_type: Node type identifier (2 = Repeater) log_fn: Optional logging function for ControlHandler + debug_log_fn: Optional logging for verbose ControlHandler messages (e.g. callback + presence). Pass logger.debug to avoid INFO noise when forwarding to companions. """ self.local_identity = local_identity self.packet_injector = packet_injector # Function to inject packets into router self.node_type = node_type - + # Create ControlHandler internally as a parsing utility - self.control_handler = ControlHandler(log_fn=log_fn or logger.info) + self.control_handler = ControlHandler( + log_fn=log_fn or logger.info, + debug_log_fn=debug_log_fn, + ) # Set up the request callback self.control_handler.set_request_callback(self._on_discovery_request) diff --git a/repeater/main.py b/repeater/main.py index fc10c54..5e05575 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -164,6 +164,7 @@ class RepeaterDaemon: packet_injector=self.router.inject_packet, node_type=2, log_fn=logger.info, + debug_log_fn=logger.debug, ) logger.info("Discovery processing helper initialized") else: @@ -449,6 +450,7 @@ class RepeaterDaemon: sqlite_handler=sqlite_handler, local_hash=self.local_hash, stats_getter=self._get_companion_stats, + control_handler=self.discovery_helper.control_handler if self.discovery_helper else None, ) await frame_server.start() self.companion_frame_servers.append(frame_server) @@ -479,6 +481,33 @@ class RepeaterDaemon: except Exception as e: logger.debug("Push RX raw to companion: %s", e) + async def deliver_control_data( + self, + snr: float, + rssi: int, + path_len: int, + path_bytes: bytes, + payload_bytes: bytes, + ) -> None: + """Deliver CONTROL payload (e.g. discovery response) to companion clients (PUSH_CODE_CONTROL_DATA 0x8E).""" + # Only push discovery responses (0x90); client expects these, not the request (0x80) + if len(payload_bytes) < 6 or (payload_bytes[0] & 0xF0) != 0x90: + return + # Push every discovery response to the client, including our own (snr=0, rssi=0 = local node's response) + servers = getattr(self, "companion_frame_servers", []) + if not servers: + return + tag = int.from_bytes(payload_bytes[2:6], "little") if len(payload_bytes) >= 6 else 0 + logger.debug( + "Delivering discovery response to %s companion(s): tag=0x%08X, len=%s", + len(servers), tag, len(payload_bytes), + ) + for fs in servers: + try: + await fs.push_control_data(snr, rssi, path_len, path_bytes, payload_bytes) + except Exception as e: + logger.warning("Companion push_control_data error: %s", e) + async def _on_trace_complete_for_companions(self, packet, parsed_data) -> None: """Trace completed at this node: push PUSH_CODE_TRACE_DATA (0x89) to companion clients (firmware onTraceRecv).""" path_len = len(parsed_data.get("trace_path", [])) diff --git a/repeater/packet_router.py b/repeater/packet_router.py index e0042d3..a1007a4 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -11,6 +11,7 @@ from pymc_core.node.handlers.path import PathHandler from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler from pymc_core.node.handlers.group_text import GroupTextHandler from pymc_core.node.handlers.protocol_response import ProtocolResponseHandler +from pymc_core.node.handlers.login_response import LoginResponseHandler logger = logging.getLogger("PacketRouter") class PacketRouter: @@ -91,7 +92,16 @@ class PacketRouter: if self.daemon.discovery_helper: await self.daemon.discovery_helper.control_handler(packet) packet.mark_do_not_retransmit() - + # Deliver to companions via daemon (frame servers push PUSH_CODE_CONTROL_DATA 0x8E) + deliver = getattr(self.daemon, "deliver_control_data", None) + if deliver: + snr = getattr(packet, "_snr", None) or getattr(packet, "snr", 0.0) + rssi = getattr(packet, "_rssi", None) or getattr(packet, "rssi", 0) + path_len = getattr(packet, "path_len", 0) or 0 + path_bytes = (bytes(getattr(packet, "path", [])) if getattr(packet, "path", None) is not None else b"")[:path_len] + payload_bytes = bytes(packet.payload) if packet.payload else b"" + await deliver(snr, rssi, path_len, path_bytes, payload_bytes) + elif payload_type == AdvertHandler.payload_type(): # Process advertisement packet for neighbor tracking if self.daemon.advert_helper: @@ -106,7 +116,8 @@ class PacketRouter: logger.debug(f"Companion bridge advert error: {e}") elif payload_type == LoginServerHandler.payload_type(): - # Route to companion if dest is a companion; else to login_helper + # Route to companion if dest is a companion; else to login_helper (for logging into this repeater). + # If dest is remote (no local handler), mark processed so we don't pass our own outbound login TX to the repeater as RX. dest_hash = packet.payload[0] if packet.payload else None companion_bridges = getattr(self.daemon, "companion_bridges", {}) if dest_hash is not None and dest_hash in companion_bridges: @@ -116,6 +127,9 @@ class PacketRouter: handled = await self.daemon.login_helper.process_login_packet(packet) if handled: processed_by_injection = True + else: + # Login request for remote repeater (we already TXed it via inject); don't treat as RX. + processed_by_injection = True elif payload_type == AckHandler.payload_type(): # ACK has no dest in payload (4-byte CRC only); deliver to all bridges so sender sees send_confirmed @@ -147,11 +161,28 @@ class PacketRouter: elif self.daemon.path_helper: await self.daemon.path_helper.process_path_packet(packet) - elif payload_type == ProtocolResponseHandler.payload_type(): - dest_hash = packet.payload[0] if packet.payload else None + elif payload_type == LoginResponseHandler.payload_type(): + # PAYLOAD_TYPE_RESPONSE (0x01): login responses from remote repeaters. + # Deliver to all companion bridges so the bridge that initiated the login receives it. companion_bridges = getattr(self.daemon, "companion_bridges", {}) - if dest_hash is not None and dest_hash in companion_bridges: - await companion_bridges[dest_hash].process_received_packet(packet) + for bridge in companion_bridges.values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge LOGIN_RESPONSE error: {e}") + if companion_bridges: + processed_by_injection = True + + elif payload_type == ProtocolResponseHandler.payload_type(): + # PAYLOAD_TYPE_PATH (0x08): protocol responses (telemetry, binary, etc.). + # Deliver to all companion bridges (response dest_hash is the client, not the bridge). + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + for bridge in companion_bridges.values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge RESPONSE error: {e}") + if companion_bridges: processed_by_injection = True elif payload_type == ProtocolRequestHandler.payload_type(): From d5cabe831c39519bb828a2c1abcceb1ee6193054 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 14 Feb 2026 20:53:25 -0800 Subject: [PATCH 07/29] Fix message persistence and deduplication in CompanionFrameServer - Updated `_persist_companion_message` to clarify deduplication of messages in SQLite. - Modified `on_message_received` and `on_channel_message_received` to include `packet_hash` for message identification. - Enhanced `SQLiteHandler` to support deduplication by `packet_hash` when pushing messages, preventing duplicates in the database. - Added `packet_hash` column to the `companion_messages` table and created an index for efficient lookups. --- repeater/companion/frame_server.py | 8 ++++--- repeater/data_acquisition/sqlite_handler.py | 25 +++++++++++++++++---- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index 80b1eea..d101814 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -149,7 +149,7 @@ class CompanionFrameServer: logger.info(f"Companion frame server stopped (port={self.port})") def _persist_companion_message(self, msg_dict: dict) -> None: - """Persist a message to SQLite and remove it from the bridge queue so it is delivered once from SQLite.""" + """Persist a message to SQLite (deduplicated) and remove it from the bridge queue so it is delivered once from SQLite.""" if not self.sqlite_handler: return self.sqlite_handler.companion_push_message(self.companion_hash, msg_dict) @@ -167,7 +167,7 @@ class CompanionFrameServer: except Exception as e: logger.debug(f"Push write error: {e}") - async def on_message_received(sender_key, text, timestamp, txt_type): + async def on_message_received(sender_key, text, timestamp, txt_type, packet_hash=None): msg_dict = { "sender_key": sender_key, "text": text, @@ -176,6 +176,7 @@ class CompanionFrameServer: "is_channel": False, "channel_idx": 0, "path_len": 0, + "packet_hash": packet_hash, } self._persist_companion_message(msg_dict) _write_push(bytes([PUSH_CODE_MSG_WAITING])) @@ -232,7 +233,7 @@ class CompanionFrameServer: _write_push(bytes([PUSH_CODE_PATH_UPDATED]) + pub_key[:32]) async def on_channel_message_received( - channel_name, sender_name, message_text, timestamp, path_len=0, channel_idx=0 + channel_name, sender_name, message_text, timestamp, path_len=0, channel_idx=0, packet_hash=None ): msg_dict = { "sender_key": b"", @@ -242,6 +243,7 @@ class CompanionFrameServer: "is_channel": True, "channel_idx": channel_idx, "path_len": path_len, + "packet_hash": packet_hash, } self._persist_companion_message(msg_dict) _write_push(bytes([PUSH_CODE_MSG_WAITING])) diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 21ebca5..883017b 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -301,6 +301,7 @@ class SQLiteHandler: is_channel INTEGER NOT NULL DEFAULT 0, channel_idx INTEGER NOT NULL DEFAULT 0, path_len INTEGER NOT NULL DEFAULT 0, + packet_hash TEXT, created_at REAL NOT NULL ) """) @@ -308,6 +309,9 @@ class SQLiteHandler: conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_contacts_pubkey ON companion_contacts(companion_hash, pubkey)") conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_channels_hash ON companion_channels(companion_hash)") conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_messages_hash ON companion_messages(companion_hash)") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_companion_messages_hash_packet ON companion_messages(companion_hash, packet_hash)" + ) logger.info("Created companion_contacts, companion_channels, companion_messages tables") conn.execute( @@ -1514,22 +1518,35 @@ class SQLiteHandler: return [] def companion_push_message(self, companion_hash: str, msg: Dict) -> bool: - """Append a message to the companion's queue.""" + """Append a message to the companion's queue. Deduplicates by packet_hash when present. Returns True if inserted, False if duplicate (skipped).""" try: + packet_hash = msg.get("packet_hash") or None + if isinstance(packet_hash, bytes): + packet_hash = packet_hash.decode("utf-8", errors="replace") if packet_hash else None + sender_key = msg.get("sender_key", b"") with sqlite3.connect(self.sqlite_path) as conn: + if packet_hash: + cursor = conn.execute(""" + SELECT id FROM companion_messages + WHERE companion_hash = ? AND packet_hash = ? + LIMIT 1 + """, (companion_hash, packet_hash)) + if cursor.fetchone(): + return False conn.execute(""" INSERT INTO companion_messages - (companion_hash, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + (companion_hash, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len, packet_hash, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( companion_hash, - msg.get("sender_key", b""), + sender_key, msg.get("txt_type", 0), msg.get("timestamp", 0), msg.get("text", ""), int(msg.get("is_channel", False)), msg.get("channel_idx", 0), msg.get("path_len", 0), + packet_hash, time.time(), )) conn.commit() From 2360660a6bed0fba11720ce513562d23a2a81188 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 14 Feb 2026 20:58:01 -0800 Subject: [PATCH 08/29] fix message persistence in CompanionFrameServer to use async I/O - Changed `_persist_companion_message` to an asynchronous method to prevent blocking the event loop during SQLite operations. - Updated calls to `_persist_companion_message` in message handling functions to use `await`. - Enhanced error handling to include `BrokenPipeError` alongside `ConnectionResetError` for improved robustness. --- repeater/companion/frame_server.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index d101814..88d5971 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -148,11 +148,14 @@ class CompanionFrameServer: self._server = None logger.info(f"Companion frame server stopped (port={self.port})") - def _persist_companion_message(self, msg_dict: dict) -> None: - """Persist a message to SQLite (deduplicated) and remove it from the bridge queue so it is delivered once from SQLite.""" + async def _persist_companion_message(self, msg_dict: dict) -> None: + """Persist a message to SQLite (deduplicated) and remove it from the bridge queue so it is delivered once from SQLite. + SQLite I/O runs in a thread so the event loop stays responsive and the client does not time out.""" if not self.sqlite_handler: return - self.sqlite_handler.companion_push_message(self.companion_hash, msg_dict) + await asyncio.to_thread( + self.sqlite_handler.companion_push_message, self.companion_hash, msg_dict + ) self.bridge.message_queue.pop_last() def _setup_push_callbacks(self) -> None: @@ -178,7 +181,7 @@ class CompanionFrameServer: "path_len": 0, "packet_hash": packet_hash, } - self._persist_companion_message(msg_dict) + await self._persist_companion_message(msg_dict) _write_push(bytes([PUSH_CODE_MSG_WAITING])) async def on_send_confirmed(crc): @@ -245,7 +248,7 @@ class CompanionFrameServer: "path_len": path_len, "packet_hash": packet_hash, } - self._persist_companion_message(msg_dict) + await self._persist_companion_message(msg_dict) _write_push(bytes([PUSH_CODE_MSG_WAITING])) async def on_binary_response(tag_bytes, response_data, parsed=None, request_type=None): @@ -457,7 +460,7 @@ class CompanionFrameServer: await self._handle_cmd(payload) except asyncio.IncompleteReadError: pass - except ConnectionResetError: + except (ConnectionResetError, BrokenPipeError): pass except Exception as e: logger.error(f"Client handler error: {e}", exc_info=True) From c07d24d3879056d88351c655932d94d7c8a93e3f Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 14 Feb 2026 21:07:18 -0800 Subject: [PATCH 09/29] Fix frame building functionality for advert push in CompanionFrameServer - Introduced a new method `_build_advert_push_frames` to construct short and full advert frames from provided data. - Refactored the handling of contact data to build and send advert frames asynchronously, improving performance and thread safety. - Enhanced data structure for advert frame construction, including optional fields for full advert details. --- repeater/companion/frame_server.py | 77 +++++++++++++++++++++--------- 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index 88d5971..a38bf7c 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -92,6 +92,26 @@ from .constants import ( logger = logging.getLogger("CompanionFrameServer") +def _build_advert_push_frames(data: dict) -> tuple[bytes, Optional[bytes]]: + """Build PUSH_CODE_ADVERT short frame and optional PUSH_CODE_NEW_ADVERT full frame from extracted data. Thread-safe for asyncio.to_thread.""" + pubkey_b = data["pubkey_b"] + short = bytes([PUSH_CODE_ADVERT]) + pubkey_b + if not data.get("include_full"): + return (short, None) + full = ( + bytes([PUSH_CODE_NEW_ADVERT]) + + pubkey_b + + bytes([data["adv_type"], data["flags"], data["opl_byte"]]) + + data["out_path"] + + data["name_b"] + + struct.pack("= 32: From a3f96962ff83d02e72488100185dde88da67290d Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 15 Feb 2026 19:33:04 -0800 Subject: [PATCH 10/29] Refactor advert frame handling in CompanionFrameServer - Improved the `_build_advert_push_frames` function to handle optional fields and ensure thread safety. - Enhanced the `on_advert_received` method to robustly process incoming contact data, including better handling of public keys and optional fields for advert details. - Added error handling to log exceptions during advert processing, improving reliability. --- repeater/companion/frame_server.py | 132 ++++++++++++++++------------- 1 file changed, 74 insertions(+), 58 deletions(-) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index a38bf7c..1a9062a 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -94,20 +94,28 @@ logger = logging.getLogger("CompanionFrameServer") def _build_advert_push_frames(data: dict) -> tuple[bytes, Optional[bytes]]: """Build PUSH_CODE_ADVERT short frame and optional PUSH_CODE_NEW_ADVERT full frame from extracted data. Thread-safe for asyncio.to_thread.""" - pubkey_b = data["pubkey_b"] + pubkey_b = data.get("pubkey_b", b"") + if isinstance(pubkey_b, bytes): + pubkey_b = pubkey_b[:32].ljust(32, b"\x00") + else: + pubkey_b = b"\x00" * 32 short = bytes([PUSH_CODE_ADVERT]) + pubkey_b if not data.get("include_full"): return (short, None) + op = data.get("out_path", b"") + op = (op if isinstance(op, bytes) else bytes(op or []))[:MAX_PATH_SIZE].ljust(MAX_PATH_SIZE, b"\x00") + nb = data.get("name_b", b"") + nb = (nb if isinstance(nb, bytes) else (nb.encode("utf-8", errors="replace") if isinstance(nb, str) else b""))[:32].ljust(32, b"\x00") full = ( bytes([PUSH_CODE_NEW_ADVERT]) + pubkey_b - + bytes([data["adv_type"], data["flags"], data["opl_byte"]]) - + data["out_path"] - + data["name_b"] - + struct.pack("= 32: From 22e337a70715eefa044fceef1ae7e0ba89483dce Mon Sep 17 00:00:00 2001 From: agessaman Date: Sun, 15 Feb 2026 22:10:23 -0800 Subject: [PATCH 11/29] Add CompanionAPIEndpoints integration in APIEndpoints class - Introduced CompanionAPIEndpoints to handle routes under /api/companion/*. - Enhanced the APIEndpoints class to create a nested companion object for improved modularity and organization of companion-related API functionality. --- repeater/web/api_endpoints.py | 4 + repeater/web/companion_endpoints.py | 576 ++++++++++++++++++++++++++++ scripts/test_companion_api.sh | 373 ++++++++++++++++++ 3 files changed, 953 insertions(+) create mode 100644 repeater/web/companion_endpoints.py create mode 100755 scripts/test_companion_api.sh diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 0ed865e..23ccde9 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -11,6 +11,7 @@ from repeater.config import update_global_flood_policy from .cad_calibration_engine import CADCalibrationEngine from .auth.middleware import require_auth from .auth_endpoints import AuthAPIEndpoints +from .companion_endpoints import CompanionAPIEndpoints from pymc_core.protocol import CryptoUtils logger = logging.getLogger("HTTPServer") @@ -146,6 +147,9 @@ class APIEndpoints: # Create nested auth object for /api/auth/* routes self.auth = AuthAPIEndpoints() + # Create nested companion object for /api/companion/* routes + self.companion = CompanionAPIEndpoints(daemon_instance, event_loop, self.config) + def _is_cors_enabled(self): return self.config.get("web", {}).get("cors_enabled", False) diff --git a/repeater/web/companion_endpoints.py b/repeater/web/companion_endpoints.py new file mode 100644 index 0000000..81ea9b3 --- /dev/null +++ b/repeater/web/companion_endpoints.py @@ -0,0 +1,576 @@ +""" +Companion Bridge REST API and SSE event stream endpoints. + +Mounted as a nested CherryPy object at /api/companion/ via APIEndpoints. +Provides browser-accessible REST endpoints that proxy into the CompanionBridge +async methods, plus a Server-Sent Events stream for real-time push callbacks. +""" + +import asyncio +import json +import logging +import queue +import time +import threading +from typing import Optional + +import cherrypy +from .auth.middleware import require_auth + +logger = logging.getLogger("CompanionAPI") + + +class CompanionAPIEndpoints: + """REST + SSE endpoints for a companion bridge. + + CherryPy auto-mounts this at ``/api/companion/`` when assigned as + ``APIEndpoints.companion``. All async bridge calls are dispatched + to the daemon's event loop via ``asyncio.run_coroutine_threadsafe``. + """ + + def __init__(self, daemon_instance=None, event_loop=None, config=None): + self.daemon_instance = daemon_instance + self.event_loop = event_loop + self.config = config or {} + + # SSE clients: each gets a thread-safe queue + self._sse_clients: list[queue.Queue] = [] + self._sse_lock = threading.Lock() + + # Flag: have we registered push callbacks yet? + self._callbacks_registered = False + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _get_bridge(self, name: Optional[str] = None, companion_hash: Optional[int] = None): + """Return the companion bridge, or raise 503/404 if unavailable. + + Resolution order (mirrors room-server pattern): + 1. *name* — look up via identity_manager by registered name. + 2. *companion_hash* — direct lookup in ``companion_bridges`` dict. + 3. Neither — return the first (and typically only) bridge. + """ + if not self.daemon_instance: + raise cherrypy.HTTPError(503, "Daemon not initialized") + bridges = getattr(self.daemon_instance, "companion_bridges", {}) + if not bridges: + raise cherrypy.HTTPError(503, "No companion bridges configured") + + # --- resolve by name via identity_manager (same pattern as room servers) --- + if name is not None: + identity_manager = getattr(self.daemon_instance, "identity_manager", None) + if identity_manager: + for reg_name, identity, _cfg in identity_manager.get_identities_by_type("companion"): + if reg_name == name: + hash_byte = identity.get_public_key()[0] + bridge = bridges.get(hash_byte) + if bridge: + return bridge + raise cherrypy.HTTPError(404, f"Companion '{name}' not found") + + # --- resolve by hash (fallback) --- + if companion_hash is not None: + bridge = bridges.get(companion_hash) + if not bridge: + raise cherrypy.HTTPError(404, f"Companion 0x{companion_hash:02X} not found") + return bridge + + # --- default: first bridge --- + return next(iter(bridges.values())) + + def _resolve_bridge_params(self, params) -> dict: + """Extract optional companion name/hash from request params. + + Returns kwargs suitable for ``_get_bridge(**result)``. + Follows the room-server convention: ``companion_name`` is the + primary selector, ``companion_hash`` is the fallback. + """ + name = params.get("companion_name") + raw_hash = params.get("companion_hash") + result: dict = {} + if name is not None: + result["name"] = str(name) + elif raw_hash is not None: + try: + result["companion_hash"] = int(str(raw_hash), 0) + except (ValueError, TypeError): + raise cherrypy.HTTPError(400, "Invalid companion_hash") + return result + + def _run_async(self, coro, timeout: float = 30.0): + """Run an async coroutine on the daemon event loop and return result.""" + if self.event_loop is None: + raise cherrypy.HTTPError(503, "Event loop not available") + future = asyncio.run_coroutine_threadsafe(coro, self.event_loop) + return future.result(timeout=timeout) + + @staticmethod + def _success(data, **kwargs): + result = {"success": True, "data": data} + result.update(kwargs) + return result + + @staticmethod + def _error(msg): + return {"success": False, "error": str(msg)} + + def _require_post(self): + if cherrypy.request.method != "POST": + cherrypy.response.headers["Allow"] = "POST" + raise cherrypy.HTTPError(405, "Method not allowed. Use POST.") + + def _get_json_body(self) -> dict: + """Read and parse the JSON request body.""" + try: + raw = cherrypy.request.body.read() + return json.loads(raw) if raw else {} + except (json.JSONDecodeError, ValueError) as exc: + raise cherrypy.HTTPError(400, f"Invalid JSON body: {exc}") + + def _pub_key_from_hex(self, hex_str: str) -> bytes: + """Decode a hex public key, raising 400 on error.""" + try: + key = bytes.fromhex(hex_str) + if len(key) != 32: + raise ValueError("Expected 32-byte key") + return key + except (ValueError, TypeError) as exc: + raise cherrypy.HTTPError(400, f"Invalid public key: {exc}") + + # ------------------------------------------------------------------ + # SSE push-event plumbing + # ------------------------------------------------------------------ + + def _ensure_callbacks(self): + """Register push callbacks on the bridge (once).""" + if self._callbacks_registered: + return + try: + bridge = self._get_bridge() + except cherrypy.HTTPError: + return # bridge not yet available + + def _make_cb(event_name): + """Create a callback that serialises event data for SSE clients.""" + def _cb(*args, **kwargs): + payload = self._serialise_event(event_name, args, kwargs) + self._broadcast_sse(payload) + return _cb + + callback_names = [ + "message_received", + "channel_message_received", + "advert_received", + "contact_path_updated", + "send_confirmed", + "login_result", + ] + for name in callback_names: + register_fn = getattr(bridge, f"on_{name}", None) + if register_fn: + register_fn(_make_cb(name)) + + self._callbacks_registered = True + + @staticmethod + def _serialise_event(event_name: str, args: tuple, kwargs: dict) -> dict: + """Convert callback arguments to a JSON-safe dict.""" + data: dict = {"event": event_name, "timestamp": int(time.time())} + for i, arg in enumerate(args): + data[f"arg{i}"] = _to_json_safe(arg) + for k, v in kwargs.items(): + data[k] = _to_json_safe(v) + return data + + def _broadcast_sse(self, payload: dict): + """Put *payload* into every active SSE client queue.""" + with self._sse_lock: + dead = [] + for q in self._sse_clients: + try: + q.put_nowait(payload) + except queue.Full: + dead.append(q) + for q in dead: + self._sse_clients.remove(q) + + # ================================================================== + # REST Endpoints + # ================================================================== + + # ----- Index / listing ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def index(self, **kwargs): + """GET /api/companion/ — list configured companions.""" + bridges = getattr(self.daemon_instance, "companion_bridges", {}) + identity_manager = getattr(self.daemon_instance, "identity_manager", None) + + # Build name lookup from identity_manager (same pattern as room servers) + name_by_hash: dict[int, str] = {} + if identity_manager: + for reg_name, identity, _cfg in identity_manager.get_identities_by_type("companion"): + name_by_hash[identity.get_public_key()[0]] = reg_name + + items = [] + for h, b in bridges.items(): + items.append({ + "companion_name": name_by_hash.get(h, ""), + "companion_hash": f"0x{h:02X}", + "node_name": b.prefs.node_name, + "public_key": b.get_public_key().hex(), + "is_running": b.is_running, + "contacts_count": b.contacts.get_count(), + "channels_count": b.channels.get_count(), + }) + return self._success(items) + + # ----- Identity ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def self_info(self, **kwargs): + """GET /api/companion/self_info — node identity and preferences.""" + bridge = self._get_bridge(**self._resolve_bridge_params(kwargs)) + prefs = bridge.get_self_info() + return self._success({ + "public_key": bridge.get_public_key().hex(), + "node_name": prefs.node_name, + "adv_type": prefs.adv_type, + "tx_power_dbm": prefs.tx_power_dbm, + "frequency_hz": prefs.frequency_hz, + "bandwidth_hz": prefs.bandwidth_hz, + "spreading_factor": prefs.spreading_factor, + "coding_rate": prefs.coding_rate, + "latitude": prefs.latitude, + "longitude": prefs.longitude, + }) + + # ----- Contacts ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def contacts(self, **kwargs): + """GET /api/companion/contacts — list all contacts.""" + bridge = self._get_bridge(**self._resolve_bridge_params(kwargs)) + since = int(kwargs.get("since", 0)) + contacts = bridge.get_contacts(since=since) + items = [] + for c in contacts: + items.append({ + "public_key": c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key, + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + }) + return self._success(items) + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def contact(self, **kwargs): + """GET /api/companion/contact?pub_key= — get single contact.""" + bridge = self._get_bridge(**self._resolve_bridge_params(kwargs)) + pk_hex = kwargs.get("pub_key") + if not pk_hex: + raise cherrypy.HTTPError(400, "pub_key required") + pub_key = self._pub_key_from_hex(pk_hex) + c = bridge.get_contact_by_key(pub_key) + if not c: + raise cherrypy.HTTPError(404, "Contact not found") + return self._success({ + "public_key": c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key, + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "out_path": c.out_path.hex() if isinstance(c.out_path, bytes) else "", + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + }) + + # ----- Channels ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def channels(self, **kwargs): + """GET /api/companion/channels — list configured channels.""" + try: + bridge = self._get_bridge(**self._resolve_bridge_params(kwargs)) + items = [] + for idx in range(bridge.channels.max_channels): + ch = bridge.channels.get(idx) + if ch: + items.append({ + "index": idx, + "name": ch.name, + # Don't expose the PSK secret over REST + }) + return self._success(items) + except cherrypy.HTTPError: + raise + except Exception as exc: + logger.error(f"channels endpoint error: {exc}", exc_info=True) + return self._error(str(exc)) + + # ----- Statistics ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def stats(self, **kwargs): + """GET /api/companion/stats?type=packets — local companion stats.""" + bridge = self._get_bridge(**self._resolve_bridge_params(kwargs)) + stats_type_map = {"core": 0, "radio": 1, "packets": 2} + stype = stats_type_map.get(kwargs.get("type", "packets"), 2) + return self._success(bridge.get_stats(stype)) + + # ----- Messaging ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def send_text(self, **kwargs): + """POST /api/companion/send_text {pub_key, text, txt_type?, companion_name?}""" + self._require_post() + body = self._get_json_body() + bridge = self._get_bridge(**self._resolve_bridge_params(body)) + pub_key = self._pub_key_from_hex(body.get("pub_key", "")) + text = body.get("text", "") + if not text: + raise cherrypy.HTTPError(400, "text required") + txt_type = int(body.get("txt_type", 0)) + result = self._run_async( + bridge.send_text_message(pub_key, text, txt_type=txt_type) + ) + return self._success({ + "sent": result.success, + "is_flood": result.is_flood, + "expected_ack": result.expected_ack, + }) + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def send_channel_message(self, **kwargs): + """POST /api/companion/send_channel_message {channel_idx, text, companion_name?}""" + self._require_post() + body = self._get_json_body() + bridge = self._get_bridge(**self._resolve_bridge_params(body)) + channel_idx = int(body.get("channel_idx", 0)) + text = body.get("text", "") + if not text: + raise cherrypy.HTTPError(400, "text required") + success = self._run_async(bridge.send_channel_message(channel_idx, text)) + return self._success({"sent": success}) + + # ----- Login ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def login(self, **kwargs): + """POST /api/companion/login {pub_key, password?, companion_name?}""" + self._require_post() + body = self._get_json_body() + bridge = self._get_bridge(**self._resolve_bridge_params(body)) + pub_key = self._pub_key_from_hex(body.get("pub_key", "")) + password = body.get("password", "") + result = self._run_async( + bridge.send_login(pub_key, password), timeout=15.0 + ) + return self._success(_to_json_safe(result)) + + # ----- Status / Telemetry Requests ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def request_status(self, **kwargs): + """POST /api/companion/request_status {pub_key, timeout?, companion_name?}""" + self._require_post() + body = self._get_json_body() + bridge = self._get_bridge(**self._resolve_bridge_params(body)) + pub_key = self._pub_key_from_hex(body.get("pub_key", "")) + timeout = float(body.get("timeout", 15.0)) + result = self._run_async( + bridge.send_status_request(pub_key, timeout=timeout), + timeout=timeout + 5.0, + ) + return self._success(_to_json_safe(result)) + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def request_telemetry(self, **kwargs): + """POST /api/companion/request_telemetry {pub_key, want_base?, want_location?, want_environment?, timeout?, companion_name?}""" + self._require_post() + try: + body = self._get_json_body() + bridge = self._get_bridge(**self._resolve_bridge_params(body)) + pub_key = self._pub_key_from_hex(body.get("pub_key", "")) + timeout = float(body.get("timeout", 10.0)) + result = self._run_async( + bridge.send_telemetry_request( + pub_key, + want_base=bool(body.get("want_base", True)), + want_location=bool(body.get("want_location", True)), + want_environment=bool(body.get("want_environment", True)), + timeout=timeout, + ), + timeout=timeout + 5.0, + ) + # Ensure all values are JSON-serialisable (telemetry may contain bytes) + return self._success(_to_json_safe(result)) + except cherrypy.HTTPError: + raise + except Exception as exc: + logger.error(f"request_telemetry endpoint error: {exc}", exc_info=True) + return self._error(str(exc)) + + # ----- Repeater Commands ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def send_command(self, **kwargs): + """POST /api/companion/send_command {pub_key, command, parameters?, companion_name?}""" + self._require_post() + body = self._get_json_body() + bridge = self._get_bridge(**self._resolve_bridge_params(body)) + pub_key = self._pub_key_from_hex(body.get("pub_key", "")) + command = body.get("command", "") + if not command: + raise cherrypy.HTTPError(400, "command required") + parameters = body.get("parameters") + result = self._run_async( + bridge.send_repeater_command(pub_key, command, parameters), + timeout=20.0, + ) + return self._success(_to_json_safe(result)) + + # ----- Path / Routing ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def reset_path(self, **kwargs): + """POST /api/companion/reset_path {pub_key, companion_name?}""" + self._require_post() + body = self._get_json_body() + bridge = self._get_bridge(**self._resolve_bridge_params(body)) + pub_key = self._pub_key_from_hex(body.get("pub_key", "")) + ok = bridge.reset_path(pub_key) + return self._success({"reset": ok}) + + # ----- Device Configuration ----- + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def set_advert_name(self, **kwargs): + """POST /api/companion/set_advert_name {advert_name, companion_name?}""" + self._require_post() + body = self._get_json_body() + bridge = self._get_bridge(**self._resolve_bridge_params(body)) + name = body.get("advert_name", body.get("name", "")) + if not name: + raise cherrypy.HTTPError(400, "name required") + bridge.set_advert_name(name) + return self._success({"name": bridge.prefs.node_name}) + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def set_advert_location(self, **kwargs): + """POST /api/companion/set_advert_location {latitude, longitude, companion_name?}""" + self._require_post() + body = self._get_json_body() + bridge = self._get_bridge(**self._resolve_bridge_params(body)) + lat = float(body.get("latitude", 0.0)) + lon = float(body.get("longitude", 0.0)) + bridge.set_advert_latlon(lat, lon) + return self._success({"latitude": lat, "longitude": lon}) + + # ================================================================== + # SSE Event Stream + # ================================================================== + + @cherrypy.expose + def events(self, **kwargs): + """GET /api/companion/events — Server-Sent Events stream for push callbacks. + + Connect with ``EventSource('/api/companion/events?token=JWT')``. + Auth is handled by the CherryPy tool-level require_auth (supports + query-param JWT tokens needed by the browser EventSource API). + """ + self._ensure_callbacks() + + cherrypy.response.headers["Content-Type"] = "text/event-stream" + cherrypy.response.headers["Cache-Control"] = "no-cache" + cherrypy.response.headers["Connection"] = "keep-alive" + cherrypy.response.headers["X-Accel-Buffering"] = "no" + + client_queue: queue.Queue = queue.Queue(maxsize=256) + with self._sse_lock: + self._sse_clients.append(client_queue) + + def generate(): + try: + yield f"data: {json.dumps({'event': 'connected', 'timestamp': int(time.time())})}\n\n" + + while True: + try: + item = client_queue.get(timeout=15.0) + yield f"data: {json.dumps(item)}\n\n" + except queue.Empty: + # Keep-alive comment + yield f"data: {json.dumps({'event': 'keepalive', 'timestamp': int(time.time())})}\n\n" + except GeneratorExit: + pass + except Exception as exc: + logger.debug(f"SSE stream ended: {exc}") + finally: + with self._sse_lock: + if client_queue in self._sse_clients: + self._sse_clients.remove(client_queue) + + return generate() + + events._cp_config = {"response.stream": True} + + +# ====================================================================== +# Utility: make arbitrary objects JSON-serialisable for SSE events +# ====================================================================== + +def _to_json_safe(obj): + """Convert common companion objects to JSON-safe dicts/values.""" + if obj is None or isinstance(obj, (bool, int, float, str)): + return obj + if isinstance(obj, bytes): + return obj.hex() + if isinstance(obj, bytearray): + return bytes(obj).hex() + if isinstance(obj, dict): + return {k: _to_json_safe(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_to_json_safe(v) for v in obj] + # Dataclass / namedtuple with __dict__ + if hasattr(obj, "__dict__"): + return {k: _to_json_safe(v) for k, v in obj.__dict__.items() if not k.startswith("_")} + return str(obj) diff --git a/scripts/test_companion_api.sh b/scripts/test_companion_api.sh new file mode 100755 index 0000000..b8b7dd8 --- /dev/null +++ b/scripts/test_companion_api.sh @@ -0,0 +1,373 @@ +#!/usr/bin/env bash +# ============================================================================= +# test_companion_api.sh — Smoke-test the companion REST + SSE endpoints +# +# Usage: +# ./scripts/test_companion_api.sh # defaults +# ./scripts/test_companion_api.sh -H 192.168.1.10 # custom host +# ./scripts/test_companion_api.sh -p 9000 # custom port +# ./scripts/test_companion_api.sh -k # use API key instead of JWT +# ./scripts/test_companion_api.sh -P # target contact for send tests +# +# Requires: curl, jq +# ============================================================================= + +set -euo pipefail + +# ----- Defaults ----- +HOST="localhost" +PORT="8000" +USERNAME="admin" +PASSWORD="" +CLIENT_ID="test-companion-api" +API_KEY="" +TARGET_PUBKEY="" +COMPANION_NAME="" + +# ----- Parse args ----- +while getopts "H:p:u:w:k:P:c:h" opt; do + case $opt in + H) HOST="$OPTARG" ;; + p) PORT="$OPTARG" ;; + u) USERNAME="$OPTARG" ;; + w) PASSWORD="$OPTARG" ;; + k) API_KEY="$OPTARG" ;; + P) TARGET_PUBKEY="$OPTARG" ;; + c) COMPANION_NAME="$OPTARG" ;; + h) + echo "Usage: $0 [-H host] [-p port] [-u user] [-w password] [-k api_key] [-P target_pubkey] [-c companion_name]" + exit 0 + ;; + *) echo "Unknown option: -$opt" >&2; exit 1 ;; + esac +done + +BASE="http://${HOST}:${PORT}" +PASS=0 +FAIL=0 +SKIP=0 + +# ----- Colours ----- +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +# ----- Helpers ----- + +auth_header() { + if [[ -n "$API_KEY" ]]; then + echo "X-API-Key: ${API_KEY}" + elif [[ -n "$TOKEN" ]]; then + echo "Authorization: Bearer ${TOKEN}" + else + echo "" + fi +} + +# Run a test: name, expected_http_code, curl_args... +run_test() { + local name="$1" + local expect_code="$2" + shift 2 + + printf " %-50s " "$name" + + local tmpfile + tmpfile=$(mktemp) + + local http_code + http_code=$(curl -s -o "$tmpfile" -w "%{http_code}" \ + -H "$(auth_header)" \ + -H "Content-Type: application/json" \ + "$@" 2>/dev/null) || true + + local body + body=$(cat "$tmpfile") + rm -f "$tmpfile" + + if [[ "$http_code" == "$expect_code" ]]; then + local success + success=$(echo "$body" | jq -r '.success // empty' 2>/dev/null) || true + if [[ "$success" == "true" ]]; then + printf "${GREEN}PASS${NC} (HTTP %s)\n" "$http_code" + PASS=$((PASS + 1)) + elif [[ "$success" == "false" ]]; then + local err + err=$(echo "$body" | jq -r '.error // .data.reason // "unknown"' 2>/dev/null) || true + printf "${YELLOW}PASS${NC} (HTTP %s, success=false: %s)\n" "$http_code" "$err" + PASS=$((PASS + 1)) + else + printf "${GREEN}PASS${NC} (HTTP %s)\n" "$http_code" + PASS=$((PASS + 1)) + fi + else + printf "${RED}FAIL${NC} (expected HTTP %s, got %s)\n" "$expect_code" "$http_code" + if [[ -n "$body" ]]; then + echo " $(echo "$body" | jq -c '.' 2>/dev/null || echo "$body" | head -c 200)" + fi + FAIL=$((FAIL + 1)) + fi +} + +skip_test() { + local name="$1" + local reason="$2" + printf " %-50s ${YELLOW}SKIP${NC} (%s)\n" "$name" "$reason" + SKIP=$((SKIP + 1)) +} + +# Pretty-print a JSON response +show_response() { + local name="$1" + shift + printf "\n${CYAN}--- %s ---${NC}\n" "$name" + curl -s -H "$(auth_header)" -H "Content-Type: application/json" "$@" 2>/dev/null | jq '.' 2>/dev/null || echo "(no JSON)" + echo "" +} + +# ============================================================================= +# 0. Connectivity check +# ============================================================================= + +echo "" +echo "========================================" +echo " Companion API Test Suite" +echo " Target: ${BASE}" +echo "========================================" +echo "" + +printf "Checking connectivity... " +if ! curl -sf -o /dev/null --connect-timeout 3 "${BASE}/api/needs_setup" 2>/dev/null; then + printf "${RED}FAILED${NC}\n" + echo "Cannot reach ${BASE}. Is the repeater running?" + exit 1 +fi +printf "${GREEN}OK${NC}\n" + +# ============================================================================= +# 1. Authentication +# ============================================================================= + +TOKEN="" + +if [[ -n "$API_KEY" ]]; then + echo "" + echo "Using API key for authentication." + TOKEN="" +elif [[ -n "$PASSWORD" ]]; then + echo "" + printf "Authenticating as '${USERNAME}'... " + LOGIN_RESP=$(curl -s -X POST "${BASE}/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"username\":\"${USERNAME}\",\"password\":\"${PASSWORD}\",\"client_id\":\"${CLIENT_ID}\"}" 2>/dev/null) + + TOKEN=$(echo "$LOGIN_RESP" | jq -r '.token // empty' 2>/dev/null) || true + if [[ -n "$TOKEN" ]]; then + printf "${GREEN}OK${NC} (token received)\n" + else + printf "${RED}FAILED${NC}\n" + echo "$LOGIN_RESP" | jq '.' 2>/dev/null || echo "$LOGIN_RESP" + echo "" + echo "Cannot authenticate. Provide -w or -k ." + exit 1 + fi +else + echo "" + echo "No password (-w) or API key (-k) provided." + echo "Attempting unauthenticated requests (will fail if auth is required)." + echo "" +fi + +# ============================================================================= +# 2. Read-only GET endpoints +# ============================================================================= + +echo "" +echo "--- GET endpoints (read-only) ---" + +# Build companion_name query string if provided +QS="" +if [[ -n "$COMPANION_NAME" ]]; then + QS="?companion_name=${COMPANION_NAME}" +fi + +run_test "GET /api/companion/" 200 "${BASE}/api/companion/" +run_test "GET /api/companion/self_info" 200 "${BASE}/api/companion/self_info${QS}" +run_test "GET /api/companion/contacts" 200 "${BASE}/api/companion/contacts${QS}" +run_test "GET /api/companion/channels" 200 "${BASE}/api/companion/channels${QS}" +run_test "GET /api/companion/stats" 200 "${BASE}/api/companion/stats${QS}" +run_test "GET /api/companion/stats?type=core" 200 "${BASE}/api/companion/stats${QS:+${QS}&}${QS:+type=core}${QS:-?type=core}" + +# Single contact lookup (needs a pub_key — grab the first one from contacts list) +FIRST_PUBKEY=$(curl -s -H "$(auth_header)" "${BASE}/api/companion/contacts${QS}" 2>/dev/null \ + | jq -r '.data[0].public_key // empty' 2>/dev/null) || true + +if [[ -n "$FIRST_PUBKEY" ]]; then + run_test "GET /api/companion/contact?pub_key=..." 200 \ + "${BASE}/api/companion/contact?pub_key=${FIRST_PUBKEY}${QS:+&companion_name=${COMPANION_NAME}}" +else + skip_test "GET /api/companion/contact?pub_key=..." "no contacts available" +fi + +# ============================================================================= +# 3. Validation / error handling +# ============================================================================= + +echo "" +echo "--- Validation & error handling ---" + +run_test "GET /api/companion/contact (no pub_key)" 400 "${BASE}/api/companion/contact" +run_test "GET /api/companion/contact (bad pub_key)" 400 "${BASE}/api/companion/contact?pub_key=zzzz" +run_test "POST send_text empty body" 400 -X POST "${BASE}/api/companion/send_text" -d '{}' +run_test "GET send_text (wrong method)" 405 "${BASE}/api/companion/send_text" + +# ============================================================================= +# 4. POST endpoints (write / send operations) +# ============================================================================= + +echo "" +echo "--- POST endpoints ---" + +# Use TARGET_PUBKEY if provided, else use FIRST_PUBKEY from contacts list +PK="${TARGET_PUBKEY:-$FIRST_PUBKEY}" + +if [[ -z "$PK" ]]; then + skip_test "POST /api/companion/login" "no target pubkey (-P)" + skip_test "POST /api/companion/request_status" "no target pubkey (-P)" + skip_test "POST /api/companion/request_telemetry" "no target pubkey (-P)" + skip_test "POST /api/companion/send_text" "no target pubkey (-P)" + skip_test "POST /api/companion/send_command" "no target pubkey (-P)" + skip_test "POST /api/companion/reset_path" "no target pubkey (-P)" +else + # Build optional companion_name field for POST body + CN_FIELD="" + if [[ -n "$COMPANION_NAME" ]]; then + CN_FIELD="\"companion_name\":\"${COMPANION_NAME}\"," + fi + + # Login (passwordless) + run_test "POST /api/companion/login" 200 \ + -X POST "${BASE}/api/companion/login" \ + -d "{${CN_FIELD}\"pub_key\":\"${PK}\",\"password\":\"\"}" + + # Status request (may timeout — that's OK, we test the plumbing) + run_test "POST /api/companion/request_status" 200 \ + -X POST "${BASE}/api/companion/request_status" \ + -d "{${CN_FIELD}\"pub_key\":\"${PK}\",\"timeout\":5}" + + # Telemetry request + run_test "POST /api/companion/request_telemetry" 200 \ + -X POST "${BASE}/api/companion/request_telemetry" \ + -d "{${CN_FIELD}\"pub_key\":\"${PK}\",\"timeout\":5}" + + # Send text + run_test "POST /api/companion/send_text" 200 \ + -X POST "${BASE}/api/companion/send_text" \ + -d "{${CN_FIELD}\"pub_key\":\"${PK}\",\"text\":\"API test ping\"}" + + # Send command + run_test "POST /api/companion/send_command" 200 \ + -X POST "${BASE}/api/companion/send_command" \ + -d "{${CN_FIELD}\"pub_key\":\"${PK}\",\"command\":\"status\"}" + + # Reset path + run_test "POST /api/companion/reset_path" 200 \ + -X POST "${BASE}/api/companion/reset_path" \ + -d "{${CN_FIELD}\"pub_key\":\"${PK}\"}" +fi + +# ============================================================================= +# 5. Device configuration endpoints +# ============================================================================= + +echo "" +echo "--- Device configuration ---" + +CN_FIELD="" +if [[ -n "$COMPANION_NAME" ]]; then + CN_FIELD="\"companion_name\":\"${COMPANION_NAME}\"," +fi + +# Set advert name (we'll read it back to verify) +run_test "POST /api/companion/set_advert_name" 200 \ + -X POST "${BASE}/api/companion/set_advert_name" \ + -d "{${CN_FIELD}\"advert_name\":\"TestNode\"}" + +run_test "POST /api/companion/set_advert_location" 200 \ + -X POST "${BASE}/api/companion/set_advert_location" \ + -d "{${CN_FIELD}\"latitude\":37.7749,\"longitude\":-122.4194}" + +# ============================================================================= +# 6. SSE event stream (quick connect/disconnect test) +# ============================================================================= + +echo "" +echo "--- SSE event stream ---" + +SSE_URL="${BASE}/api/companion/events" +if [[ -n "$TOKEN" ]]; then + SSE_URL="${SSE_URL}?token=${TOKEN}" +elif [[ -n "$API_KEY" ]]; then + # SSE via EventSource doesn't support custom headers; API key in query not supported + # so we just test that the endpoint responds + SSE_URL="${SSE_URL}" +fi + +printf " %-50s " "SSE /api/companion/events (3s sample)" + +SSE_TMP=$(mktemp) +# Connect for 3 seconds, capture whatever comes +curl -s -N --max-time 3 \ + -H "$(auth_header)" \ + "$SSE_URL" > "$SSE_TMP" 2>/dev/null || true + +SSE_LINES=$(wc -l < "$SSE_TMP" | tr -d ' ') +SSE_FIRST=$(head -1 "$SSE_TMP") + +if [[ "$SSE_LINES" -gt 0 ]] && echo "$SSE_FIRST" | grep -q "data:"; then + # Check for connected event + if grep -q '"connected"' "$SSE_TMP" 2>/dev/null; then + printf "${GREEN}PASS${NC} (connected event received, %s lines)\n" "$SSE_LINES" + PASS=$((PASS + 1)) + else + printf "${YELLOW}PASS${NC} (got %s lines, no 'connected' event)\n" "$SSE_LINES" + PASS=$((PASS + 1)) + fi +else + printf "${RED}FAIL${NC} (no SSE data received)\n" + FAIL=$((FAIL + 1)) +fi +rm -f "$SSE_TMP" + +# ============================================================================= +# 7. Verbose output: show full response bodies +# ============================================================================= + +echo "" +echo "--- Sample responses ---" + +show_response "Companion listing" "${BASE}/api/companion/" +show_response "Self info" "${BASE}/api/companion/self_info${QS}" +show_response "Contacts" "${BASE}/api/companion/contacts${QS}" +show_response "Stats (packets)" "${BASE}/api/companion/stats${QS}" + +# ============================================================================= +# Summary +# ============================================================================= + +echo "" +echo "========================================" +printf " Results: ${GREEN}%d passed${NC}" "$PASS" +if [[ "$FAIL" -gt 0 ]]; then + printf ", ${RED}%d failed${NC}" "$FAIL" +fi +if [[ "$SKIP" -gt 0 ]]; then + printf ", ${YELLOW}%d skipped${NC}" "$SKIP" +fi +echo "" +echo "========================================" +echo "" + +exit $FAIL From c2f8a2e3cd2cdbeef83da65e31ce527e6b5f379d Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 21 Feb 2026 15:35:47 -0800 Subject: [PATCH 12/29] refactor: companion FrameServer and related (substantive only, no Black) Reapply refactor from ce8381a (replace monolithic FrameServer with thin pymc_core subclass, re-export constants, SQLite persistence hooks) while preserving pre-refactor whitespace where patch applied cleanly. Remaining files match refactor commit exactly. Diff vs ce8381a is whitespace-only. Co-authored-by: Cursor --- .gitignore | 2 +- README.md | 4 - config.yaml.example | 54 +- convert_firmware_key.sh | 8 +- debian/pymc-repeater.postinst | 4 +- manage.sh | 170 +- pyproject.toml | 1 - radio-presets.json | 2 +- repeater/__init__.py | 1 + repeater/airtime.py | 20 +- repeater/companion/__init__.py | 8 +- repeater/companion/constants.py | 284 +-- repeater/companion/frame_server.py | 1335 +--------- repeater/config.py | 14 +- repeater/config_manager.py | 111 +- repeater/data_acquisition/__init__.py | 6 +- repeater/data_acquisition/hardware_stats.py | 87 +- repeater/data_acquisition/letsmesh_handler.py | 90 +- repeater/data_acquisition/mqtt_handler.py | 15 +- repeater/data_acquisition/rrdtool_handler.py | 192 +- repeater/data_acquisition/sqlite_handler.py | 1196 +++++---- .../data_acquisition/storage_collector.py | 70 +- repeater/data_acquisition/storage_utils.py | 2 +- .../data_acquisition/websocket_handler.py | 22 +- repeater/engine.py | 112 +- repeater/handler_helpers/__init__.py | 16 +- repeater/handler_helpers/acl.py | 24 +- repeater/handler_helpers/advert.py | 5 +- repeater/handler_helpers/discovery.py | 1 + repeater/handler_helpers/login.py | 26 +- repeater/handler_helpers/mesh_cli.py | 430 ++-- repeater/handler_helpers/path.py | 38 +- repeater/handler_helpers/protocol_request.py | 69 +- repeater/handler_helpers/repeater_cli.py | 234 +- repeater/handler_helpers/room_server.py | 347 +-- repeater/handler_helpers/text.py | 319 +-- repeater/handler_helpers/trace.py | 102 +- repeater/identity_manager.py | 38 +- repeater/main.py | 245 +- repeater/packet_router.py | 27 +- repeater/service_utils.py | 8 +- repeater/web/__init__.py | 16 +- repeater/web/api_endpoints.py | 2175 +++++++++-------- repeater/web/auth/__init__.py | 8 +- repeater/web/auth/api_tokens.py | 23 +- repeater/web/auth/cherrypy_tool.py | 13 +- repeater/web/auth/jwt_handler.py | 23 +- repeater/web/auth/middleware.py | 55 +- repeater/web/auth_endpoints.py | 528 ++-- repeater/web/cad_calibration_engine.py | 257 +- repeater/web/companion_endpoints.py | 155 +- .../web/html/assets/plotly.min-DO11Gp-n.js | 44 +- repeater/web/http_server.py | 233 +- repeater/web/openapi.yaml | 24 +- scripts/build-prod.sh | 2 +- scripts/setup-build-env.sh | 2 +- setup-radio-config.sh | 10 +- 57 files changed, 4385 insertions(+), 4922 deletions(-) diff --git a/.gitignore b/.gitignore index 012cf78..f045404 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,4 @@ data/ # Logs *.log .DS_Store -syncpi.sh \ No newline at end of file +syncpi.sh diff --git a/README.md b/README.md index da5f638..e341724 100644 --- a/README.md +++ b/README.md @@ -286,7 +286,3 @@ This software is intended for educational and experimental purposes. Always test ## License This project is licensed under the MIT License - see the LICENSE file for details. - - - - diff --git a/config.yaml.example b/config.yaml.example index 39b7130..c711fa7 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -45,20 +45,20 @@ repeater: security: # Maximum number of authenticated clients (across all identities) max_clients: 1 - + # Admin password for full access admin_password: "admin123" - + # Guest password for limited access guest_password: "guest123" - + # Allow read-only access for clients without password/not in ACL allow_read_only: false - + # JWT secret key for signing tokens (auto-generated if not provided) # Generate with: python -c "import secrets; print(secrets.token_hex(32))" jwt_secret: "" - + # JWT token expiry time in minutes (default: 60 minutes / 1 hour) # Controls how long users stay logged in before needing to re-authenticate jwt_expiry_minutes: 60 @@ -81,7 +81,7 @@ identities: # - name: "TestBBS" # identity_key: "your_room_identity_key_hex_here" # type: "room_server" - # + # # # Room-specific settings # settings: # node_name: "Test BBS Room" @@ -89,7 +89,7 @@ identities: # longitude: 0.0 # admin_password: "room_admin_password" # guest_password: "room_guest_password" - + # Add more room servers as needed # - name: "SocialHub" # identity_key: "another_identity_key_hex_here" @@ -195,39 +195,39 @@ duty_cycle: mqtt: # Enable/disable MQTT publishing enabled: false - + # MQTT broker settings broker: "localhost" port: 1883 # Use 8883 for TLS/SSL, 80/443/9001 for WebSockets - + # Use WebSocket transport instead of standard TCP # Typically uses ports: 80 (ws://), 443 (wss://), or 9001 use_websockets: false - + # Authentication (optional) username: null password: null - + # TLS/SSL configuration (optional) # For public brokers with trusted certificates, just enable TLS: # tls: # enabled: true tls: enabled: false - + # Advanced TLS options (usually not needed for public brokers): - + # Custom CA certificate for server verification # Leave null to use system default CA certificates (recommended) ca_cert: null # e.g., "/etc/ssl/certs/ca-certificates.crt" - + # Client certificate and key for mutual TLS (rarely needed) client_cert: null # e.g., "/etc/pymc/client.crt" client_key: null # e.g., "/etc/pymc/client.key" - + # Skip certificate verification (insecure, not recommended) insecure: false - + # Base topic for publishing # Messages will be published to: {base_topic}/{node_name}/{packet|advert} base_topic: "meshcore/repeater" @@ -243,29 +243,29 @@ storage: retention: # Clean up SQLite records older than this many days sqlite_cleanup_days: 31 - + # RRD archives are managed automatically: # - 1 minute resolution for 1 week - # - 5 minute resolution for 1 month + # - 5 minute resolution for 1 month # - 1 hour resolution for 1 year letsmesh: enabled: false iata_code: "Test" # e.g., "SFO", "LHR", "Test" - + # ============================================================ # BROKER SELECTION MODE - Choose how to connect to brokers # ============================================================ - # + # # EXAMPLE 1: Single built-in broker (default, most common) # Connect to Europe only - simple, low bandwidth broker_index: 0 # 0 = Europe, 1 = US West - + # EXAMPLE 2: All built-in brokers for maximum redundancy # Survives single broker failure, best uptime # broker_index: -1 # or null - connects to both EU and US - + # EXAMPLE 3: Only custom brokers (private/self-hosted) # Ignores built-in LetsMesh brokers completely # broker_index: -2 @@ -274,7 +274,7 @@ letsmesh: # host: "mqtt.myserver.com" # port: 443 # audience: "mqtt.myserver.com" - + # EXAMPLE 4: Single built-in + custom backup # Use EU primary with your own backup # broker_index: 0 @@ -283,7 +283,7 @@ letsmesh: # host: "mqtt-backup.mydomain.com" # port: 8883 # audience: "mqtt-backup.mydomain.com" - + # EXAMPLE 5: All built-in + multiple custom (maximum redundancy) # EU + US + your own servers - best for critical deployments # broker_index: -1 @@ -297,14 +297,14 @@ letsmesh: # port: 443 # audience: "mqtt-2.mydomain.com" # ============================================================ - + status_interval: 300 owner: "" email: "" - + # Block specific packet types from being published to LetsMesh # If not specified or empty list, all types are published - # Available types: REQ, RESPONSE, TXT_MSG, ACK, ADVERT, GRP_TXT, + # Available types: REQ, RESPONSE, TXT_MSG, ACK, ADVERT, GRP_TXT, # GRP_DATA, ANON_REQ, PATH, TRACE, RAW_CUSTOM disallowed_packet_types: [] # - REQ # Don't publish requests diff --git a/convert_firmware_key.sh b/convert_firmware_key.sh index bd918e6..b5660d0 100755 --- a/convert_firmware_key.sh +++ b/convert_firmware_key.sh @@ -79,17 +79,17 @@ key_bytes = bytes.fromhex(key_hex) # Verify with pyMC if available try: from nacl.bindings import crypto_scalarmult_ed25519_base_noclamp - + scalar = key_bytes[:32] pubkey = crypto_scalarmult_ed25519_base_noclamp(scalar) - + print(f"Derived public key: {pubkey.hex()}") - + # Calculate address (MeshCore uses first byte of pubkey directly, not SHA256) address = pubkey[0] print(f"Node address: 0x{address:02x}") print() - + except ImportError: print("Warning: PyNaCl not available, skipping verification") print() diff --git a/debian/pymc-repeater.postinst b/debian/pymc-repeater.postinst index dc4f967..d2d57d7 100755 --- a/debian/pymc-repeater.postinst +++ b/debian/pymc-repeater.postinst @@ -38,13 +38,13 @@ case "$1" in echo "Installing pymc_core[hardware] from PyPI..." python3 -m pip install --break-system-packages 'pymc_core[hardware]>=1.0.7' || true fi - + # Install packages not available in Debian repos if ! python3 -c "import cherrypy_cors" 2>/dev/null; then echo "Installing cherrypy-cors from PyPI..." python3 -m pip install --break-system-packages 'cherrypy-cors==1.7.0' || true fi - + if ! python3 -c "import ws4py" 2>/dev/null; then echo "Installing ws4py from PyPI..." python3 -m pip install --break-system-packages 'ws4py>=0.5.1' || true diff --git a/manage.sh b/manage.sh index 57087d7..6e40487 100755 --- a/manage.sh +++ b/manage.sh @@ -96,7 +96,7 @@ get_status_display() { # Main menu show_main_menu() { local status=$(get_status_display) - + CHOICE=$($DIALOG --backtitle "pyMC Repeater Management" --title "pyMC Repeater Management" --menu "\nCurrent Status: $status\n\nChoose an action:" 18 70 9 \ "install" "Install pyMC Repeater" \ "upgrade" "Upgrade existing installation" \ @@ -109,7 +109,7 @@ show_main_menu() { "logs" "View live logs" \ "status" "Show detailed status" \ "exit" "Exit" 3>&1 1>&2 2>&3) - + case $CHOICE in "install") if is_installed; then @@ -173,10 +173,10 @@ install_repeater() { show_error "Installation requires root privileges.\n\nPlease run: sudo $0" return fi - + # Welcome screen $DIALOG --backtitle "pyMC Repeater Management" --title "Welcome" --msgbox "\nWelcome to pyMC Repeater Setup\n\nThis installer will configure your Linux system as a LoRa mesh network repeater.\n\nPress OK to continue..." 12 70 - + # SPI Check - Universal approach that works on all boards if ! ls /dev/spidev* >/dev/null 2>&1; then # SPI devices not found, check if we're on a Raspberry Pi and can enable it @@ -186,7 +186,7 @@ install_repeater() { elif [ -f "/boot/config.txt" ]; then CONFIG_FILE="/boot/config.txt" fi - + if [ -n "$CONFIG_FILE" ]; then # Raspberry Pi detected - offer to enable SPI if ask_yes_no "SPI Not Enabled" "\nSPI interface is required but not detected (/dev/spidev* not found)!\n\nWould you like to enable it now?\n(This will require a reboot)"; then @@ -203,29 +203,29 @@ install_repeater() { return fi fi - + # Get script directory for file copying during installation SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - + # Installation progress ( echo "0"; echo "# Creating service user..." if ! id "$SERVICE_USER" &>/dev/null; then useradd --system --home /var/lib/pymc_repeater --shell /sbin/nologin "$SERVICE_USER" fi - + echo "10"; echo "# Adding user to hardware groups..." usermod -a -G gpio,i2c,spi "$SERVICE_USER" 2>/dev/null || true usermod -a -G dialout "$SERVICE_USER" 2>/dev/null || true - + echo "20"; echo "# Creating directories..." mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater - + echo "25"; echo "# Installing system dependencies..." apt-get update -qq apt-get install -y libffi-dev jq pip python3-rrdtool wget swig build-essential python3-dev pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true - + # Install mikefarah yq v4 if not already installed if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then YQ_VERSION="v4.40.5" @@ -237,7 +237,7 @@ install_repeater() { fi wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" && chmod +x /usr/local/bin/yq fi - + echo "28"; echo "# Generating version file..." cd "$SCRIPT_DIR" # Generate version file using setuptools_scm before copying @@ -248,18 +248,18 @@ install_repeater() { python3 -c "from setuptools_scm import get_version; get_version(write_to='repeater/_version.py')" 2>&1 || echo " Warning: Could not generate _version.py file" echo " Generated version: $GENERATED_VERSION" fi - + # Clean up stale bytecode in source directory before copying find "$SCRIPT_DIR/repeater" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$SCRIPT_DIR/repeater" -type f -name '*.pyc' -delete 2>/dev/null || true - + echo "29"; echo "# Cleaning old installation files..." # Remove old repeater directory to ensure clean install rm -rf "$INSTALL_DIR/repeater" 2>/dev/null || true # Clean up old Python bytecode find "$INSTALL_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$INSTALL_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true - + echo "30"; echo "# Installing files..." cp -r "$SCRIPT_DIR/repeater" "$INSTALL_DIR/" cp "$SCRIPT_DIR/pyproject.toml" "$INSTALL_DIR/" @@ -268,17 +268,17 @@ install_repeater() { cp "$SCRIPT_DIR/pymc-repeater.service" "$INSTALL_DIR/" 2>/dev/null || true cp "$SCRIPT_DIR/radio-settings.json" /var/lib/pymc_repeater/ 2>/dev/null || true cp "$SCRIPT_DIR/radio-presets.json" /var/lib/pymc_repeater/ 2>/dev/null || true - + echo "45"; echo "# Installing configuration..." cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml.example" if [ ! -f "$CONFIG_DIR/config.yaml" ]; then cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml" fi - + echo "55"; echo "# Installing systemd service..." cp "$SCRIPT_DIR/pymc-repeater.service" /etc/systemd/system/ systemctl daemon-reload - + echo "65"; echo "# Setting permissions..." chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater chmod 750 "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater @@ -287,7 +287,7 @@ install_repeater() { # Pre-create the .config directory that the service will need mkdir -p /var/lib/pymc_repeater/.config/pymc_repeater chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config - + # Configure polkit for passwordless service restart mkdir -p /etc/polkit-1/rules.d cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF' @@ -300,13 +300,13 @@ polkit.addRule(function(action, subject) { }); EOF chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules - + echo "75"; echo "# Starting service..." systemctl enable "$SERVICE_NAME" - + echo "90"; echo "# Installation files complete..." ) | $DIALOG --backtitle "pyMC Repeater Management" --title "Installing" --gauge "Setting up pyMC Repeater..." 8 70 0 - + # Install Python package outside of progress gauge for better error handling clear echo "=== Installing Python Dependencies ===" @@ -314,13 +314,13 @@ EOF echo "Installing pymc_repeater and dependencies (including pymc_core from GitHub)..." echo "This may take a few minutes..." echo "" - + SCRIPT_DIR="$(dirname "$0")" cd "$SCRIPT_DIR" - + # Suppress pip root user warnings export PIP_ROOT_USER_ACTION=ignore - + # Calculate version from git for setuptools_scm if [ -d .git ]; then git fetch --tags 2>/dev/null || true @@ -330,16 +330,16 @@ EOF else export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5" fi - + # Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi) export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil echo "Note: Using optimized binary wheels for faster installation" echo "" - + if pip install --break-system-packages --no-cache-dir .[hardware]; then echo "" echo "✓ Python package installation completed successfully!" - + # Reload systemd and start the service systemctl daemon-reload systemctl start "$SERVICE_NAME" @@ -349,7 +349,7 @@ EOF echo "Please check the error messages above and try again." read -p "Press Enter to continue..." || true fi - + # Show final results sleep 2 local ip_address=$(hostname -I | awk '{print $1}') @@ -393,18 +393,18 @@ reset_repeater() { show_error "Upgrade requires root privileges.\n\nPlease run: sudo $0" return fi - + local current_version=$(get_version) - + if ask_yes_no "Confirm Reset of pyMC Repeater restoring to default configuration.\n\nContinue?"; then - + # Show info that upgrade is starting show_info "Reseting" "Starting reset process...\n\nProgress will be shown in the terminal." - + echo "=== Reset Progress ===" echo "[1/4] Stopping service..." systemctl stop "$SERVICE_NAME" 2>/dev/null || true - + echo "[2/4] Backing up configuration..." if [ -d "$CONFIG_DIR" ]; then cp -r "$CONFIG_DIR" "$CONFIG_DIR.backup.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true @@ -451,37 +451,37 @@ reset_repeater() { fi fi } - + # Upgrade function upgrade_repeater() { if [ "$EUID" -ne 0 ]; then show_error "Upgrade requires root privileges.\n\nPlease run: sudo $0" return fi - + local current_version=$(get_version) - + if ask_yes_no "Confirm Upgrade" "Current version: $current_version\n\nThis will upgrade pyMC Repeater while preserving your configuration.\n\nContinue?"; then - + # Show info that upgrade is starting show_info "Upgrading" "Starting upgrade process...\n\nThis may take a few minutes.\nProgress will be shown in the terminal." - + echo "=== Upgrade Progress ===" echo "[1/9] Stopping service..." systemctl stop "$SERVICE_NAME" 2>/dev/null || true - + echo "[2/9] Backing up configuration..." if [ -d "$CONFIG_DIR" ]; then cp -r "$CONFIG_DIR" "$CONFIG_DIR.backup.$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true echo " ✓ Configuration backed up" fi - + echo "[3/9] Updating system dependencies..." apt-get update -qq apt-get install -y libffi-dev jq pip python3-rrdtool wget swig build-essential python3-dev pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true - + # Install mikefarah yq v4 if not already installed if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then YQ_VERSION="v4.40.5" @@ -494,7 +494,7 @@ upgrade_repeater() { wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" && chmod +x /usr/local/bin/yq fi echo " ✓ Dependencies updated" - + echo "[3.5/9] Generating version file..." SCRIPT_DIR="$(dirname "$0")" cd "$SCRIPT_DIR" @@ -510,7 +510,7 @@ upgrade_repeater() { find "$SCRIPT_DIR/repeater" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$SCRIPT_DIR/repeater" -type f -name '*.pyc' -delete 2>/dev/null || true echo " ✓ Version file generated and bytecode cleaned" - + echo "[3.8/9] Cleaning old installation files..." # Remove old repeater directory to ensure clean upgrade rm -rf "$INSTALL_DIR/repeater" 2>/dev/null || true @@ -518,7 +518,7 @@ upgrade_repeater() { find "$INSTALL_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true find "$INSTALL_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true echo " ✓ Old files cleaned" - + echo "[4/9] Installing new files..." cp -r repeater "$INSTALL_DIR/" 2>/dev/null || true cp pyproject.toml "$INSTALL_DIR/" 2>/dev/null || true @@ -527,14 +527,14 @@ upgrade_repeater() { cp radio-settings.json /var/lib/pymc_repeater/ 2>/dev/null || true cp radio-presets.json /var/lib/pymc_repeater/ 2>/dev/null || true echo " ✓ Files updated" - + echo "[5/9] Validating and updating configuration..." if validate_and_update_config; then echo " ✓ Configuration validated and updated" else echo " ⚠ Configuration validation failed, keeping existing config" fi - + echo "[6/9] Fixing permissions..." chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater 2>/dev/null || true chmod 750 "$CONFIG_DIR" "$LOG_DIR" 2>/dev/null || true @@ -555,24 +555,24 @@ polkit.addRule(function(action, subject) { EOF chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules echo " ✓ Permissions updated" - + echo "[7/9] Reloading systemd..." systemctl daemon-reload echo " ✓ Systemd reloaded" - + echo "=== Installing Python Dependencies ===" echo "" echo "Updating pymc_repeater and dependencies (including pymc_core from GitHub)..." echo "This may take a few minutes..." echo "" - + # Install from source directory to properly resolve Git dependencies SCRIPT_DIR="$(dirname "$0")" cd "$SCRIPT_DIR" - + # Suppress pip root user warnings export PIP_ROOT_USER_ACTION=ignore - + # Calculate version from git for setuptools_scm if [ -d .git ]; then git fetch --tags 2>/dev/null || true @@ -582,12 +582,12 @@ EOF else export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5" fi - + # Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi) export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil echo "Note: Using optimized binary wheels and cached packages for faster installation" echo "" - + # Upgrade packages (uses cache for unchanged dependencies - much faster) if python3 -m pip install --break-system-packages --upgrade --upgrade-strategy eager .[hardware]; then echo "" @@ -596,22 +596,22 @@ EOF echo "" echo "⚠ Package update failed, but continuing..." fi - + echo "" echo "✓ All packages including pymc_core reinstalled successfully" - + echo "[8/9] Starting service..." systemctl daemon-reload systemctl start "$SERVICE_NAME" echo " ✓ Service started" - + echo "[9/9] Verifying installation..." sleep 3 # Give service time to start - + local new_version=$(get_version) - + if is_running; then echo " ✓ Service is running" show_info "Upgrade Complete" "Upgrade completed successfully!\n\nVersion: $current_version → $new_version\n\n✓ Service is running\n✓ Configuration preserved" @@ -630,10 +630,10 @@ configure_radio() { show_error "Service is not running!\n\nPlease start the service first from the main menu." return fi - + # Get IP address local ip_address=$(hostname -I | awk '{print $1}') - + # Show info about web-based configuration if ask_yes_no "Configure Radio Settings" "Radio configuration is now done through the web interface.\n\nThe web-based setup wizard provides an easy way to:\n\n• Change repeater name\n• Select hardware board\n• Configure radio frequency and settings\n• Update admin password\n\nWeb Dashboard: http://$ip_address:8000/setup\n\nWould you like to open this information?"; then clear @@ -669,36 +669,36 @@ uninstall_repeater() { show_error "Uninstall requires root privileges.\n\nPlease run: sudo $0" return fi - + if ask_yes_no "Confirm Uninstall" "This will completely remove pyMC Repeater including:\n\n- Service and files\n- Configuration (backup will be created)\n- Logs and data\n\nThis action cannot be undone!\n\nContinue?"; then ( echo "0"; echo "# Stopping and disabling service..." systemctl stop "$SERVICE_NAME" 2>/dev/null || true systemctl disable "$SERVICE_NAME" 2>/dev/null || true - + echo "20"; echo "# Backing up configuration..." if [ -d "$CONFIG_DIR" ]; then cp -r "$CONFIG_DIR" "/tmp/pymc_repeater_config_backup_$(date +%Y%m%d_%H%M%S)" 2>/dev/null || true fi - + echo "40"; echo "# Removing service files..." rm -f /etc/systemd/system/pymc-repeater.service systemctl daemon-reload - + echo "60"; echo "# Removing installation..." rm -rf "$INSTALL_DIR" rm -rf "$CONFIG_DIR" rm -rf "$LOG_DIR" rm -rf /var/lib/pymc_repeater - + echo "80"; echo "# Removing service user..." if id "$SERVICE_USER" &>/dev/null; then userdel "$SERVICE_USER" 2>/dev/null || true fi - + echo "100"; echo "# Uninstall complete!" ) | $DIALOG --backtitle "pyMC Repeater Management" --title "Uninstalling" --gauge "Removing pyMC Repeater..." 8 70 0 - + show_info "Uninstall Complete" "\npyMC Repeater has been completely removed.\n\nConfiguration backup saved to /tmp/\n\nThank you for using pyMC Repeater!" fi } @@ -706,17 +706,17 @@ uninstall_repeater() { # Service management manage_service() { local action=$1 - + if [ "$EUID" -ne 0 ]; then show_error "Service management requires root privileges.\n\nPlease run: sudo $0" return fi - + if ! service_exists; then show_error "Service is not installed." return fi - + case $action in "start") systemctl start "$SERVICE_NAME" @@ -746,14 +746,14 @@ show_detailed_status() { local status_info="" local version=$(get_version) local ip_address=$(hostname -I | awk '{print $1}') - + status_info="Installation Status: " if is_installed; then status_info="${status_info}Installed\n" status_info="${status_info}Version: $version\n" status_info="${status_info}Install Directory: $INSTALL_DIR\n" status_info="${status_info}Config Directory: $CONFIG_DIR\n\n" - + status_info="${status_info}Service Status: " if is_running; then status_info="${status_info}Running ✓\n" @@ -761,7 +761,7 @@ show_detailed_status() { else status_info="${status_info}Stopped ✗\n\n" fi - + # Add system info status_info="${status_info}System Info:\n" status_info="${status_info}- SPI: " @@ -770,14 +770,14 @@ show_detailed_status() { else status_info="${status_info}Disabled ✗\n" fi - + status_info="${status_info}- IP Address: $ip_address\n" status_info="${status_info}- Hostname: $(hostname)\n" - + else status_info="${status_info}Not Installed" fi - + show_info "System Status" "$status_info" } @@ -786,7 +786,7 @@ validate_and_update_config() { local config_file="$CONFIG_DIR/config.yaml" local example_file="config.yaml.example" local updated_example="$CONFIG_DIR/config.yaml.example" - + # Copy the new example file if [ -f "$example_file" ]; then cp "$example_file" "$updated_example" @@ -794,40 +794,40 @@ validate_and_update_config() { echo " ⚠ config.yaml.example not found in source directory" return 1 fi - + # Check if user config exists if [ ! -f "$config_file" ]; then echo " ⚠ No existing config.yaml found, copying example" cp "$updated_example" "$config_file" return 0 fi - + # Check if yq is available YQ_CMD="/usr/local/bin/yq" if ! command -v "$YQ_CMD" &> /dev/null; then echo " ⚠ mikefarah yq not found at $YQ_CMD, skipping config merge" return 0 fi - + # Verify it's the correct yq version if [[ "$($YQ_CMD --version 2>&1)" != *"mikefarah/yq"* ]]; then echo " ⚠ Wrong yq version detected at $YQ_CMD, skipping config merge" return 0 fi - + echo " Merging configuration..." - + # Create backup of user config local backup_file="${config_file}.backup.$(date +%Y%m%d_%H%M%S)" cp "$config_file" "$backup_file" echo " ✓ Backup created: $backup_file" - + # Merge strategy: user config takes precedence, add missing keys from example # This uses yq's multiply merge operator (*) which: # - Keeps all values from the right operand (user config) # - Adds missing keys from the left operand (example config) local temp_merged="${config_file}.merged" - + if "$YQ_CMD" eval-all '. as $item ireduce ({}; . * $item)' "$updated_example" "$config_file" > "$temp_merged" 2>/dev/null; then # Verify the merged file is valid YAML if "$YQ_CMD" eval '.' "$temp_merged" > /dev/null 2>&1; then diff --git a/pyproject.toml b/pyproject.toml index 4d52e7c..767502b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,4 +86,3 @@ line_length = 100 [tool.setuptools_scm] version_scheme = "guess-next-dev" local_scheme = "no-local-version" - diff --git a/radio-presets.json b/radio-presets.json index 40b4b7a..9123f44 100644 --- a/radio-presets.json +++ b/radio-presets.json @@ -1 +1 @@ -{"config":{"connect_screen":{"info_message":"The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings."},"remote_management":{"repeaters":{"guest_login_enabled":true,"guest_login_disabled_message":"Guest login has been temporarily disabled. Please try again later.","guest_login_passwords":[""],"flood_routed_guest_login_enabled":true,"flood_routed_guest_login_disabled_message":"To avoid overwhelming the mesh with flood packets, please set a path to log in to a repeater as a guest."}},"suggested_radio_settings":{"info_message":"These radio settings have been suggested by the community.","entries":[{"title":"Australia","description":"915.800MHz / SF10 / BW250 / CR5","frequency":"915.800","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Australia: Victoria","description":"916.575MHz / SF7 / BW62.5 / CR8","frequency":"916.575","spreading_factor":"7","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Narrow)","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Long Range)","description":"869.525MHz / SF11 / BW250 / CR5","frequency":"869.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"EU/UK (Medium Range)","description":"869.525MHz / SF10 / BW250 / CR5","frequency":"869.525","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Czech Republic (Narrow)","description":"869.525MHz / SF7 / BW62.5 / CR5","frequency":"869.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"EU 433MHz (Long Range)","description":"433.650MHz / SF11 / BW250 / CR5","frequency":"433.650","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand","description":"917.375MHz / SF11 / BW250 / CR5","frequency":"917.375","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand (Narrow)","description":"917.375MHz / SF7 / BW62.5 / CR5","frequency":"917.375","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"Portugal 433","description":"433.375MHz / SF9 / BW62.5 / CR6","frequency":"433.375","spreading_factor":"9","bandwidth":"62.5","coding_rate":"6"},{"title":"Portugal 868","description":"869.618MHz / SF7 / BW62.5 / CR6","frequency":"869.618","spreading_factor":"7","bandwidth":"62.5","coding_rate":"6"},{"title":"Switzerland","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"USA/Canada (Recommended)","description":"910.525MHz / SF7 / BW62.5 / CR5","frequency":"910.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"USA/Canada (Alternate)","description":"910.525MHz / SF11 / BW250 / CR5","frequency":"910.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"Vietnam","description":"920.250MHz / SF11 / BW250 / CR5","frequency":"920.250","spreading_factor":"11","bandwidth":"250","coding_rate":"5"}]}}} \ No newline at end of file +{"config":{"connect_screen":{"info_message":"The default pin for devices without a screen is 123456. Trouble pairing? Forget the bluetooth device in system settings."},"remote_management":{"repeaters":{"guest_login_enabled":true,"guest_login_disabled_message":"Guest login has been temporarily disabled. Please try again later.","guest_login_passwords":[""],"flood_routed_guest_login_enabled":true,"flood_routed_guest_login_disabled_message":"To avoid overwhelming the mesh with flood packets, please set a path to log in to a repeater as a guest."}},"suggested_radio_settings":{"info_message":"These radio settings have been suggested by the community.","entries":[{"title":"Australia","description":"915.800MHz / SF10 / BW250 / CR5","frequency":"915.800","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Australia: Victoria","description":"916.575MHz / SF7 / BW62.5 / CR8","frequency":"916.575","spreading_factor":"7","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Narrow)","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"EU/UK (Long Range)","description":"869.525MHz / SF11 / BW250 / CR5","frequency":"869.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"EU/UK (Medium Range)","description":"869.525MHz / SF10 / BW250 / CR5","frequency":"869.525","spreading_factor":"10","bandwidth":"250","coding_rate":"5"},{"title":"Czech Republic (Narrow)","description":"869.525MHz / SF7 / BW62.5 / CR5","frequency":"869.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"EU 433MHz (Long Range)","description":"433.650MHz / SF11 / BW250 / CR5","frequency":"433.650","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand","description":"917.375MHz / SF11 / BW250 / CR5","frequency":"917.375","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"New Zealand (Narrow)","description":"917.375MHz / SF7 / BW62.5 / CR5","frequency":"917.375","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"Portugal 433","description":"433.375MHz / SF9 / BW62.5 / CR6","frequency":"433.375","spreading_factor":"9","bandwidth":"62.5","coding_rate":"6"},{"title":"Portugal 868","description":"869.618MHz / SF7 / BW62.5 / CR6","frequency":"869.618","spreading_factor":"7","bandwidth":"62.5","coding_rate":"6"},{"title":"Switzerland","description":"869.618MHz / SF8 / BW62.5 / CR8","frequency":"869.618","spreading_factor":"8","bandwidth":"62.5","coding_rate":"8"},{"title":"USA/Canada (Recommended)","description":"910.525MHz / SF7 / BW62.5 / CR5","frequency":"910.525","spreading_factor":"7","bandwidth":"62.5","coding_rate":"5"},{"title":"USA/Canada (Alternate)","description":"910.525MHz / SF11 / BW250 / CR5","frequency":"910.525","spreading_factor":"11","bandwidth":"250","coding_rate":"5"},{"title":"Vietnam","description":"920.250MHz / SF11 / BW250 / CR5","frequency":"920.250","spreading_factor":"11","bandwidth":"250","coding_rate":"5"}]}}} diff --git a/repeater/__init__.py b/repeater/__init__.py index df25aa5..19b87fa 100644 --- a/repeater/__init__.py +++ b/repeater/__init__.py @@ -3,6 +3,7 @@ try: except ImportError: try: from importlib.metadata import version + __version__ = version("pymc_repeater") except Exception: __version__ = "unknown" diff --git a/repeater/airtime.py b/repeater/airtime.py index bd8ae26..d823974 100644 --- a/repeater/airtime.py +++ b/repeater/airtime.py @@ -37,9 +37,9 @@ class AirtimeManager: ) -> float: """ Calculate LoRa packet airtime using the Semtech reference formula. - + Reference: https://www.semtech.com/design-support/lora-calculator - + Args: payload_len: Payload length in bytes spreading_factor: SF7-SF12 (uses config value if None) @@ -48,7 +48,7 @@ class AirtimeManager: preamble_len: Preamble symbols (uses config value if None) crc_enabled: Whether CRC is enabled (default: True) explicit_header: Whether explicit header mode is used (default: True) - + Returns: Airtime in milliseconds """ @@ -58,25 +58,25 @@ class AirtimeManager: preamble_len = preamble_len or self.preamble_length crc = 1 if crc_enabled else 0 h = 0 if explicit_header else 1 # H=0 for explicit, H=1 for implicit - + # Low data rate optimization: required for SF11/SF12 at 125kHz de = 1 if (sf >= 11 and bandwidth_hz <= 125000) else 0 - + # Symbol time in milliseconds: T_sym = 2^SF / BW_kHz - t_sym = (2 ** sf) / bw_khz - + t_sym = (2**sf) / bw_khz + # Preamble time: T_preamble = (n_preamble + 4.25) * T_sym t_preamble = (preamble_len + 4.25) * t_sym - + # Payload symbol calculation (Semtech formula): # n_payload = 8 + ceil(max(8*PL - 4*SF + 28 + 16*CRC - 20*H, 0) / (4*(SF - 2*DE))) * CR numerator = max(8 * payload_len - 4 * sf + 28 + 16 * crc - 20 * h, 0) denominator = 4 * (sf - 2 * de) n_payload = 8 + math.ceil(numerator / denominator) * cr - + # Payload time t_payload = n_payload * t_sym - + # Total packet airtime return t_preamble + t_payload diff --git a/repeater/companion/__init__.py b/repeater/companion/__init__.py index ecfa313..f64af20 100644 --- a/repeater/companion/__init__.py +++ b/repeater/companion/__init__.py @@ -3,17 +3,17 @@ Exposes the MeshCore companion frame protocol over TCP for standard clients. """ -from .frame_server import CompanionFrameServer from .constants import ( CMD_APP_START, CMD_GET_CONTACTS, + CMD_SEND_LOGIN, CMD_SEND_TXT_MSG, CMD_SYNC_NEXT_MESSAGE, - CMD_SEND_LOGIN, - RESP_CODE_OK, - RESP_CODE_ERR, PUSH_CODE_MSG_WAITING, + RESP_CODE_ERR, + RESP_CODE_OK, ) +from .frame_server import CompanionFrameServer __all__ = [ "CompanionFrameServer", diff --git a/repeater/companion/constants.py b/repeater/companion/constants.py index 4fdbd7c..2fa3b16 100644 --- a/repeater/companion/constants.py +++ b/repeater/companion/constants.py @@ -1,138 +1,150 @@ -"""Companion frame protocol constants (MeshCore Companion Radio Protocol).""" +"""Companion frame protocol constants — re-exported from pyMC_core. -import base64 +All protocol constants now live in :mod:`pymc_core.companion.constants`. +This module re-exports them so existing repeater imports continue to work. +""" -# Commands (app -> radio) -CMD_APP_START = 1 -CMD_SEND_TXT_MSG = 2 -CMD_SEND_CHANNEL_TXT_MSG = 3 -CMD_GET_CONTACTS = 4 -CMD_GET_DEVICE_TIME = 5 -CMD_SET_DEVICE_TIME = 6 -CMD_SEND_SELF_ADVERT = 7 -CMD_SET_ADVERT_NAME = 8 -CMD_ADD_UPDATE_CONTACT = 9 -CMD_SYNC_NEXT_MESSAGE = 10 -CMD_SET_RADIO_PARAMS = 11 -CMD_SET_RADIO_TX_POWER = 12 -CMD_RESET_PATH = 13 -CMD_SET_ADVERT_LATLON = 14 -CMD_REMOVE_CONTACT = 15 -CMD_SHARE_CONTACT = 16 -CMD_EXPORT_CONTACT = 17 -CMD_IMPORT_CONTACT = 18 -CMD_REBOOT = 19 -CMD_GET_BATT_AND_STORAGE = 20 -CMD_SET_TUNING_PARAMS = 21 -CMD_DEVICE_QUERY = 22 -CMD_EXPORT_PRIVATE_KEY = 23 -CMD_IMPORT_PRIVATE_KEY = 24 -CMD_SEND_RAW_DATA = 25 -CMD_SEND_LOGIN = 26 -CMD_SEND_STATUS_REQ = 27 -CMD_HAS_CONNECTION = 28 -CMD_LOGOUT = 29 -CMD_GET_CONTACT_BY_KEY = 30 -CMD_GET_CHANNEL = 31 -CMD_SET_CHANNEL = 32 -CMD_SIGN_START = 33 -CMD_SIGN_DATA = 34 -CMD_SIGN_FINISH = 35 -CMD_SEND_TRACE_PATH = 36 -CMD_SET_DEVICE_PIN = 37 -CMD_SET_OTHER_PARAMS = 38 -CMD_SEND_TELEMETRY_REQ = 39 -CMD_GET_CUSTOM_VARS = 40 -CMD_SET_CUSTOM_VAR = 41 -CMD_GET_ADVERT_PATH = 42 -CMD_GET_TUNING_PARAMS = 43 -CMD_SEND_BINARY_REQ = 50 -CMD_FACTORY_RESET = 51 -CMD_SEND_PATH_DISCOVERY_REQ = 52 -CMD_SET_FLOOD_SCOPE = 54 -CMD_SEND_CONTROL_DATA = 55 -CMD_GET_STATS = 56 -CMD_SEND_ANON_REQ = 57 -CMD_SET_AUTOADD_CONFIG = 58 -CMD_GET_AUTOADD_CONFIG = 59 - -# Response codes (radio -> app) -RESP_CODE_OK = 0 -RESP_CODE_ERR = 1 -RESP_CODE_CONTACTS_START = 2 -RESP_CODE_CONTACT = 3 -RESP_CODE_END_OF_CONTACTS = 4 -RESP_CODE_SELF_INFO = 5 -RESP_CODE_SENT = 6 -RESP_CODE_CONTACT_MSG_RECV = 7 -RESP_CODE_CHANNEL_MSG_RECV = 8 -RESP_CODE_CURR_TIME = 9 -RESP_CODE_NO_MORE_MESSAGES = 10 -RESP_CODE_EXPORT_CONTACT = 11 -RESP_CODE_BATT_AND_STORAGE = 12 -RESP_CODE_DEVICE_INFO = 13 # CMD_DEVICE_QUERY response -RESP_CODE_PRIVATE_KEY = 14 -RESP_CODE_DISABLED = 15 -RESP_CODE_CONTACT_MSG_RECV_V3 = 16 -RESP_CODE_CHANNEL_MSG_RECV_V3 = 17 -RESP_CODE_CHANNEL_INFO = 18 -RESP_CODE_SIGN_START = 19 -RESP_CODE_SIGNATURE = 20 -RESP_CODE_CUSTOM_VARS = 21 -RESP_CODE_ADVERT_PATH = 22 -RESP_CODE_TUNING_PARAMS = 23 -RESP_CODE_STATS = 24 -RESP_CODE_AUTOADD_CONFIG = 25 - -# Push codes (radio -> app, unsolicited) -PUSH_CODE_ADVERT = 0x80 -PUSH_CODE_PATH_UPDATED = 0x81 -PUSH_CODE_SEND_CONFIRMED = 0x82 -PUSH_CODE_MSG_WAITING = 0x83 -PUSH_CODE_RAW_DATA = 0x84 -PUSH_CODE_LOGIN_SUCCESS = 0x85 -PUSH_CODE_LOGIN_FAIL = 0x86 -PUSH_CODE_STATUS_RESPONSE = 0x87 -PUSH_CODE_LOG_RX_DATA = 0x88 -PUSH_CODE_TRACE_DATA = 0x89 -PUSH_CODE_NEW_ADVERT = 0x8A -PUSH_CODE_TELEMETRY_RESPONSE = 0x8B -PUSH_CODE_BINARY_RESPONSE = 0x8C -PUSH_CODE_PATH_DISCOVERY_RESPONSE = 0x8D -PUSH_CODE_CONTROL_DATA = 0x8E -PUSH_CODE_CONTACT_DELETED = 0x8F -PUSH_CODE_CONTACTS_FULL = 0x90 - -# Error codes -ERR_CODE_UNSUPPORTED_CMD = 1 -ERR_CODE_NOT_FOUND = 2 -ERR_CODE_TABLE_FULL = 3 -ERR_CODE_BAD_STATE = 4 -ERR_CODE_FILE_IO_ERROR = 5 -ERR_CODE_ILLEGAL_ARG = 6 - -# Stats sub-types -STATS_TYPE_CORE = 0 -STATS_TYPE_RADIO = 1 -STATS_TYPE_PACKETS = 2 - -# Frame delimiters (USB/TCP: > = outbound, < = inbound) -FRAME_OUTBOUND_PREFIX = 0x3E # '>' -FRAME_INBOUND_PREFIX = 0x3C # '<' -MAX_FRAME_SIZE = 512 -PUB_KEY_SIZE = 32 -MAX_PATH_SIZE = 64 - -# ADV types -ADV_TYPE_CHAT = 1 -ADV_TYPE_REPEATER = 2 -ADV_TYPE_ROOM = 3 -ADV_TYPE_SENSOR = 4 - -# Default Public channel PSK (from firmware MeshCore/examples/companion_radio/MyMesh.cpp) -# Base64-encoded; decode to get the 16-byte secret used for MAC/AES -PUBLIC_GROUP_PSK = b"izOH6cXN6mrJ5e26oRXNcg==" - -# Default public channel secret: base64-decode PUBLIC_GROUP_PSK so we match firmware -# (firmware uses decode_base64(psk) -> 16 bytes; HMAC key is that + 16 zero bytes) -DEFAULT_PUBLIC_CHANNEL_SECRET = base64.b64decode(PUBLIC_GROUP_PSK) +# Re-exports; F401 ignored for re-exported names. +from pymc_core.companion.constants import ( # noqa: F401 + ADV_TYPE_CHAT, + ADV_TYPE_REPEATER, + ADV_TYPE_ROOM, + ADV_TYPE_SENSOR, + ADVERT_LOC_NONE, + ADVERT_LOC_SHARE, + AUTOADD_CHAT, + AUTOADD_OVERWRITE_OLDEST, + AUTOADD_REPEATER, + AUTOADD_ROOM, + AUTOADD_SENSOR, + CMD_ADD_UPDATE_CONTACT, + CMD_APP_START, + CMD_DEVICE_QUERY, + CMD_EXPORT_CONTACT, + CMD_EXPORT_PRIVATE_KEY, + CMD_FACTORY_RESET, + CMD_GET_ADVERT_PATH, + CMD_GET_AUTOADD_CONFIG, + CMD_GET_BATT_AND_STORAGE, + CMD_GET_CHANNEL, + CMD_GET_CONTACT_BY_KEY, + CMD_GET_CONTACTS, + CMD_GET_CUSTOM_VARS, + CMD_GET_DEVICE_TIME, + CMD_GET_STATS, + CMD_GET_TUNING_PARAMS, + CMD_HAS_CONNECTION, + CMD_IMPORT_CONTACT, + CMD_IMPORT_PRIVATE_KEY, + CMD_LOGOUT, + CMD_REBOOT, + CMD_REMOVE_CONTACT, + CMD_RESET_PATH, + CMD_SEND_ANON_REQ, + CMD_SEND_BINARY_REQ, + CMD_SEND_CHANNEL_TXT_MSG, + CMD_SEND_CONTROL_DATA, + CMD_SEND_LOGIN, + CMD_SEND_PATH_DISCOVERY_REQ, + CMD_SEND_RAW_DATA, + CMD_SEND_SELF_ADVERT, + CMD_SEND_STATUS_REQ, + CMD_SEND_TELEMETRY_REQ, + CMD_SEND_TRACE_PATH, + CMD_SEND_TXT_MSG, + CMD_SET_ADVERT_LATLON, + CMD_SET_ADVERT_NAME, + CMD_SET_AUTOADD_CONFIG, + CMD_SET_CHANNEL, + CMD_SET_CUSTOM_VAR, + CMD_SET_DEVICE_PIN, + CMD_SET_DEVICE_TIME, + CMD_SET_FLOOD_SCOPE, + CMD_SET_OTHER_PARAMS, + CMD_SET_RADIO_PARAMS, + CMD_SET_RADIO_TX_POWER, + CMD_SET_TUNING_PARAMS, + CMD_SHARE_CONTACT, + CMD_SIGN_DATA, + CMD_SIGN_FINISH, + CMD_SIGN_START, + CMD_SYNC_NEXT_MESSAGE, + CONTACT_NAME_SIZE, + DEFAULT_MAX_CHANNELS, + DEFAULT_MAX_CONTACTS, + DEFAULT_OFFLINE_QUEUE_SIZE, + DEFAULT_PUBLIC_CHANNEL_SECRET, + DEFAULT_RESPONSE_TIMEOUT_MS, + ERR_CODE_BAD_STATE, + ERR_CODE_FILE_IO_ERROR, + ERR_CODE_ILLEGAL_ARG, + ERR_CODE_NOT_FOUND, + ERR_CODE_TABLE_FULL, + ERR_CODE_UNSUPPORTED_CMD, + FRAME_INBOUND_PREFIX, + FRAME_OUTBOUND_PREFIX, + MAX_FRAME_SIZE, + MAX_PATH_SIZE, + MAX_SIGN_DATA_SIZE, + MSG_SEND_FAILED, + MSG_SEND_SENT_DIRECT, + MSG_SEND_SENT_FLOOD, + PROTOCOL_CODE_ANON_REQ, + PROTOCOL_CODE_BINARY_REQ, + PROTOCOL_CODE_RAW_DATA, + PUB_KEY_SIZE, + PUBLIC_GROUP_PSK, + PUSH_CODE_ADVERT, + PUSH_CODE_BINARY_RESPONSE, + PUSH_CODE_CONTACT_DELETED, + PUSH_CODE_CONTACTS_FULL, + PUSH_CODE_CONTROL_DATA, + PUSH_CODE_LOG_RX_DATA, + PUSH_CODE_LOGIN_FAIL, + PUSH_CODE_LOGIN_SUCCESS, + PUSH_CODE_MSG_WAITING, + PUSH_CODE_NEW_ADVERT, + PUSH_CODE_PATH_DISCOVERY_RESPONSE, + PUSH_CODE_PATH_UPDATED, + PUSH_CODE_RAW_DATA, + PUSH_CODE_SEND_CONFIRMED, + PUSH_CODE_STATUS_RESPONSE, + PUSH_CODE_TELEMETRY_RESPONSE, + PUSH_CODE_TRACE_DATA, + RESP_CODE_ADVERT_PATH, + RESP_CODE_AUTOADD_CONFIG, + RESP_CODE_BATT_AND_STORAGE, + RESP_CODE_CHANNEL_INFO, + RESP_CODE_CHANNEL_MSG_RECV, + RESP_CODE_CHANNEL_MSG_RECV_V3, + RESP_CODE_CONTACT, + RESP_CODE_CONTACT_MSG_RECV, + RESP_CODE_CONTACT_MSG_RECV_V3, + RESP_CODE_CONTACTS_START, + RESP_CODE_CURR_TIME, + RESP_CODE_CUSTOM_VARS, + RESP_CODE_DEVICE_INFO, + RESP_CODE_DISABLED, + RESP_CODE_END_OF_CONTACTS, + RESP_CODE_ERR, + RESP_CODE_EXPORT_CONTACT, + RESP_CODE_NO_MORE_MESSAGES, + RESP_CODE_OK, + RESP_CODE_PRIVATE_KEY, + RESP_CODE_SELF_INFO, + RESP_CODE_SENT, + RESP_CODE_SIGN_START, + RESP_CODE_SIGNATURE, + RESP_CODE_STATS, + RESP_CODE_TUNING_PARAMS, + STATS_TYPE_CORE, + STATS_TYPE_PACKETS, + STATS_TYPE_RADIO, + TELEM_MODE_ALLOW_ALL, + TELEM_MODE_ALLOW_FLAGS, + TELEM_MODE_DENY, + TXT_TYPE_CLI_DATA, + TXT_TYPE_PLAIN, + TXT_TYPE_SIGNED_PLAIN, + BinaryReqType, +) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index 1a9062a..ba051f1 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -1,129 +1,29 @@ """ -Companion frame protocol TCP server. +Repeater-specific CompanionFrameServer with SQLite persistence. -Implements the MeshCore Companion Radio Protocol over TCP for standard clients. -Frame format: outbound '>' + 2-byte len (LE) + data; inbound '<' + 2-byte len + data. +Thin subclass of :class:`pymc_core.companion.frame_server.CompanionFrameServer` +that adds SQLite-backed message, contact, and channel persistence via a +``sqlite_handler`` dependency. """ +from __future__ import annotations + import asyncio -import base64 import logging -import struct -import time from typing import Optional -from pymc_core.companion.constants import ADV_TYPE_CHAT -from pymc_core.companion.models import Contact, QueuedMessage - -from .constants import ( - RESP_CODE_DEVICE_INFO, - CMD_ADD_UPDATE_CONTACT, - CMD_GET_CHANNEL, - CMD_GET_CONTACT_BY_KEY, - CMD_SET_CHANNEL, - CMD_SET_FLOOD_SCOPE, - CMD_APP_START, - CMD_DEVICE_QUERY, - CMD_GET_ADVERT_PATH, - CMD_GET_BATT_AND_STORAGE, - CMD_GET_CONTACTS, - CMD_GET_STATS, - CMD_IMPORT_CONTACT, - CMD_REMOVE_CONTACT, - CMD_RESET_PATH, - CMD_SEND_BINARY_REQ, - CMD_SEND_PATH_DISCOVERY_REQ, - CMD_SEND_CONTROL_DATA, - CMD_SEND_CHANNEL_TXT_MSG, - CMD_SEND_LOGIN, - CMD_SEND_SELF_ADVERT, - CMD_SEND_STATUS_REQ, - CMD_SEND_TELEMETRY_REQ, - CMD_SEND_TRACE_PATH, - CMD_SEND_TXT_MSG, - CMD_SET_ADVERT_LATLON, - CMD_SET_ADVERT_NAME, - CMD_SYNC_NEXT_MESSAGE, - ERR_CODE_BAD_STATE, - ERR_CODE_ILLEGAL_ARG, - ERR_CODE_NOT_FOUND, - ERR_CODE_TABLE_FULL, - ERR_CODE_UNSUPPORTED_CMD, - FRAME_INBOUND_PREFIX, - FRAME_OUTBOUND_PREFIX, - MAX_FRAME_SIZE, - MAX_PATH_SIZE, - PUB_KEY_SIZE, - PUSH_CODE_ADVERT, - PUSH_CODE_BINARY_RESPONSE, - PUSH_CODE_LOGIN_FAIL, - PUSH_CODE_LOGIN_SUCCESS, - PUSH_CODE_LOG_RX_DATA, - PUSH_CODE_NEW_ADVERT, - PUSH_CODE_TRACE_DATA, - PUSH_CODE_MSG_WAITING, - PUSH_CODE_PATH_UPDATED, - PUSH_CODE_SEND_CONFIRMED, - PUSH_CODE_STATUS_RESPONSE, - PUSH_CODE_TELEMETRY_RESPONSE, - RESP_CODE_ADVERT_PATH, - RESP_CODE_BATT_AND_STORAGE, - RESP_CODE_CHANNEL_INFO, - RESP_CODE_CHANNEL_MSG_RECV, - RESP_CODE_CHANNEL_MSG_RECV_V3, - RESP_CODE_CONTACT, - RESP_CODE_CONTACT_MSG_RECV_V3, - RESP_CODE_CONTACT_MSG_RECV, - RESP_CODE_CONTACTS_START, - RESP_CODE_END_OF_CONTACTS, - RESP_CODE_ERR, - RESP_CODE_NO_MORE_MESSAGES, - RESP_CODE_OK, - RESP_CODE_SELF_INFO, - RESP_CODE_SENT, - RESP_CODE_STATS, - STATS_TYPE_CORE, - STATS_TYPE_PACKETS, - STATS_TYPE_RADIO, - PUSH_CODE_PATH_DISCOVERY_RESPONSE, - PUSH_CODE_CONTROL_DATA, -) +from pymc_core.companion.frame_server import CompanionFrameServer as _BaseFrameServer +from pymc_core.companion.models import QueuedMessage logger = logging.getLogger("CompanionFrameServer") -def _build_advert_push_frames(data: dict) -> tuple[bytes, Optional[bytes]]: - """Build PUSH_CODE_ADVERT short frame and optional PUSH_CODE_NEW_ADVERT full frame from extracted data. Thread-safe for asyncio.to_thread.""" - pubkey_b = data.get("pubkey_b", b"") - if isinstance(pubkey_b, bytes): - pubkey_b = pubkey_b[:32].ljust(32, b"\x00") - else: - pubkey_b = b"\x00" * 32 - short = bytes([PUSH_CODE_ADVERT]) + pubkey_b - if not data.get("include_full"): - return (short, None) - op = data.get("out_path", b"") - op = (op if isinstance(op, bytes) else bytes(op or []))[:MAX_PATH_SIZE].ljust(MAX_PATH_SIZE, b"\x00") - nb = data.get("name_b", b"") - nb = (nb if isinstance(nb, bytes) else (nb.encode("utf-8", errors="replace") if isinstance(nb, str) else b""))[:32].ljust(32, b"\x00") - full = ( - bytes([PUSH_CODE_NEW_ADVERT]) - + pubkey_b - + bytes([data.get("adv_type", 0), data.get("flags", 0), data.get("opl_byte", 0xFF)]) - + op - + nb - + struct.pack(" dict for companion stats - self._control_handler = control_handler # Optional; used to register/clear discovery callbacks so "No callback waiting" is not logged - self._server: Optional[asyncio.Server] = None - self._client_writer: Optional[asyncio.StreamWriter] = None - self._client_reader: Optional[asyncio.StreamReader] = None - self._app_target_ver = 0 - - async def start(self) -> None: - """Start the TCP server.""" - self._server = await asyncio.start_server( - self._handle_client, - self.bind_address, - self.port, + super().__init__( + bridge=bridge, + companion_hash=companion_hash, + port=port, + bind_address=bind_address, + device_model="pyMC-Repeater-Companion", + device_version="1.0.0", + build_date="13 Feb 2026", + local_hash=local_hash, + stats_getter=stats_getter, + control_handler=control_handler, ) - addr = self._server.sockets[0].getsockname() if self._server.sockets else (self.bind_address, self.port) - logger.info(f"Companion frame server listening on {addr[0]}:{addr[1]} (hash=0x{int(self.companion_hash):02x})") + self.sqlite_handler = sqlite_handler - async def stop(self) -> None: - """Stop the TCP server and disconnect any client.""" - if self._client_writer: - try: - self._client_writer.close() - await self._client_writer.wait_closed() - except Exception: - pass - self._client_writer = None - self._client_reader = None - if self._server: - self._server.close() - await self._server.wait_closed() - self._server = None - logger.info(f"Companion frame server stopped (port={self.port})") + # ----------------------------------------------------------------- + # Persistence hook overrides + # ----------------------------------------------------------------- async def _persist_companion_message(self, msg_dict: dict) -> None: - """Persist a message to SQLite (deduplicated) and remove it from the bridge queue so it is delivered once from SQLite. - SQLite I/O runs in a thread so the event loop stays responsive and the client does not time out.""" + """Persist message to SQLite and pop from bridge queue.""" if not self.sqlite_handler: return await asyncio.to_thread( - self.sqlite_handler.companion_push_message, self.companion_hash, msg_dict + self.sqlite_handler.companion_push_message, + self.companion_hash, + msg_dict, ) self.bridge.message_queue.pop_last() - def _setup_push_callbacks(self) -> None: - """Subscribe to bridge events and send PUSH frames to connected client.""" - - def _write_push(data: bytes) -> None: - if self._client_writer and not self._client_writer.is_closing(): - try: - frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack("= 32: - _write_push(bytes([PUSH_CODE_PATH_UPDATED]) + pub_key[:32]) - - async def on_channel_message_received( - channel_name, sender_name, message_text, timestamp, path_len=0, channel_idx=0, packet_hash=None - ): - msg_dict = { - "sender_key": b"", - "text": message_text, - "timestamp": timestamp, - "txt_type": 0, - "is_channel": True, - "channel_idx": channel_idx, - "path_len": path_len, - "packet_hash": packet_hash, - } - await self._persist_companion_message(msg_dict) - _write_push(bytes([PUSH_CODE_MSG_WAITING])) - - async def on_binary_response(tag_bytes, response_data, parsed=None, request_type=None): - # PUSH_CODE_BINARY_RESPONSE: 0x8C + reserved(1) + tag(4) + response_payload - frame = ( - bytes([PUSH_CODE_BINARY_RESPONSE, 0]) - + (tag_bytes if isinstance(tag_bytes, bytes) else struct.pack(" None: - """Push PUSH_CODE_TRACE_DATA (0x89) to client. Matches firmware onTraceRecv() frame format.""" - if not self._client_writer or self._client_writer.is_closing(): - return - # Firmware: code(1) + reserved(1) + path_len(1) + flags(1) + tag(4) + auth(4) + path_hashes + path_snrs + final_snr(1) - path_sz = flags & 0x03 - expected_snr_len = path_len >> path_sz - if len(path_snrs) != expected_snr_len: - logger.debug("push_trace_data: path_snrs len %s != expected %s", len(path_snrs), expected_snr_len) - return - data = ( - bytes([PUSH_CODE_TRACE_DATA, 0, path_len, flags]) - + struct.pack(" Optional[QueuedMessage]: + """Retrieve next message from SQLite when bridge queue is empty.""" + if not self.sqlite_handler: + return None + msg_dict = self.sqlite_handler.companion_pop_message(self.companion_hash) + if not msg_dict: + return None + return QueuedMessage( + sender_key=msg_dict.get("sender_key", b""), + txt_type=msg_dict.get("txt_type", 0), + timestamp=msg_dict.get("timestamp", 0), + text=msg_dict.get("text", ""), + is_channel=bool(msg_dict.get("is_channel", False)), + channel_idx=msg_dict.get("channel_idx", 0), + path_len=msg_dict.get("path_len", 0), ) - try: - frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack(" None: - """Push raw RX packet to client (PUSH_CODE_LOG_RX_DATA 0x88). Matches firmware logRxRaw() so client can track repeats by packet hash.""" - if not self._client_writer or self._client_writer.is_closing(): - logger.debug("push_rx_raw: no client connected (companion %s)", self.companion_hash) - return - # Firmware: code(1) + snr(1) + rssi(1) + raw; snr = (int8)(snr*4), rssi = (int8)rssi - snr_byte = max(-128, min(127, int(round(snr * 4)))) - rssi_byte = max(-128, min(127, int(rssi))) - if snr_byte < 0: - snr_byte += 256 - if rssi_byte < 0: - rssi_byte += 256 - payload_len = min(len(raw), MAX_FRAME_SIZE - 3) - data = bytes([PUSH_CODE_LOG_RX_DATA, snr_byte & 0xFF, rssi_byte & 0xFF]) + raw[:payload_len] - try: - frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack(" None: - """Push CONTROL packet to client (PUSH_CODE_CONTROL_DATA 0x8E). Spec: code, SNR*4, RSSI (signed), path_len, payload (no path bytes). Frame layout matches meshcore_py reader (PacketType.CONTROL_DATA) and firmware MyMesh::onControlDataRecv. See docs/companion-discovery.md for discovery payload layout.""" - if not self._client_writer or self._client_writer.is_closing(): - logger.warning("Push control data skipped: no client connection") - return - # Discovery response (0x90): clear the no-op callback we registered for this tag - if self._control_handler and len(payload) >= 6 and (payload[0] & 0xF0) == 0x90: - tag = struct.unpack(" None: - if self._client_writer: - try: - await self._client_writer.drain() - except Exception: - pass - - def _write_frame(self, data: bytes) -> None: - """Send a frame to the connected client (outbound format).""" - if self._client_writer and not self._client_writer.is_closing(): - frame = bytes([FRAME_OUTBOUND_PREFIX]) + struct.pack(" None: - self._write_frame(bytes([RESP_CODE_OK])) - - def _write_err(self, err_code: int) -> None: - self._write_frame(bytes([RESP_CODE_ERR, err_code])) def _save_contacts(self) -> None: """Persist contacts to SQLite.""" @@ -460,859 +91,41 @@ class CompanionFrameServer: dicts = [] for c in contacts: pk = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key) - dicts.append({ - "pubkey": pk, - "name": c.name, - "adv_type": c.adv_type, - "flags": c.flags, - "out_path_len": c.out_path_len, - "out_path": c.out_path if isinstance(c.out_path, bytes) else (bytes.fromhex(c.out_path) if c.out_path else b""), - "last_advert_timestamp": c.last_advert_timestamp, - "lastmod": c.lastmod, - "gps_lat": c.gps_lat, - "gps_lon": c.gps_lon, - "sync_since": c.sync_since, - }) + dicts.append( + { + "pubkey": pk, + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "out_path": ( + c.out_path + if isinstance(c.out_path, bytes) + else (bytes.fromhex(c.out_path) if c.out_path else b"") + ), + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + "sync_since": c.sync_since, + } + ) self.sqlite_handler.companion_save_contacts(self.companion_hash, dicts) - async def _handle_client( - self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter - ) -> None: - """Handle a new client connection. One client at a time.""" - if self._client_writer: - logger.warning("Companion already has a client; rejecting new connection") - writer.close() - await writer.wait_closed() - return - - self._client_reader = reader - self._client_writer = writer - self._setup_push_callbacks() - logger.info(f"Companion client connected (port={self.port})") - - try: - while True: - prefix = await reader.read(1) - if not prefix: - break - if prefix[0] != FRAME_INBOUND_PREFIX: - logger.warning(f"Invalid frame prefix: 0x{prefix[0]:02x}") - continue - len_bytes = await reader.readexactly(2) - frame_len = struct.unpack(" MAX_FRAME_SIZE: - logger.warning(f"Frame too long: {frame_len}") - break - payload = await reader.readexactly(frame_len) - await self._handle_cmd(payload) - except asyncio.IncompleteReadError: - pass - except (ConnectionResetError, BrokenPipeError): - pass - except Exception as e: - logger.error(f"Client handler error: {e}", exc_info=True) - finally: - self._client_writer = None - self._client_reader = None - logger.info(f"Companion client disconnected (port={self.port})") - - async def _handle_cmd(self, payload: bytes) -> None: - """Dispatch command to handler.""" - if not payload: - return - cmd = payload[0] - data = payload[1:] - # Log every command at INFO so discovery (52) and unsupported are visible in logs - logger.info("Companion cmd 0x%02x (%s) len=%s", cmd, cmd, len(payload)) - if cmd in (CMD_GET_CHANNEL, CMD_SET_CHANNEL): - logger.debug(f"Companion cmd 0x{cmd:02x} ({'GET_CHANNEL' if cmd == CMD_GET_CHANNEL else 'SET_CHANNEL'}), payload_len={len(payload)}") - - try: - if cmd == CMD_APP_START: - await self._cmd_app_start(data) - elif cmd == CMD_DEVICE_QUERY: - await self._cmd_device_query(data) - elif cmd == CMD_GET_CONTACTS: - await self._cmd_get_contacts(data) - elif cmd == CMD_GET_CONTACT_BY_KEY: - await self._cmd_get_contact_by_key(data) - elif cmd == CMD_SEND_TXT_MSG: - await self._cmd_send_txt_msg(data) - elif cmd == CMD_SEND_CHANNEL_TXT_MSG: - await self._cmd_send_channel_txt_msg(data) - elif cmd == CMD_SYNC_NEXT_MESSAGE: - await self._cmd_sync_next_message(data) - elif cmd == CMD_SEND_LOGIN: - await self._cmd_send_login(data) - elif cmd == CMD_SEND_STATUS_REQ: - await self._cmd_send_status_req(data) - elif cmd == CMD_SEND_TELEMETRY_REQ: - await self._cmd_send_telemetry_req(data) - elif cmd == CMD_SEND_SELF_ADVERT: - await self._cmd_send_self_advert(data) - elif cmd == CMD_SET_ADVERT_NAME: - await self._cmd_set_advert_name(data) - elif cmd == CMD_SET_ADVERT_LATLON: - await self._cmd_set_advert_latlon(data) - elif cmd == CMD_ADD_UPDATE_CONTACT: - await self._cmd_add_update_contact(data) - elif cmd == CMD_REMOVE_CONTACT: - await self._cmd_remove_contact(data) - elif cmd == CMD_RESET_PATH: - await self._cmd_reset_path(data) - elif cmd == CMD_GET_BATT_AND_STORAGE: - await self._cmd_get_batt_and_storage(data) - elif cmd == CMD_GET_STATS: - await self._cmd_get_stats(data) - elif cmd == CMD_GET_ADVERT_PATH: - await self._cmd_get_advert_path(data) - elif cmd == CMD_IMPORT_CONTACT: - await self._cmd_import_contact(data) - elif cmd == CMD_GET_CHANNEL: - await self._cmd_get_channel(data) - elif cmd == CMD_SET_CHANNEL: - await self._cmd_set_channel(data) - elif cmd == CMD_SEND_BINARY_REQ: - await self._cmd_send_binary_req(data) - elif cmd == CMD_SEND_PATH_DISCOVERY_REQ: - await self._cmd_send_path_discovery_req(data) - elif cmd == CMD_SEND_CONTROL_DATA: - await self._cmd_send_control_data(data) - elif cmd == CMD_SEND_TRACE_PATH: - await self._cmd_send_trace_path(data) - elif cmd == CMD_SET_FLOOD_SCOPE: - # App sends this on connect; no-op for repeater companion (no radio scope) - self._write_ok() - else: - logger.warning( - "Companion unsupported cmd 0x%02x (%s) len=%s (expected 52 for path discovery)", - cmd, cmd, len(payload), - ) - self._write_err(ERR_CODE_UNSUPPORTED_CMD) - except Exception as e: - logger.error(f"Cmd 0x{cmd:02x} error: {e}", exc_info=True) - self._write_err(ERR_CODE_ILLEGAL_ARG) - - async def _cmd_app_start(self, data: bytes) -> None: - if len(data) >= 1: - self._app_target_ver = data[0] - # RESP_CODE_SELF_INFO - name is varchar (remainder of frame) - # Send name without null terminator; client displays remainder of frame as-is - prefs = self.bridge.get_self_info() - pubkey = self.bridge.get_public_key() - name = prefs.node_name.encode("utf-8", errors="replace") - lat = int(getattr(prefs, "latitude", 0) * 1e6) - lon = int(getattr(prefs, "longitude", 0) * 1e6) - frame = ( - bytes([RESP_CODE_SELF_INFO, ADV_TYPE_CHAT, prefs.tx_power_dbm, 22]) - + pubkey - + struct.pack(" None: - if len(data) >= 1: - self._app_target_ver = data[0] - firmware_ver = 8 - # Protocol: max_contacts_div_2 and max_channels are bytes (ver 3+) - max_contacts = getattr( - getattr(self.bridge, "contacts", None), "max_contacts", 1000 - ) - max_channels_val = getattr( - getattr(self.bridge, "channels", None), "max_channels", 40 - ) - max_contacts_div_2 = min(max_contacts // 2, 255) - max_channels = min(max_channels_val, 255) - ble_pin = 0 - build_date = b"13 Feb 2026\x00"[:12].ljust(12, b"\x00") - model = b"pyMC-Repeater-Companion\x00"[:40].ljust(40, b"\x00") - version = b"1.0.0\x00"[:20].ljust(20, b"\x00") - frame = ( - bytes([RESP_CODE_DEVICE_INFO, firmware_ver, max_contacts_div_2, max_channels]) - + struct.pack(" None: - since = struct.unpack("= 4 else 0 - contacts = self.bridge.get_contacts(since=since) - self._write_frame(bytes([RESP_CODE_CONTACTS_START]) + struct.pack(" 0xFF, else 0-255 - opl = c.out_path_len if hasattr(c, "out_path_len") else -1 - opl_byte = 0xFF if opl < 0 else min(opl, 255) - frame = ( - bytes([RESP_CODE_CONTACT]) - + pubkey - + bytes([c.adv_type if hasattr(c, "adv_type") else 0, c.flags if hasattr(c, "flags") else 0]) - + bytes([opl_byte]) - + (c.out_path[:MAX_PATH_SIZE] if hasattr(c, "out_path") and c.out_path else b"").ljust(MAX_PATH_SIZE, b"\x00") - + name - + struct.pack(" None: - """Handle CMD_GET_CONTACT_BY_KEY (0x1e): lookup by 32-byte pubkey, respond with RESP_CODE_CONTACT or ERR.""" - if len(data) < PUB_KEY_SIZE: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[:PUB_KEY_SIZE] - contact = self.bridge.contacts.get_by_key(pubkey) if hasattr(self.bridge.contacts, "get_by_key") else None - if not contact: - self._write_err(ERR_CODE_NOT_FOUND) - return - c = contact - pubkey_b = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key) - name = (c.name.encode("utf-8")[:32] if isinstance(c.name, str) else c.name[:32]).ljust(32, b"\x00") - opl = c.out_path_len if hasattr(c, "out_path_len") else -1 - opl_byte = 0xFF if opl < 0 else min(opl, 255) - frame = ( - bytes([RESP_CODE_CONTACT]) - + pubkey_b - + bytes([c.adv_type if hasattr(c, "adv_type") else 0, c.flags if hasattr(c, "flags") else 0]) - + bytes([opl_byte]) - + (c.out_path[:MAX_PATH_SIZE] if hasattr(c, "out_path") and c.out_path else b"").ljust(MAX_PATH_SIZE, b"\x00") - + name - + struct.pack(" None: - if len(data) < 12: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - txt_type = data[0] - attempt = data[1] - sender_ts = struct.unpack_from(" None: - if len(data) < 6: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - # Protocol: txt_type(1) + channel_idx(1) + sender_timestamp(4) + text (matches firmware/meshcore_py) - txt_type = data[0] - channel_idx = data[1] - sender_ts = struct.unpack_from(" None: - # CMD_SEND_BINARY_REQ: pubkey(32) + req_data (request_type(1) + optional payload) - if len(data) < 33: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[:32] - req_data = data[32:] - send_binary_req = getattr(self.bridge, "send_binary_req", None) - if not send_binary_req: - self._write_err(ERR_CODE_UNSUPPORTED_CMD) - return - try: - result = await send_binary_req(pubkey, req_data) - except Exception as e: - logger.error(f"send_binary_req error: {e}", exc_info=True) - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - if not result.success: - self._write_err(ERR_CODE_NOT_FOUND) - return - # RESP_CODE_SENT: 0x06 + flood(1) + tag(4 LE) + timeout(4 LE) - tag = result.expected_ack if result.expected_ack is not None else 0 - timeout_ms = result.timeout_ms if result.timeout_ms is not None else 10000 - frame = bytes([RESP_CODE_SENT, 1 if result.is_flood else 0]) + struct.pack(" None: - # CMD_SEND_CONTROL_DATA (55): first byte is flags/type (0x80 = DISCOVER_REQ). Firmware: (cmd_frame[1] & 0x80) != 0. - if len(data) < 2: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - if (data[0] & 0x80) == 0: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - # Discovery request: register a no-op response callback so ControlHandler won't log "No callback waiting" - if self._control_handler and len(data) >= 6 and (data[0] & 0xF0) == 0x80: - tag = struct.unpack(" None: - # CMD_SEND_PATH_DISCOVERY_REQ (52): reserved(1) + pub_key(32). Firmware: cmd_frame[1]==0, cmd_frame[2:34]=pub_key. - logger.info("Path discovery request received (cmd 52), data_len=%s", len(data)) - if len(data) < 33: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pub_key = data[1:33] - send_req = getattr(self.bridge, "send_path_discovery_req", None) - if not send_req: - self._write_err(ERR_CODE_UNSUPPORTED_CMD) - return - try: - result = await send_req(pub_key) - except Exception as e: - logger.error("send_path_discovery_req error: %s", e, exc_info=True) - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - if not result.success: - self._write_err(ERR_CODE_NOT_FOUND) - return - tag = result.expected_ack if result.expected_ack is not None else 0 - timeout_ms = result.timeout_ms if result.timeout_ms is not None else 10000 - frame = bytes([RESP_CODE_SENT, 1 if result.is_flood else 0]) + struct.pack(" None: - # CMD_SEND_TRACE_PATH: tag(4) + auth(4) + flags(1) + path_bytes (firmware MyMesh.cpp) - if len(data) < 10: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - tag = struct.unpack_from("> path_sz) <= MAX_PATH_SIZE and path_len % (1 << path_sz) == 0 - if (path_len >> path_sz) > MAX_PATH_SIZE or (path_len % (1 << path_sz)) != 0: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - send_raw = getattr(self.bridge, "send_trace_path_raw", None) - if not send_raw: - self._write_err(ERR_CODE_UNSUPPORTED_CMD) - return - try: - ok = await send_raw(tag, auth_code, flags, path_bytes) - except Exception as e: - logger.error(f"send_trace_path error: {e}", exc_info=True) - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - if not ok: - self._write_err(ERR_CODE_TABLE_FULL) - return - # RESP_CODE_SENT + 0 (not flood) + tag(4) + est_timeout(4) = 10 bytes (firmware) - est_timeout_ms = 5000 + (path_len * 200) - frame = bytes([RESP_CODE_SENT, 0]) + struct.pack("> path_sz - path_snrs = bytes(snr_len) # no RX SNR when we're the sender - final_snr_byte = 0 - self.push_trace_data( - path_len, flags, tag, auth_code, path_bytes, path_snrs, final_snr_byte - ) - - async def _cmd_sync_next_message(self, data: bytes) -> None: - msg = self.bridge.sync_next_message() - if msg is None and self.sqlite_handler: - msg_dict = self.sqlite_handler.companion_pop_message(self.companion_hash) - if msg_dict: - msg = QueuedMessage( - sender_key=msg_dict.get("sender_key", b""), - txt_type=msg_dict.get("txt_type", 0), - timestamp=msg_dict.get("timestamp", 0), - text=msg_dict.get("text", ""), - is_channel=bool(msg_dict.get("is_channel", False)), - channel_idx=msg_dict.get("channel_idx", 0), - path_len=msg_dict.get("path_len", 0), - ) - if msg is None: - self._write_frame(bytes([RESP_CODE_NO_MORE_MESSAGES])) - return - if msg.is_channel: - # Layout must match meshcore_py reader.py (PacketType.CHANNEL_MSG_RECV and type 17) - # so client can group repeats by (channel_idx, sender_timestamp, text); path_len differs per repeat. - path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF - txt_type = 0 # TXT_TYPE_PLAIN - text_bytes = (msg.text or "").rstrip("\x00").encode("utf-8", errors="replace") - if self._app_target_ver >= 3: - # V3: code(1) + snr(1) + reserved(2) + channel_idx(1) + path_len(1) + txt_type(1) + timestamp(4) + text - frame = bytes([ - RESP_CODE_CHANNEL_MSG_RECV_V3, - 0, 0, 0, # snr + reserved - msg.channel_idx, - path_len_byte, - txt_type, - ]) + struct.pack("= 6 else msg.sender_key.ljust(6, b"\x00") - path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF - text_bytes = msg.text.encode("utf-8", errors="replace") - if self._app_target_ver >= 3: - frame = bytes([ - RESP_CODE_CONTACT_MSG_RECV_V3, - 0, 0, 0, # snr + reserved - ]) + prefix + bytes([path_len_byte, msg.txt_type]) + struct.pack(" None: - if len(data) < 32: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[:32] - password = data[32:].decode("utf-8", errors="replace").rstrip("\x00") if len(data) > 32 else "" - self._write_frame(bytes([RESP_CODE_SENT, 1]) + struct.pack(" None: - # CMD_SEND_STATUS_REQ (27): pub_key(32). - # Firmware: cmd_frame[0]=CMD, pub_key = &cmd_frame[1]; len >= 1+PUB_KEY_SIZE - # data here is payload[1:] so data = pub_key(32). No reserved byte. - if len(data) < 32: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[0:32] - # Immediate RESP_CODE_SENT so client knows the request was dispatched - self._write_frame(bytes([RESP_CODE_SENT, 0]) + struct.pack("= 16 else raw_bytes.hex()}" - ) - self._write_frame( - bytes([PUSH_CODE_STATUS_RESPONSE, 0]) - + pubkey[:6] - + raw_bytes - ) - - async def _cmd_send_telemetry_req(self, data: bytes) -> None: - # CMD_SEND_TELEMETRY_REQ (39): reserved(3) + pub_key(32) + optional flags(1). - # Firmware: cmd_frame[0]=CMD, reserved(3), pub_key = &cmd_frame[4]; len >= 4+PUB_KEY_SIZE - # data here is payload[1:] so data = reserved(3) + pub_key(32) + optional flags. - if len(data) < 35: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[3:35] - # The 3 reserved bytes (data[0..2]) are unused by firmware. - # Default to requesting all telemetry categories. - flags = 0x07 # request all: base + location + environment - want_base = bool(flags & 0x01) - want_location = bool(flags & 0x02) - want_environment = bool(flags & 0x04) - # Immediate RESP_CODE_SENT so client knows the request was dispatched - self._write_frame(bytes([RESP_CODE_SENT, 0]) + struct.pack(" None: - flood = len(data) >= 1 and data[0] == 1 - ok = await self.bridge.advertise(flood=flood) - self._write_ok() if ok else self._write_err(ERR_CODE_BAD_STATE) - - async def _cmd_set_advert_name(self, data: bytes) -> None: - name = data.decode("utf-8", errors="replace").rstrip("\x00") - self.bridge.set_advert_name(name) - self._write_ok() - - async def _cmd_set_advert_latlon(self, data: bytes) -> None: - if len(data) < 8: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - lat, lon = struct.unpack_from(" None: - # Match meshcore minimum: 36 bytes (pubkey 32 + adv_type 1 + flags 1 + out_path_len 1). - if len(data) < 36: - self._write_err(ERR_CODE_ILLEGAL_ARG) - await self._drain_writer() - return - pubkey = data[0:32] - adv_type = data[32] - flags = data[33] - out_path_len = struct.unpack_from("= out_path_end: - out_path = data[35:out_path_end].rstrip(b"\x00") - else: - out_path = data[35:len(data)].rstrip(b"\x00") if len(data) > 35 else b"" - name_start = 35 + MAX_PATH_SIZE - name_end = name_start + 32 - if len(data) >= name_end: - name_raw = data[name_start:name_end] - elif len(data) > name_start: - name_raw = data[name_start:len(data)].ljust(32, b"\x00") - else: - name_raw = b"\x00" * 32 - name = name_raw.split(b"\x00")[0].decode("utf-8", errors="replace") - last_advert = 0 - if len(data) >= name_end + 4: - last_advert = struct.unpack_from("= name_end + 4 + 8: - gps_lat = struct.unpack_from("= name_end + 4 + 12: - lastmod = struct.unpack_from(" 255 else out_path_len - out_path_padded = (out_path[:MAX_PATH_SIZE] if out_path else b"").ljust(MAX_PATH_SIZE, b"\x00") - name_padded = (name.encode("utf-8")[:32] if isinstance(name, str) else name[:32]).ljust(32, b"\x00") - contact_frame = ( - bytes([RESP_CODE_CONTACT]) - + pubkey - + bytes([adv_type, flags, opl_byte]) - + out_path_padded - + name_padded - + struct.pack(" None: - if len(data) < 32: - self._write_err(ERR_CODE_ILLEGAL_ARG) - await self._drain_writer() - return - pubkey = data[:32] - ok = self.bridge.remove_contact(pubkey) - if ok and self.sqlite_handler: - self._save_contacts() - self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) - await self._drain_writer() - - async def _cmd_reset_path(self, data: bytes) -> None: - if len(data) < 32: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pubkey = data[:32] - ok = self.bridge.reset_path(pubkey) - self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) - - async def _cmd_get_batt_and_storage(self, data: bytes) -> None: - millivolts = 0 - used_kb = 0 - total_kb = 0 - frame = bytes([RESP_CODE_BATT_AND_STORAGE]) + struct.pack(" None: - # CMD_GET_STATS (56): data[0] = stats_type (0=core, 1=radio, 2=packets). Firmware MyMesh.cpp + meshcore_py reader. - stats_type = data[0] if len(data) >= 1 else STATS_TYPE_PACKETS - if stats_type not in (STATS_TYPE_CORE, STATS_TYPE_RADIO, STATS_TYPE_PACKETS): - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - stats = (self.stats_getter(stats_type) if self.stats_getter else None) or self.bridge.get_stats(stats_type) - frame = bytes([RESP_CODE_STATS, stats_type]) - if stats_type == STATS_TYPE_CORE: - # Format: battery_mv(H) + uptime_secs(I) + errors(H) + queue_len(B) = 9 bytes (meshcore_py ) - battery_mv = int(stats.get("battery_mv", 0)) - uptime_secs = int(stats.get("uptime_secs", 0)) - errors = int(stats.get("errors", 0)) - queue_len = min(255, max(0, int(stats.get("queue_len", 0)))) - frame += struct.pack(" None: - # CMD_GET_ADVERT_PATH (42): reserved(1) + pub_key(32). Return inbound path from advert_paths (path_cache). - # Firmware: RESP_CODE_ADVERT_PATH(1) + recv_timestamp(4 LE) + path_len(1) + path(path_len) - if len(data) < 1 + PUB_KEY_SIZE: - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - pub_key = data[1 : 1 + PUB_KEY_SIZE] - prefix = pub_key[:7] - found = self.bridge.get_advert_path(prefix) if getattr(self.bridge, "get_advert_path", None) else None - if not found: - self._write_err(ERR_CODE_NOT_FOUND) - return - path_bytes = getattr(found, "path", None) or b"" - if not isinstance(path_bytes, bytes): - path_bytes = bytes(path_bytes) - path_len = min(len(path_bytes), MAX_PATH_SIZE) - recv_ts = getattr(found, "recv_timestamp", 0) - frame = bytes([RESP_CODE_ADVERT_PATH]) + struct.pack(" None: - ok = self.bridge.import_contact(data) - self._write_ok() if ok else self._write_err(ERR_CODE_ILLEGAL_ARG) - - async def _cmd_get_channel(self, data: bytes) -> None: - # Payload: channel index (1 byte), or empty for "get full list" (some apps send - # one request with no payload to receive all channels in one go). - channel_idx = data[0] if len(data) >= 1 else 0 - get_full_list = len(data) == 0 - max_channels_val = getattr( - getattr(self.bridge, "channels", None), "max_channels", 40 - ) - logger.debug( - f"CMD_GET_CHANNEL: idx={channel_idx}, data_len={len(data)}, get_full_list={get_full_list}" - ) - - # Frame format per firmware & meshcore_py: code(1) + channel_idx(1) + name(32) + secret(16) - def _channel_info_frame(idx: int, ch) -> bytes: - if ch is None: - name = b"\x00" * 32 - secret = b"\x00" * 16 - else: - name = ch.name.encode("utf-8", errors="replace")[:32].ljust( - 32, b"\x00" - ) - # Firmware and meshcore_py use 16-byte (128-bit) secret in the frame - secret = (ch.secret[:16] if ch.secret else b"\x00" * 16).ljust(16, b"\x00") - return bytes([RESP_CODE_CHANNEL_INFO, idx]) + name + secret - - if get_full_list: - for idx in range(max_channels_val): - ch = self.bridge.get_channel(idx) - frame = _channel_info_frame(idx, ch) - self._write_frame(frame) - logger.debug(f"CMD_GET_CHANNEL: sent full list ({max_channels_val} slots)") - return - - if channel_idx < 0 or channel_idx >= max_channels_val: - logger.debug(f"CMD_GET_CHANNEL: channel {channel_idx} out of range") - self._write_err(ERR_CODE_NOT_FOUND) - return - ch = self.bridge.get_channel(channel_idx) - if ch is None: - logger.debug(f"CMD_GET_CHANNEL: returning empty slot {channel_idx}") - else: - logger.debug(f"CMD_GET_CHANNEL: returning {ch.name!r}, secret_len=16") - frame = _channel_info_frame(channel_idx, ch) - self._write_frame(frame) - - async def _cmd_set_channel(self, data: bytes) -> None: - # MeshCore format: channel_idx(1) + name(32) + secret(32) or secret_hex(64) - logger.debug(f"CMD_SET_CHANNEL: data_len={len(data)}, data_hex={data[:50].hex()}...") - if len(data) < 34: # minimum: idx + name(32) + at least 1 byte secret - logger.debug(f"CMD_SET_CHANNEL: rejected (len {len(data)} < 34)") - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - channel_idx = data[0] - name_raw = data[1:33] - name = name_raw.split(b"\x00")[0].decode("utf-8", errors="replace").strip() - if len(data) >= 97: - # Hex secret: 64 hex chars = 32 bytes - try: - secret = bytes.fromhex(data[33:97].decode("ascii")) - logger.debug(f"CMD_SET_CHANNEL: parsed hex secret, len={len(secret)}") - except (ValueError, UnicodeDecodeError) as e: - logger.debug(f"CMD_SET_CHANNEL: hex secret parse failed: {e}") - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - elif len(data) >= 65: - # Binary secret: 32 bytes (MeshCore DataStore format) - secret = data[33:65] - logger.debug(f"CMD_SET_CHANNEL: parsed 32-byte binary secret") - elif len(data) >= 49: - # Legacy: 16-byte binary secret - secret = data[33:49] - logger.debug(f"CMD_SET_CHANNEL: parsed 16-byte binary secret") - else: - logger.debug(f"CMD_SET_CHANNEL: rejected (len {len(data)} not in 49/65/97)") - self._write_err(ERR_CODE_ILLEGAL_ARG) - return - logger.debug(f"CMD_SET_CHANNEL: idx={channel_idx}, name={name!r}, secret_len={len(secret)}") - ok = self.bridge.set_channel(channel_idx, name, secret) - if ok and self.sqlite_handler: - self._save_channels() - logger.debug(f"CMD_SET_CHANNEL: set_channel ok={ok}") - - self._write_ok() if ok else self._write_err(ERR_CODE_NOT_FOUND) - def _save_channels(self) -> None: """Persist channels to SQLite.""" if not self.sqlite_handler: return channels = [] - max_ch = getattr( - getattr(self.bridge, "channels", None), "max_channels", 40 - ) + max_ch = getattr(getattr(self.bridge, "channels", None), "max_channels", 40) for idx in range(max_ch): ch = self.bridge.get_channel(idx) if ch is not None: - channels.append({ - "channel_idx": idx, - "name": ch.name, - "secret": ch.secret, - }) + channels.append( + { + "channel_idx": idx, + "name": ch.name, + "secret": ch.secret, + } + ) self.sqlite_handler.companion_save_channels(self.companion_hash, channels) diff --git a/repeater/config.py b/repeater/config.py index 5bd9ffa..cb6b98b 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -49,7 +49,7 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]: "model": letsmesh_config.get("model", "PyMC-Repeater"), "disallowed_packet_types": disallowed_hex, "email": letsmesh_config.get("email", ""), - "owner": letsmesh_config.get("owner", "") + "owner": letsmesh_config.get("owner", ""), } @@ -107,14 +107,14 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None) # Create backup of existing config config_file = Path(config_path) if config_file.exists(): - backup_path = config_file.with_suffix('.yaml.backup') + backup_path = config_file.with_suffix(".yaml.backup") config_file.rename(backup_path) logger.info(f"Created backup at {backup_path}") - + # Save new config - with open(config_path, 'w') as f: + with open(config_path, "w") as f: yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) - + logger.info(f"Saved configuration to {config_path}") return True @@ -252,7 +252,9 @@ def get_radio_for_board(board_config: dict): from pymc_core.hardware.kiss_modem_wrapper import KissModemWrapper except ImportError: try: - from pymc_core.hardware.kiss_serial_wrapper import KissSerialWrapper as KissModemWrapper + from pymc_core.hardware.kiss_serial_wrapper import ( + KissSerialWrapper as KissModemWrapper, + ) except ImportError: raise RuntimeError( "KISS modem support requires pyMC_core with KISS support. " diff --git a/repeater/config_manager.py b/repeater/config_manager.py index e2457a3..a30003c 100644 --- a/repeater/config_manager.py +++ b/repeater/config_manager.py @@ -2,19 +2,20 @@ from __future__ import annotations import logging import os -import yaml from typing import Any, Dict, List, Optional +import yaml + logger = logging.getLogger("ConfigManager") class ConfigManager: """Manages configuration persistence and live updates to the daemon.""" - + def __init__(self, config_path: str, config: dict, daemon_instance=None): """ Initialize ConfigManager. - + Args: config_path: Path to the YAML config file config: Reference to the config dictionary @@ -23,7 +24,7 @@ class ConfigManager: self.config_path = config_path self.config = config self.daemon = daemon_instance - + def save_to_file(self) -> tuple[bool, str]: """ Save current config to YAML file. @@ -35,7 +36,7 @@ class ConfigManager: dirpath = os.path.dirname(self.config_path) if dirpath: os.makedirs(dirpath, exist_ok=True) - with open(self.config_path, 'w') as f: + with open(self.config_path, "w") as f: # Use safe_dump with explicit width to prevent line wrapping # Setting width to a very large number prevents truncation of long strings like identity keys yaml.safe_dump( @@ -45,7 +46,7 @@ class ConfigManager: indent=2, width=1000000, # Very large width to prevent any line wrapping sort_keys=False, - allow_unicode=True + allow_unicode=True, ) logger.info(f"Configuration saved to {self.config_path}") return True, "" @@ -53,73 +54,75 @@ class ConfigManager: msg = f"Failed to save config to {self.config_path}: {e}" logger.error(msg, exc_info=True) return False, str(e) - + def live_update_daemon(self, sections: Optional[List[str]] = None) -> bool: """ Apply configuration changes to the running daemon's in-memory config. - + Args: sections: List of config sections to update (e.g., ['repeater', 'delays']). If None, updates all common sections. - + Returns: True if live update was successful, False otherwise """ - if not self.daemon or not hasattr(self.daemon, 'config'): + if not self.daemon or not hasattr(self.daemon, "config"): logger.warning("Daemon not available for live update") return False - + try: daemon_config = self.daemon.config - + # Default sections to update if not specified if sections is None: - sections = ['repeater', 'delays', 'radio', 'acl', 'identities'] - + sections = ["repeater", "delays", "radio", "acl", "identities"] + # Update each section for section in sections: if section in self.config: if section not in daemon_config: daemon_config[section] = {} - + # Deep copy the section to avoid reference issues if isinstance(self.config[section], dict): daemon_config[section].update(self.config[section]) else: daemon_config[section] = self.config[section] - + logger.debug(f"Live updated daemon config section: {section}") - + logger.info(f"Live updated daemon config sections: {', '.join(sections)}") - + # Also reload runtime config in RepeaterHandler if delays or repeater sections changed - if self.daemon and hasattr(self.daemon, 'repeater_handler'): - if any(s in ['delays', 'repeater'] for s in sections): - if hasattr(self.daemon.repeater_handler, 'reload_runtime_config'): + if self.daemon and hasattr(self.daemon, "repeater_handler"): + if any(s in ["delays", "repeater"] for s in sections): + if hasattr(self.daemon.repeater_handler, "reload_runtime_config"): self.daemon.repeater_handler.reload_runtime_config() logger.info("Reloaded RepeaterHandler runtime config") - + return True - + except Exception as e: logger.error(f"Failed to live update daemon config: {e}", exc_info=True) return False - - def update_and_save(self, - updates: Dict[str, Any], - live_update: bool = True, - live_update_sections: Optional[List[str]] = None) -> Dict[str, Any]: + + def update_and_save( + self, + updates: Dict[str, Any], + live_update: bool = True, + live_update_sections: Optional[List[str]] = None, + ) -> Dict[str, Any]: """ Apply updates to config, save to file, and optionally live update daemon. - + This is the main method that should be used by both mesh_cli and api_endpoints. - + Args: updates: Dictionary of config updates in nested format. Example: {"repeater": {"node_name": "NewName"}, "delays": {"tx_delay_factor": 1.5}} live_update: Whether to apply changes to running daemon immediately live_update_sections: Specific sections to live update. If None, auto-detects from updates. - + Returns: Dict with keys: - success: bool - Whether operation succeeded @@ -127,23 +130,19 @@ class ConfigManager: - live_updated: bool - Whether daemon was live updated - error: str (optional) - Error message if failed """ - result = { - "success": False, - "saved": False, - "live_updated": False - } - + result = {"success": False, "saved": False, "live_updated": False} + try: # Apply updates to config for section, values in updates.items(): if section not in self.config: self.config[section] = {} - + if isinstance(values, dict): self.config[section].update(values) else: self.config[section] = values - + # Save to file saved, err = self.save_to_file() result["saved"] = saved @@ -151,39 +150,39 @@ class ConfigManager: if not result["saved"]: result["error"] = err or "Failed to save config to file" return result - + # Live update daemon if requested if live_update: # Auto-detect sections if not specified if live_update_sections is None: live_update_sections = list(updates.keys()) - + result["live_updated"] = self.live_update_daemon(live_update_sections) - + result["success"] = result["saved"] return result - + except Exception as e: logger.error(f"Error in update_and_save: {e}", exc_info=True) result["error"] = str(e) return result - + def update_nested(self, path: str, value: Any, live_update: bool = True) -> Dict[str, Any]: """ Update a nested config value using dot notation. - + Convenience method for simple updates like "repeater.node_name" = "NewName" - + Args: path: Dot-separated path to config value (e.g., "repeater.node_name") value: Value to set live_update: Whether to apply changes to running daemon - + Returns: Result dict from update_and_save """ - parts = path.split('.') - + parts = path.split(".") + if len(parts) == 1: # Top-level key updates = {parts[0]: value} @@ -202,26 +201,26 @@ class ConfigManager: current[part] = {} current = current[part] current[parts[-1]] = value - + # Determine which section to live update section = parts[0] - + return self.update_and_save( updates=updates, live_update=live_update, - live_update_sections=[section] if live_update else None + live_update_sections=[section] if live_update else None, ) - + def get_status(self) -> Dict[str, Any]: """ Get status information about the ConfigManager. - + Returns: Dict with config file path, existence, daemon availability """ return { "config_path": self.config_path, "config_exists": os.path.exists(self.config_path), - "daemon_available": self.daemon is not None and hasattr(self.daemon, 'config'), - "config_sections": list(self.config.keys()) if self.config else [] + "daemon_available": self.daemon is not None and hasattr(self.daemon, "config"), + "config_sections": list(self.config.keys()) if self.config else [], } diff --git a/repeater/data_acquisition/__init__.py b/repeater/data_acquisition/__init__.py index 5df598e..14a0e13 100644 --- a/repeater/data_acquisition/__init__.py +++ b/repeater/data_acquisition/__init__.py @@ -1,6 +1,6 @@ -from .sqlite_handler import SQLiteHandler -from .rrdtool_handler import RRDToolHandler from .mqtt_handler import MQTTHandler +from .rrdtool_handler import RRDToolHandler +from .sqlite_handler import SQLiteHandler from .storage_collector import StorageCollector -__all__ = ['SQLiteHandler', 'RRDToolHandler', 'MQTTHandler', 'StorageCollector'] \ No newline at end of file +__all__ = ["SQLiteHandler", "RRDToolHandler", "MQTTHandler", "StorageCollector"] diff --git a/repeater/data_acquisition/hardware_stats.py b/repeater/data_acquisition/hardware_stats.py index a19ffc0..465478e 100644 --- a/repeater/data_acquisition/hardware_stats.py +++ b/repeater/data_acquisition/hardware_stats.py @@ -5,13 +5,14 @@ KISS - Keep It Simple Stupid approach. try: import psutil + PSUTIL_AVAILABLE = True except ImportError: PSUTIL_AVAILABLE = False psutil = None -import time import logging +import time logger = logging.getLogger("HardwareStats") @@ -26,10 +27,8 @@ class HardwareStatsCollector: if not PSUTIL_AVAILABLE: logger.error("psutil not available - cannot collect hardware stats") - return { - "error": "psutil library not available - cannot collect hardware statistics" - } - + return {"error": "psutil library not available - cannot collect hardware statistics"} + try: # Get current timestamp now = time.time() @@ -42,10 +41,10 @@ class HardwareStatsCollector: # Memory stats memory = psutil.virtual_memory() - + # Disk stats - disk = psutil.disk_usage('/') - + disk = psutil.disk_usage("/") + # Network stats (total across all interfaces) net_io = psutil.net_io_counters() @@ -79,48 +78,39 @@ class HardwareStatsCollector: "usage_percent": cpu_percent, "count": cpu_count, "frequency": cpu_freq.current if cpu_freq else 0, - "load_avg": { - "1min": load_avg[0], - "5min": load_avg[1], - "15min": load_avg[2] - } + "load_avg": {"1min": load_avg[0], "5min": load_avg[1], "15min": load_avg[2]}, }, "memory": { "total": memory.total, "available": memory.available, "used": memory.used, - "usage_percent": memory.percent + "usage_percent": memory.percent, }, "disk": { "total": disk.total, "used": disk.used, "free": disk.free, - "usage_percent": round((disk.used / disk.total) * 100, 1) + "usage_percent": round((disk.used / disk.total) * 100, 1), }, "network": { "bytes_sent": net_io.bytes_sent, "bytes_recv": net_io.bytes_recv, "packets_sent": net_io.packets_sent, - "packets_recv": net_io.packets_recv + "packets_recv": net_io.packets_recv, }, - "system": { - "uptime": system_uptime, - "boot_time": boot_time - } + "system": {"uptime": system_uptime, "boot_time": boot_time}, } - + # Add temperatures if available if temperatures: stats["temperatures"] = temperatures return stats - + except Exception as e: logger.error(f"Error collecting hardware stats: {e}") - return { - "error": str(e) - } - + return {"error": str(e)} + def get_processes_summary(self, limit=10): """ Get top processes by CPU and memory usage. @@ -131,44 +121,39 @@ class HardwareStatsCollector: return { "processes": [], "total_processes": 0, - "error": "psutil library not available - cannot collect process statistics" + "error": "psutil library not available - cannot collect process statistics", } - + try: processes = [] - + # Get all processes - for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent', 'memory_info']): + for proc in psutil.process_iter( + ["pid", "name", "cpu_percent", "memory_percent", "memory_info"] + ): try: pinfo = proc.info # Calculate memory in MB memory_mb = 0 - if pinfo['memory_info']: - memory_mb = pinfo['memory_info'].rss / 1024 / 1024 # RSS in MB - + if pinfo["memory_info"]: + memory_mb = pinfo["memory_info"].rss / 1024 / 1024 # RSS in MB + process_data = { - "pid": pinfo['pid'], - "name": pinfo['name'] or 'Unknown', - "cpu_percent": pinfo['cpu_percent'] or 0.0, - "memory_percent": pinfo['memory_percent'] or 0.0, - "memory_mb": round(memory_mb, 1) + "pid": pinfo["pid"], + "name": pinfo["name"] or "Unknown", + "cpu_percent": pinfo["cpu_percent"] or 0.0, + "memory_percent": pinfo["memory_percent"] or 0.0, + "memory_mb": round(memory_mb, 1), } processes.append(process_data) except (psutil.NoSuchProcess, psutil.AccessDenied): pass - + # Sort by CPU usage and get top processes - top_processes = sorted(processes, key=lambda x: x['cpu_percent'], reverse=True)[:limit] - - return { - "processes": top_processes, - "total_processes": len(processes) - } - + top_processes = sorted(processes, key=lambda x: x["cpu_percent"], reverse=True)[:limit] + + return {"processes": top_processes, "total_processes": len(processes)} + except Exception as e: logger.error(f"Error collecting process stats: {e}") - return { - "processes": [], - "total_processes": 0, - "error": str(e) - } \ No newline at end of file + return {"processes": [], "total_processes": 0, "error": str(e)} diff --git a/repeater/data_acquisition/letsmesh_handler.py b/repeater/data_acquisition/letsmesh_handler.py index 872640c..452a4ed 100644 --- a/repeater/data_acquisition/letsmesh_handler.py +++ b/repeater/data_acquisition/letsmesh_handler.py @@ -1,24 +1,27 @@ +import base64 +import binascii import json import logging -import binascii -import base64 -import paho.mqtt.client as mqtt import threading +from datetime import UTC, datetime, timedelta +from typing import Callable, Dict, List, Optional -from datetime import datetime, timedelta, UTC +import paho.mqtt.client as mqtt from nacl.signing import SigningKey -from typing import Callable, Optional, List, Dict -from .. import __version__ +from .. import __version__ # Try to import paho-mqtt error code mappings try: from paho.mqtt.reasoncodes import ReasonCode + HAS_REASON_CODES = True except ImportError: HAS_REASON_CODES = False logger = logging.getLogger("LetsMeshHandler") + + # -------------------------------------------------------------------- # Helper: Base64URL without padding # -------------------------------------------------------------------- @@ -117,7 +120,7 @@ class _BrokerConnection: payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode()) signing_input = f"{header_b64}.{payload_b64}".encode() - + # Sign using LocalIdentity (supports both standard and firmware keys) try: signature = self.local_identity.sign(signing_input) @@ -126,10 +129,10 @@ class _BrokerConnection: logging.error(f" - public_key: {self.public_key}") logging.error(f" - signing_input length: {len(signing_input)}") raise - + signature_hex = binascii.hexlify(signature).decode() token = f"{header_b64}.{payload_b64}.{signature_hex}" - + logging.debug(f"JWT token generated for {self.broker['name']}: {token[:50]}...") return token @@ -152,7 +155,7 @@ class _BrokerConnection: """MQTT disconnection callback""" was_running = self._running self._running = False - + if rc != 0: # Unexpected disconnect error_msg = get_mqtt_error_message(rc, is_disconnect=True) logging.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}") @@ -160,7 +163,7 @@ class _BrokerConnection: self._schedule_reconnect(reason=error_msg) else: logging.info(f"Clean disconnect from {self.broker['name']}") - + if self._on_disconnect_callback: self._on_disconnect_callback(self.broker["name"]) @@ -168,29 +171,31 @@ class _BrokerConnection: """Schedule reconnection with exponential backoff""" if self._reconnect_timer: self._reconnect_timer.cancel() - + # Exponential backoff: 5s, 10s, 20s, 40s, 80s, up to max - delay = min(5 * (2 ** self._reconnect_attempts), self._max_reconnect_delay) + delay = min(5 * (2**self._reconnect_attempts), self._max_reconnect_delay) self._reconnect_attempts += 1 - - logging.info(f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts}, reason: {reason})") + + logging.info( + f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts}, reason: {reason})" + ) self._reconnect_timer = threading.Timer(delay, lambda: self._attempt_reconnect(reason)) self._reconnect_timer.daemon = True self._reconnect_timer.start() - + def _attempt_reconnect(self, reason: str = "connection lost"): """Attempt to reconnect to broker with fresh JWT""" try: logging.info(f"Attempting reconnection to {self.broker['name']} (reason: {reason})...") - + # Stop the loop if it's still running (websocket mode requires clean restart) try: self.client.loop_stop() except: pass - + self._set_jwt_credentials() - + # Reconnect and restart loop self.client.connect(self.broker["host"], self.broker["port"], keepalive=60) self.client.loop_start() @@ -198,7 +203,7 @@ class _BrokerConnection: except Exception as e: logging.error(f"Reconnection failed for {self.broker['name']}: {e}") self._schedule_reconnect() # Try again later - + def _set_jwt_credentials(self): """Set JWT token credentials before connecting (CONNECT handshake only)""" try: @@ -242,7 +247,7 @@ class _BrokerConnection: """Disconnect from broker""" self._running = False self._loop_running = False - + # Cancel any pending timers if self._reconnect_timer: self._reconnect_timer.cancel() @@ -250,7 +255,7 @@ class _BrokerConnection: if self._jwt_refresh_timer: self._jwt_refresh_timer.cancel() self._jwt_refresh_timer = None - + self.client.loop_stop() self.client.disconnect() logging.info(f"Disconnected from {self.broker['name']}") @@ -265,7 +270,7 @@ class _BrokerConnection: def is_connected(self) -> bool: """Check if connection is active""" return self._running - + def has_pending_reconnect(self) -> bool: """Check if a reconnection is scheduled""" return self._reconnect_timer is not None and self._reconnect_timer.is_alive() @@ -281,19 +286,19 @@ class _BrokerConnection: stagger_offset = self.broker_index * 0.05 refresh_threshold = 0.80 + stagger_offset return elapsed >= expiry_seconds * refresh_threshold - + def _schedule_jwt_refresh(self): """Schedule proactive JWT refresh before token expires""" if self._jwt_refresh_timer: self._jwt_refresh_timer.cancel() - + expiry_seconds = self.jwt_expiry_minutes * 60 # Stagger refresh by 5% per broker to prevent simultaneous disconnects # Broker 0: 80%, Broker 1: 85%, Broker 2: 90%, etc. stagger_offset = self.broker_index * 0.05 refresh_threshold = 0.80 + stagger_offset refresh_delay = expiry_seconds * refresh_threshold - + logging.info( f"JWT refresh scheduled for {self.broker['name']} in {refresh_delay:.0f}s " f"({refresh_threshold*100:.0f}% of {self.jwt_expiry_minutes}min token lifetime)" @@ -301,12 +306,12 @@ class _BrokerConnection: self._jwt_refresh_timer = threading.Timer(refresh_delay, self.reconnect_for_token_expiry) self._jwt_refresh_timer.daemon = True self._jwt_refresh_timer.start() - + def reconnect_for_token_expiry(self): """Proactively reconnect with new JWT before current one expires""" if not self._running: return - + logging.info(f"JWT token expiring soon for {self.broker['name']}, refreshing...") self._running = False self._jwt_refresh_timer = None @@ -330,7 +335,7 @@ class MeshCoreToMqttJwtPusher: # Store local identity and get public key self.local_identity = local_identity public_key = local_identity.get_public_key().hex().upper() - + # Extract values from config from ..config import get_node_info @@ -356,9 +361,11 @@ class MeshCoreToMqttJwtPusher: elif broker_index is None or broker_index == -1: # Connect to all built-in brokers + additional ones self.brokers = LETSMESH_BROKERS.copy() - logging.info(f"Multi-broker mode: connecting to all {len(LETSMESH_BROKERS)} built-in brokers") + logging.info( + f"Multi-broker mode: connecting to all {len(LETSMESH_BROKERS)} built-in brokers" + ) else: - + if broker_index >= len(LETSMESH_BROKERS): raise ValueError(f"Invalid broker_index {broker_index}") self.brokers = [LETSMESH_BROKERS[broker_index]] @@ -372,7 +379,7 @@ class MeshCoreToMqttJwtPusher: logging.info(f"Added custom broker: {broker_config['name']}") else: logging.warning(f"Skipping invalid broker config: {broker_config}") - + # Validate that we have at least one broker if not self.brokers: raise ValueError( @@ -432,7 +439,7 @@ class MeshCoreToMqttJwtPusher: # Check if all connections are down AND none have pending reconnects all_down = all(not conn.is_connected() for conn in self.connections) any_reconnecting = any(conn.has_pending_reconnect() for conn in self.connections) - + if all_down and not any_reconnecting: logging.warning("All broker connections lost with no pending reconnects") elif all_down: @@ -454,7 +461,7 @@ class MeshCoreToMqttJwtPusher: timer.start() except Exception as e: logging.error(f"Failed to connect to {conn.broker['name']}: {e}") - + def _delayed_connect(self, conn): """Connect a broker after a delay (called by timer)""" try: @@ -471,6 +478,7 @@ class MeshCoreToMqttJwtPusher: self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config) import time + time.sleep(0.5) # Give time for messages to be sent # Disconnect all brokers @@ -493,7 +501,7 @@ class MeshCoreToMqttJwtPusher: state="online", origin=self.node_name, radio_config=self.radio_config ) logging.debug(f"Status heartbeat sent (next in {self.status_interval}s)") - + time.sleep(self.status_interval) except Exception as e: logging.error(f"Status heartbeat error: {e}") @@ -579,14 +587,15 @@ class MeshCoreToMqttJwtPusher: # Helper Functions # ==================================================================== + def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: """ Get human-readable MQTT error message. - + Args: rc: Return code from paho-mqtt is_disconnect: True if from on_disconnect, False if from on_connect - + Returns: Human-readable error message """ @@ -596,7 +605,7 @@ def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: return f"{reason.name}: {reason.value}" except (ValueError, AttributeError): pass - + # Fallback to manual mappings connect_errors = { 0: "connection accepted", @@ -607,7 +616,7 @@ def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: 5: "not authorized (JWT signature/format invalid)", 6: "reserved error code", } - + disconnect_errors = { 0: "normal disconnect", 1: "unacceptable protocol version", @@ -618,7 +627,6 @@ def get_mqtt_error_message(rc: int, is_disconnect: bool = False) -> str: 16: "connection lost / protocol error", 17: "client timeout", } - + error_dict = disconnect_errors if is_disconnect else connect_errors return error_dict.get(rc, f"unknown error code {rc}") - diff --git a/repeater/data_acquisition/mqtt_handler.py b/repeater/data_acquisition/mqtt_handler.py index 1baf9e7..c08810d 100644 --- a/repeater/data_acquisition/mqtt_handler.py +++ b/repeater/data_acquisition/mqtt_handler.py @@ -1,10 +1,11 @@ import json import logging import ssl -from typing import Dict, Any, Optional +from typing import Any, Dict, Optional try: import paho.mqtt.client as mqtt + MQTT_AVAILABLE = True except ImportError: MQTT_AVAILABLE = False @@ -102,17 +103,17 @@ class MQTTHandler: try: base_topic = self.mqtt_config.get("base_topic", "meshcore/repeater") topic = f"{base_topic}/{self.node_name}/{record_type}" - + if record_type == "packet": packet_record = PacketRecord.from_packet_record( - record, - origin=self.node_name, - origin_id=self.node_id + record, origin=self.node_name, origin_id=self.node_id ) if not packet_record: - logger.debug("Skipping MQTT publish: packet missing required data for PacketRecord") + logger.debug( + "Skipping MQTT publish: packet missing required data for PacketRecord" + ) return - + payload = packet_record.to_dict() logger.debug("Publishing packet using PacketRecord format") else: diff --git a/repeater/data_acquisition/rrdtool_handler.py b/repeater/data_acquisition/rrdtool_handler.py index 8a3a442..e89b4ed 100644 --- a/repeater/data_acquisition/rrdtool_handler.py +++ b/repeater/data_acquisition/rrdtool_handler.py @@ -1,10 +1,11 @@ import logging import time from pathlib import Path -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional try: import rrdtool + RRDTOOL_AVAILABLE = True except ImportError: RRDTOOL_AVAILABLE = False @@ -23,17 +24,18 @@ class RRDToolHandler: if not self.available: logger.warning("RRDTool not available - skipping RRD initialization") return - + if self.rrd_path.exists(): logger.info(f"RRD database exists: {self.rrd_path}") return - + try: rrdtool.create( str(self.rrd_path), - "--step", "60", - "--start", str(int(time.time() - 60)), - + "--step", + "60", + "--start", + str(int(time.time() - 60)), "DS:rx_count:COUNTER:120:0:U", "DS:tx_count:COUNTER:120:0:U", "DS:drop_count:COUNTER:120:0:U", @@ -42,7 +44,6 @@ class RRDToolHandler: "DS:avg_length:GAUGE:120:0:256", "DS:avg_score:GAUGE:120:0:1", "DS:neighbor_count:GAUGE:120:0:U", - "DS:type_0:COUNTER:120:0:U", "DS:type_1:COUNTER:120:0:U", "DS:type_2:COUNTER:120:0:U", @@ -60,25 +61,24 @@ class RRDToolHandler: "DS:type_14:COUNTER:120:0:U", "DS:type_15:COUNTER:120:0:U", "DS:type_other:COUNTER:120:0:U", - "RRA:AVERAGE:0.5:1:10080", "RRA:AVERAGE:0.5:5:8640", "RRA:AVERAGE:0.5:60:8760", "RRA:MAX:0.5:1:10080", - "RRA:MIN:0.5:1:10080" + "RRA:MIN:0.5:1:10080", ) logger.info(f"RRD database created: {self.rrd_path}") - + except Exception as e: logger.error(f"Failed to create RRD database: {e}") def update_packet_metrics(self, record: dict, cumulative_counts: dict): if not self.available or not self.rrd_path.exists(): return - + try: timestamp = int(record.get("timestamp", time.time())) - + try: info = rrdtool.info(str(self.rrd_path)) last_update = int(info.get("last_update", timestamp - 60)) @@ -86,104 +86,114 @@ class RRDToolHandler: return except Exception as e: logger.debug(f"Failed to get RRD info for packet update: {e}") - + rx_total = cumulative_counts.get("rx_total", 0) tx_total = cumulative_counts.get("tx_total", 0) drop_total = cumulative_counts.get("drop_total", 0) type_counts = cumulative_counts.get("type_counts", {}) - + type_values = [] for i in range(16): type_values.append(str(type_counts.get(f"type_{i}", 0))) type_values.append(str(type_counts.get("type_other", 0))) - + # Handle None values for TX packets - use 'U' (unknown) for RRD - rssi = record.get('rssi') - snr = record.get('snr') - score = record.get('score') - - rssi_val = 'U' if rssi is None else str(rssi) - snr_val = 'U' if snr is None else str(snr) - score_val = 'U' if score is None else str(score) - length_val = str(record.get('length', 0)) - - basic_values = f"{timestamp}:{rx_total}:{tx_total}:{drop_total}:" \ - f"{rssi_val}:{snr_val}:{length_val}:{score_val}:" \ - f"U" - + rssi = record.get("rssi") + snr = record.get("snr") + score = record.get("score") + + rssi_val = "U" if rssi is None else str(rssi) + snr_val = "U" if snr is None else str(snr) + score_val = "U" if score is None else str(score) + length_val = str(record.get("length", 0)) + + basic_values = ( + f"{timestamp}:{rx_total}:{tx_total}:{drop_total}:" + f"{rssi_val}:{snr_val}:{length_val}:{score_val}:" + f"U" + ) + type_values_str = ":".join(type_values) values = f"{basic_values}:{type_values_str}" - + rrdtool.update(str(self.rrd_path), values) - + except Exception as e: logger.error(f"Failed to update RRD packet metrics: {e}") logger.debug(f"RRD packet update failed - record: {record}") - def get_data(self, start_time: Optional[int] = None, end_time: Optional[int] = None, - resolution: str = "average") -> Optional[dict]: + def get_data( + self, + start_time: Optional[int] = None, + end_time: Optional[int] = None, + resolution: str = "average", + ) -> Optional[dict]: if not self.available or not self.rrd_path.exists(): - logger.error(f"RRD not available: available={self.available}, rrd_path exists={self.rrd_path.exists()}") + logger.error( + f"RRD not available: available={self.available}, rrd_path exists={self.rrd_path.exists()}" + ) return None - + try: if end_time is None: end_time = int(time.time()) if start_time is None: start_time = end_time - (24 * 3600) - + fetch_result = rrdtool.fetch( str(self.rrd_path), resolution.upper(), - "--start", str(start_time), - "--end", str(end_time) + "--start", + str(start_time), + "--end", + str(end_time), ) - + if not fetch_result: logger.error("RRD fetch returned None") return None - + (start, end, step), data_sources, data_points = fetch_result - + if not data_points: logger.warning("No data points returned from RRD fetch") - + result = { "start_time": start, "end_time": end, "step": step, "data_sources": data_sources, "packet_types": {}, - "metrics": {} + "metrics": {}, } - + timestamps = [] current_time = start - + for ds in data_sources: - if ds.startswith('type_'): - if 'packet_types' not in result: - result['packet_types'] = {} - result['packet_types'][ds] = [] + if ds.startswith("type_"): + if "packet_types" not in result: + result["packet_types"] = {} + result["packet_types"][ds] = [] else: - result['metrics'][ds] = [] - + result["metrics"][ds] = [] + for point in data_points: timestamps.append(current_time) - + for i, value in enumerate(point): ds_name = data_sources[i] - if ds_name.startswith('type_'): - result['packet_types'][ds_name].append(value) + if ds_name.startswith("type_"): + result["packet_types"][ds_name].append(value) else: - result['metrics'][ds_name].append(value) - + result["metrics"][ds_name].append(value) + current_time += step - - result['timestamps'] = timestamps - + + result["timestamps"] = timestamps + return result - + except Exception as e: logger.error(f"Failed to get RRD data: {e}") return None @@ -192,65 +202,65 @@ class RRDToolHandler: try: end_time = int(time.time()) start_time = end_time - (hours * 3600) - + rrd_data = self.get_data(start_time, end_time) - if not rrd_data or 'packet_types' not in rrd_data: + if not rrd_data or "packet_types" not in rrd_data: logger.warning(f"No RRD data available") return None - + type_totals = {} packet_type_names = { - 'type_0': 'Request (REQ)', - 'type_1': 'Response (RESPONSE)', - 'type_2': 'Plain Text Message (TXT_MSG)', - 'type_3': 'Acknowledgment (ACK)', - 'type_4': 'Node Advertisement (ADVERT)', - 'type_5': 'Group Text Message (GRP_TXT)', - 'type_6': 'Group Datagram (GRP_DATA)', - 'type_7': 'Anonymous Request (ANON_REQ)', - 'type_8': 'Returned Path (PATH)', - 'type_9': 'Trace (TRACE)', - 'type_10': 'Multi-part Packet', - 'type_11': 'Control Packet Data', - 'type_12': 'Reserved Type 12', - 'type_13': 'Reserved Type 13', - 'type_14': 'Reserved Type 14', - 'type_15': 'Custom Packet (RAW_CUSTOM)', - 'type_other': 'Other Types (>15)' + "type_0": "Request (REQ)", + "type_1": "Response (RESPONSE)", + "type_2": "Plain Text Message (TXT_MSG)", + "type_3": "Acknowledgment (ACK)", + "type_4": "Node Advertisement (ADVERT)", + "type_5": "Group Text Message (GRP_TXT)", + "type_6": "Group Datagram (GRP_DATA)", + "type_7": "Anonymous Request (ANON_REQ)", + "type_8": "Returned Path (PATH)", + "type_9": "Trace (TRACE)", + "type_10": "Multi-part Packet", + "type_11": "Control Packet Data", + "type_12": "Reserved Type 12", + "type_13": "Reserved Type 13", + "type_14": "Reserved Type 14", + "type_15": "Custom Packet (RAW_CUSTOM)", + "type_other": "Other Types (>15)", } - + total_valid_points = 0 - for type_key, data_points in rrd_data['packet_types'].items(): + for type_key, data_points in rrd_data["packet_types"].items(): valid_points = [p for p in data_points if p is not None] total_valid_points += len(valid_points) - + if total_valid_points < 10: logger.warning(f"RRD data too sparse ({total_valid_points} valid points)") return None - - for type_key, data_points in rrd_data['packet_types'].items(): + + for type_key, data_points in rrd_data["packet_types"].items(): valid_points = [p for p in data_points if p is not None] - + if len(valid_points) >= 2: total = max(valid_points) - min(valid_points) elif len(valid_points) == 1: total = valid_points[0] else: total = 0 - + type_name = packet_type_names.get(type_key, type_key) type_totals[type_name] = max(0, total or 0) - + result = { "hours": hours, "packet_type_totals": type_totals, "total_packets": sum(type_totals.values()), "period": f"{hours} hours", - "data_source": "rrd" + "data_source": "rrd", } - + return result - + except Exception as e: logger.error(f"Failed to get packet type stats from RRD: {e}") - return None \ No newline at end of file + return None diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 883017b..812dbf6 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -1,14 +1,15 @@ +import base64 import json import logging +import secrets import sqlite3 import time -import secrets -import base64 from pathlib import Path -from typing import Optional, List, Dict, Any +from typing import Any, Dict, List, Optional logger = logging.getLogger("SQLiteHandler") + class SQLiteHandler: def __init__(self, storage_dir: Path): self.storage_dir = storage_dir @@ -19,7 +20,8 @@ class SQLiteHandler: def _init_database(self): try: with sqlite3.connect(self.sqlite_path) as conn: - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS packets ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, @@ -45,9 +47,11 @@ class SQLiteHandler: forwarded_path TEXT, raw_packet TEXT ) - """) - - conn.execute(""" + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS adverts ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, @@ -66,17 +70,21 @@ class SQLiteHandler: is_new_neighbor BOOLEAN NOT NULL, zero_hop BOOLEAN NOT NULL DEFAULT FALSE ) - """) - - conn.execute(""" + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS noise_floor ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, noise_floor_dbm REAL NOT NULL ) - """) - - conn.execute(""" + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS transport_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -88,9 +96,11 @@ class SQLiteHandler: updated_at REAL NOT NULL, FOREIGN KEY (parent_id) REFERENCES transport_keys(id) ) - """) - - conn.execute(""" + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -98,20 +108,34 @@ class SQLiteHandler: created_at REAL NOT NULL, last_used REAL ) - """) - - conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp)") + """ + ) + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp)" + ) conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_type ON packets(type)") conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_hash ON packets(packet_hash)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_transmitted ON packets(transmitted)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_adverts_timestamp ON adverts(timestamp)") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_packets_transmitted ON packets(transmitted)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_adverts_timestamp ON adverts(timestamp)" + ) conn.execute("CREATE INDEX IF NOT EXISTS idx_adverts_pubkey ON adverts(pubkey)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_noise_timestamp ON noise_floor(timestamp)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_transport_keys_name ON transport_keys(name)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_transport_keys_parent ON transport_keys(parent_id)") - + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_noise_timestamp ON noise_floor(timestamp)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_transport_keys_name ON transport_keys(name)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_transport_keys_parent ON transport_keys(parent_id)" + ) + # Room server tables - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS room_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_hash TEXT NOT NULL, @@ -122,9 +146,11 @@ class SQLiteHandler: txt_type INTEGER NOT NULL, created_at REAL NOT NULL ) - """) - - conn.execute(""" + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS room_client_sync ( id INTEGER PRIMARY KEY AUTOINCREMENT, room_hash TEXT NOT NULL, @@ -138,16 +164,25 @@ class SQLiteHandler: updated_at REAL NOT NULL, UNIQUE(room_hash, client_pubkey) ) - """) - - conn.execute("CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_hash, post_timestamp)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_room_messages_author ON room_messages(author_pubkey)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_room_client_sync_room ON room_client_sync(room_hash, client_pubkey)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_room_client_sync_pending ON room_client_sync(pending_ack_crc)") - + """ + ) + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_room_messages_room ON room_messages(room_hash, post_timestamp)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_room_messages_author ON room_messages(author_pubkey)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_room_client_sync_room ON room_client_sync(room_hash, client_pubkey)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_room_client_sync_pending ON room_client_sync(pending_ack_crc)" + ) + conn.commit() logger.info(f"SQLite database initialized: {self.sqlite_path}") - + except Exception as e: logger.error(f"Failed to initialize SQLite: {e}") @@ -156,83 +191,92 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: # Create migrations table if it doesn't exist - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS migrations ( id INTEGER PRIMARY KEY AUTOINCREMENT, migration_name TEXT NOT NULL UNIQUE, applied_at REAL NOT NULL ) - """) - + """ + ) + # Migration 1: Add zero_hop column to adverts table migration_name = "add_zero_hop_to_adverts" existing = conn.execute( "SELECT migration_name FROM migrations WHERE migration_name = ?", - (migration_name,) + (migration_name,), ).fetchone() - + if not existing: # Check if zero_hop column already exists cursor = conn.execute("PRAGMA table_info(adverts)") columns = [column[1] for column in cursor.fetchall()] - + if "zero_hop" not in columns: - conn.execute("ALTER TABLE adverts ADD COLUMN zero_hop BOOLEAN NOT NULL DEFAULT FALSE") + conn.execute( + "ALTER TABLE adverts ADD COLUMN zero_hop BOOLEAN NOT NULL DEFAULT FALSE" + ) logger.info("Added zero_hop column to adverts table") - + # Mark migration as applied conn.execute( "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", - (migration_name, time.time()) + (migration_name, time.time()), ) logger.info(f"Migration '{migration_name}' applied successfully") - + # Migration 2: Add LBT metrics columns to packets table migration_name = "add_lbt_metrics_to_packets" existing = conn.execute( "SELECT migration_name FROM migrations WHERE migration_name = ?", - (migration_name,) + (migration_name,), ).fetchone() - + if not existing: # Check if columns already exist cursor = conn.execute("PRAGMA table_info(packets)") columns = [column[1] for column in cursor.fetchall()] - + if "lbt_attempts" not in columns: - conn.execute("ALTER TABLE packets ADD COLUMN lbt_attempts INTEGER DEFAULT 0") + conn.execute( + "ALTER TABLE packets ADD COLUMN lbt_attempts INTEGER DEFAULT 0" + ) logger.info("Added lbt_attempts column to packets table") - + if "lbt_backoff_delays_ms" not in columns: conn.execute("ALTER TABLE packets ADD COLUMN lbt_backoff_delays_ms TEXT") logger.info("Added lbt_backoff_delays_ms column to packets table") - + if "lbt_channel_busy" not in columns: - conn.execute("ALTER TABLE packets ADD COLUMN lbt_channel_busy BOOLEAN DEFAULT FALSE") + conn.execute( + "ALTER TABLE packets ADD COLUMN lbt_channel_busy BOOLEAN DEFAULT FALSE" + ) logger.info("Added lbt_channel_busy column to packets table") - + # Mark migration as applied conn.execute( "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", - (migration_name, time.time()) + (migration_name, time.time()), ) logger.info(f"Migration '{migration_name}' applied successfully") - + # Migration 3: Add api_tokens table migration_name = "add_api_tokens_table" existing = conn.execute( "SELECT migration_name FROM migrations WHERE migration_name = ?", - (migration_name,) + (migration_name,), ).fetchone() - + if not existing: # Check if api_tokens table already exists cursor = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='api_tokens'" ) - + if not cursor.fetchone(): - conn.execute(""" + conn.execute( + """ CREATE TABLE api_tokens ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, @@ -240,13 +284,14 @@ class SQLiteHandler: created_at REAL NOT NULL, last_used REAL ) - """) + """ + ) logger.info("Created api_tokens table") - + # Mark migration as applied conn.execute( "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", - (migration_name, time.time()) + (migration_name, time.time()), ) logger.info(f"Migration '{migration_name}' applied successfully") @@ -254,7 +299,7 @@ class SQLiteHandler: migration_name = "add_companion_tables" existing = conn.execute( "SELECT migration_name FROM migrations WHERE migration_name = ?", - (migration_name,) + (migration_name,), ).fetchone() if not existing: @@ -262,7 +307,8 @@ class SQLiteHandler: "SELECT name FROM sqlite_master WHERE type='table' AND name='companion_contacts'" ) if not cursor.fetchone(): - conn.execute(""" + conn.execute( + """ CREATE TABLE companion_contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, companion_hash TEXT NOT NULL, @@ -279,8 +325,10 @@ class SQLiteHandler: sync_since INTEGER NOT NULL DEFAULT 0, updated_at REAL NOT NULL ) - """) - conn.execute(""" + """ + ) + conn.execute( + """ CREATE TABLE companion_channels ( id INTEGER PRIMARY KEY AUTOINCREMENT, companion_hash TEXT NOT NULL, @@ -289,8 +337,10 @@ class SQLiteHandler: secret BLOB NOT NULL, updated_at REAL NOT NULL ) - """) - conn.execute(""" + """ + ) + conn.execute( + """ CREATE TABLE companion_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, companion_hash TEXT NOT NULL, @@ -304,19 +354,30 @@ class SQLiteHandler: packet_hash TEXT, created_at REAL NOT NULL ) - """) - conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_contacts_hash ON companion_contacts(companion_hash)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_contacts_pubkey ON companion_contacts(companion_hash, pubkey)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_channels_hash ON companion_channels(companion_hash)") - conn.execute("CREATE INDEX IF NOT EXISTS idx_companion_messages_hash ON companion_messages(companion_hash)") + """ + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_companion_contacts_hash ON companion_contacts(companion_hash)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_companion_contacts_pubkey ON companion_contacts(companion_hash, pubkey)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_companion_channels_hash ON companion_channels(companion_hash)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_companion_messages_hash ON companion_messages(companion_hash)" + ) conn.execute( "CREATE INDEX IF NOT EXISTS idx_companion_messages_hash_packet ON companion_messages(companion_hash, packet_hash)" ) - logger.info("Created companion_contacts, companion_channels, companion_messages tables") + logger.info( + "Created companion_contacts, companion_channels, companion_messages tables" + ) conn.execute( "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", - (migration_name, time.time()) + (migration_name, time.time()), ) logger.info(f"Migration '{migration_name}' applied successfully") @@ -332,43 +393,38 @@ class SQLiteHandler: with sqlite3.connect(self.sqlite_path) as conn: cursor = conn.execute( "INSERT INTO api_tokens (name, token_hash, created_at) VALUES (?, ?, ?)", - (name, token_hash, time.time()) + (name, token_hash, time.time()), ) return cursor.lastrowid except Exception as e: logger.error(f"Failed to create API token: {e}") raise - + def verify_api_token(self, token_hash: str) -> Optional[Dict[str, Any]]: """Verify API token and update last_used timestamp""" try: with sqlite3.connect(self.sqlite_path) as conn: cursor = conn.execute( "SELECT id, name, created_at FROM api_tokens WHERE token_hash = ?", - (token_hash,) + (token_hash,), ) row = cursor.fetchone() - + if row: token_id, name, created_at = row - + # Update last_used timestamp conn.execute( - "UPDATE api_tokens SET last_used = ? WHERE id = ?", - (time.time(), token_id) + "UPDATE api_tokens SET last_used = ? WHERE id = ?", (time.time(), token_id) ) conn.commit() - - return { - 'id': token_id, - 'name': name, - 'created_at': created_at - } + + return {"id": token_id, "name": name, "created_at": created_at} return None except Exception as e: logger.error(f"Failed to verify API token: {e}") return None - + def revoke_api_token(self, token_id: int) -> bool: """Revoke (delete) an API token""" try: @@ -378,7 +434,7 @@ class SQLiteHandler: except Exception as e: logger.error(f"Failed to revoke API token: {e}") return False - + def list_api_tokens(self) -> List[Dict[str, Any]]: """List all API tokens (without sensitive data)""" try: @@ -386,15 +442,12 @@ class SQLiteHandler: cursor = conn.execute( "SELECT id, name, created_at, last_used FROM api_tokens ORDER BY created_at DESC" ) - + tokens = [] for row in cursor.fetchall(): - tokens.append({ - 'id': row[0], - 'name': row[1], - 'created_at': row[2], - 'last_used': row[3] - }) + tokens.append( + {"id": row[0], "name": row[1], "created_at": row[2], "last_used": row[3]} + ) return tokens except Exception as e: logger.error(f"Failed to list API tokens: {e}") @@ -414,42 +467,49 @@ class SQLiteHandler: except Exception: fwd_path_val = str(fwd_path) - conn.execute(""" + conn.execute( + """ INSERT INTO packets ( timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, - header, transport_codes, payload, payload_length, + header, transport_codes, payload, payload_length, tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet, lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - record.get("timestamp", time.time()), - record.get("type", 0), - record.get("route", 0), - record.get("length", 0), - record.get("rssi"), - record.get("snr"), - record.get("score"), - int(bool(record.get("transmitted", False))), - int(bool(record.get("is_duplicate", False))), - record.get("drop_reason"), - record.get("src_hash"), - record.get("dst_hash"), - record.get("path_hash"), - record.get("header"), - record.get("transport_codes"), - record.get("payload"), - record.get("payload_length"), - record.get("tx_delay_ms"), - record.get("packet_hash"), - orig_path_val, - fwd_path_val, - record.get("raw_packet"), - record.get("lbt_attempts", 0), - json.dumps(record.get("lbt_backoff_delays_ms")) if record.get("lbt_backoff_delays_ms") else None, - int(bool(record.get("lbt_channel_busy", False))) - )) - + """, + ( + record.get("timestamp", time.time()), + record.get("type", 0), + record.get("route", 0), + record.get("length", 0), + record.get("rssi"), + record.get("snr"), + record.get("score"), + int(bool(record.get("transmitted", False))), + int(bool(record.get("is_duplicate", False))), + record.get("drop_reason"), + record.get("src_hash"), + record.get("dst_hash"), + record.get("path_hash"), + record.get("header"), + record.get("transport_codes"), + record.get("payload"), + record.get("payload_length"), + record.get("tx_delay_ms"), + record.get("packet_hash"), + orig_path_val, + fwd_path_val, + record.get("raw_packet"), + record.get("lbt_attempts", 0), + ( + json.dumps(record.get("lbt_backoff_delays_ms")) + if record.get("lbt_backoff_delays_ms") + else None + ), + int(bool(record.get("lbt_channel_busy", False))), + ), + ) + except Exception as e: logger.error(f"Failed to store packet in SQLite: {e}") @@ -459,16 +519,16 @@ class SQLiteHandler: conn.row_factory = sqlite3.Row existing = conn.execute( "SELECT pubkey, first_seen, advert_count, zero_hop, rssi, snr FROM adverts WHERE pubkey = ? ORDER BY last_seen DESC LIMIT 1", - (record.get("pubkey", ""),) + (record.get("pubkey", ""),), ).fetchone() - + current_time = record.get("timestamp", time.time()) - - if existing: + + if existing: # Use incoming zero_hop value (already calculated from route_type + path_len) incoming_zero_hop = record.get("zero_hop", False) existing_zero_hop = bool(existing["zero_hop"]) - + # Signal measurement logic: # - If incoming is zero-hop: ALWAYS store incoming rssi/snr (most recent zero-hop measurement) # - If incoming is multi-hop and existing was zero-hop: preserve existing (don't overwrite zero-hop with multi-hop) @@ -485,78 +545,85 @@ class SQLiteHandler: rssi_to_store = None snr_to_store = None zero_hop_to_store = False - - conn.execute(""" - UPDATE adverts + + conn.execute( + """ + UPDATE adverts SET timestamp = ?, node_name = ?, is_repeater = ?, route_type = ?, contact_type = ?, latitude = ?, longitude = ?, last_seen = ?, rssi = ?, snr = ?, advert_count = advert_count + 1, is_new_neighbor = 0, zero_hop = ? WHERE pubkey = ? - """, ( - current_time, - record.get("node_name"), - record.get("is_repeater", False), - record.get("route_type"), - record.get("contact_type"), - record.get("latitude"), - record.get("longitude"), - current_time, - rssi_to_store, - snr_to_store, - zero_hop_to_store, - record.get("pubkey", "") - )) + """, + ( + current_time, + record.get("node_name"), + record.get("is_repeater", False), + record.get("route_type"), + record.get("contact_type"), + record.get("latitude"), + record.get("longitude"), + current_time, + rssi_to_store, + snr_to_store, + zero_hop_to_store, + record.get("pubkey", ""), + ), + ) else: - conn.execute(""" + conn.execute( + """ INSERT INTO adverts ( - timestamp, pubkey, node_name, is_repeater, route_type, contact_type, - latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, + timestamp, pubkey, node_name, is_repeater, route_type, contact_type, + latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, is_new_neighbor, zero_hop ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - current_time, - record.get("pubkey", ""), - record.get("node_name"), - record.get("is_repeater", False), - record.get("route_type"), - record.get("contact_type"), - record.get("latitude"), - record.get("longitude"), - current_time, - current_time, - record.get("rssi"), - record.get("snr"), - 1, - True, - record.get("zero_hop", False) - )) - + """, + ( + current_time, + record.get("pubkey", ""), + record.get("node_name"), + record.get("is_repeater", False), + record.get("route_type"), + record.get("contact_type"), + record.get("latitude"), + record.get("longitude"), + current_time, + current_time, + record.get("rssi"), + record.get("snr"), + 1, + True, + record.get("zero_hop", False), + ), + ) + except Exception as e: logger.error(f"Failed to store advert in SQLite: {e}") def store_noise_floor(self, record: dict): try: with sqlite3.connect(self.sqlite_path) as conn: - conn.execute(""" + conn.execute( + """ INSERT INTO noise_floor (timestamp, noise_floor_dbm) VALUES (?, ?) - """, ( - record.get("timestamp", time.time()), - record.get("noise_floor_dbm") - )) + """, + (record.get("timestamp", time.time()), record.get("noise_floor_dbm")), + ) except Exception as e: logger.error(f"Failed to store noise floor in SQLite: {e}") def get_packet_stats(self, hours: int = 24) -> dict: try: cutoff = time.time() - (hours * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - - stats = conn.execute(""" - SELECT + + stats = conn.execute( + """ + SELECT COUNT(*) as total_packets, SUM(transmitted) as transmitted_packets, SUM(CASE WHEN transmitted = 0 THEN 1 ELSE 0 END) as dropped_packets, @@ -565,26 +632,34 @@ class SQLiteHandler: AVG(score) as avg_score, AVG(payload_length) as avg_payload_length, AVG(tx_delay_ms) as avg_tx_delay - FROM packets + FROM packets WHERE timestamp > ? - """, (cutoff,)).fetchone() - - types = conn.execute(""" + """, + (cutoff,), + ).fetchone() + + types = conn.execute( + """ SELECT type, COUNT(*) as count - FROM packets + FROM packets WHERE timestamp > ? GROUP BY type ORDER BY count DESC - """, (cutoff,)).fetchall() - - drop_reasons = conn.execute(""" + """, + (cutoff,), + ).fetchall() + + drop_reasons = conn.execute( + """ SELECT drop_reason, COUNT(*) as count - FROM packets + FROM packets WHERE timestamp > ? AND transmitted = 0 AND drop_reason IS NOT NULL GROUP BY drop_reason ORDER BY count DESC - """, (cutoff,)).fetchall() - + """, + (cutoff,), + ).fetchall() + return { "total_packets": stats["total_packets"], "transmitted_packets": stats["transmitted_packets"], @@ -595,9 +670,12 @@ class SQLiteHandler: "avg_payload_length": round(stats["avg_payload_length"] or 0, 1), "avg_tx_delay": round(stats["avg_tx_delay"] or 0, 1), "packet_types": [{"type": row["type"], "count": row["count"]} for row in types], - "drop_reasons": [{"reason": row["drop_reason"], "count": row["count"]} for row in drop_reasons] + "drop_reasons": [ + {"reason": row["drop_reason"], "count": row["count"]} + for row in drop_reasons + ], } - + except Exception as e: logger.error(f"Failed to get packet stats: {e}") return {} @@ -606,78 +684,83 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - - packets = conn.execute(""" - SELECT + + packets = conn.execute( + """ + SELECT timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, - header, transport_codes, payload, payload_length, + header, transport_codes, payload, payload_length, tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet, lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy - FROM packets + FROM packets ORDER BY timestamp DESC LIMIT ? - """, (limit,)).fetchall() - + """, + (limit,), + ).fetchall() + return [dict(row) for row in packets] - + except Exception as e: logger.error(f"Failed to get recent packets: {e}") return [] - def get_filtered_packets(self, - packet_type: Optional[int] = None, - route: Optional[int] = None, - start_timestamp: Optional[float] = None, - end_timestamp: Optional[float] = None, - limit: int = 1000, - offset: int = 0) -> list: + def get_filtered_packets( + self, + packet_type: Optional[int] = None, + route: Optional[int] = None, + start_timestamp: Optional[float] = None, + end_timestamp: Optional[float] = None, + limit: int = 1000, + offset: int = 0, + ) -> list: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - + where_clauses = [] params = [] - + if packet_type is not None: where_clauses.append("type = ?") params.append(packet_type) - + if route is not None: where_clauses.append("route = ?") params.append(route) - + if start_timestamp is not None: where_clauses.append("timestamp >= ?") params.append(start_timestamp) - + if end_timestamp is not None: where_clauses.append("timestamp <= ?") params.append(end_timestamp) - + base_query = """ - SELECT + SELECT timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, - header, transport_codes, payload, payload_length, + header, transport_codes, payload, payload_length, tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet, lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy FROM packets """ - + if where_clauses: query = f"{base_query} WHERE {' AND '.join(where_clauses)}" else: query = base_query - + query += " ORDER BY timestamp DESC LIMIT ? OFFSET ?" params.append(limit) params.append(offset) - + packets = conn.execute(query, params).fetchall() - + return [dict(row) for row in packets] - + except Exception as e: logger.error(f"Failed to get filtered packets: {e}") return [] @@ -686,20 +769,23 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - - packet = conn.execute(""" - SELECT + + packet = conn.execute( + """ + SELECT timestamp, type, route, length, rssi, snr, score, transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash, - header, transport_codes, payload, payload_length, + header, transport_codes, payload, payload_length, tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet, lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy - FROM packets + FROM packets WHERE packet_hash = ? - """, (packet_hash,)).fetchone() - + """, + (packet_hash,), + ).fetchone() + return dict(packet) if packet else None - + except Exception as e: logger.error(f"Failed to get packet by hash: {e}") return None @@ -707,47 +793,54 @@ class SQLiteHandler: def get_packet_type_stats(self, hours: int = 24) -> dict: try: cutoff = time.time() - (hours * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - + type_counts = {} packet_type_names = { - 0: 'Request (REQ)', 1: 'Response (RESPONSE)', - 2: 'Plain Text Message (TXT_MSG)', 3: 'Acknowledgment (ACK)', - 4: 'Node Advertisement (ADVERT)', 5: 'Group Text Message (GRP_TXT)', - 6: 'Group Datagram (GRP_DATA)', 7: 'Anonymous Request (ANON_REQ)', - 8: 'Returned Path (PATH)', 9: 'Trace (TRACE)', - 10: 'Multi-part Packet', 11: 'Reserved Type 11', - 12: 'Reserved Type 12', 13: 'Reserved Type 13', - 14: 'Reserved Type 14', 15: 'Custom Packet (RAW_CUSTOM)' + 0: "Request (REQ)", + 1: "Response (RESPONSE)", + 2: "Plain Text Message (TXT_MSG)", + 3: "Acknowledgment (ACK)", + 4: "Node Advertisement (ADVERT)", + 5: "Group Text Message (GRP_TXT)", + 6: "Group Datagram (GRP_DATA)", + 7: "Anonymous Request (ANON_REQ)", + 8: "Returned Path (PATH)", + 9: "Trace (TRACE)", + 10: "Multi-part Packet", + 11: "Reserved Type 11", + 12: "Reserved Type 12", + 13: "Reserved Type 13", + 14: "Reserved Type 14", + 15: "Custom Packet (RAW_CUSTOM)", } - + for packet_type in range(16): count = conn.execute( - "SELECT COUNT(*) FROM packets WHERE type = ? AND timestamp > ?", - (packet_type, cutoff) + "SELECT COUNT(*) FROM packets WHERE type = ? AND timestamp > ?", + (packet_type, cutoff), ).fetchone()[0] - - type_name = packet_type_names.get(packet_type, f'Type {packet_type}') + + type_name = packet_type_names.get(packet_type, f"Type {packet_type}") if count > 0: type_counts[type_name] = count - + other_count = conn.execute( - "SELECT COUNT(*) FROM packets WHERE type > 15 AND timestamp > ?", - (cutoff,) + "SELECT COUNT(*) FROM packets WHERE type > 15 AND timestamp > ?", (cutoff,) ).fetchone()[0] if other_count > 0: - type_counts['Other Types (>15)'] = other_count - + type_counts["Other Types (>15)"] = other_count + return { "hours": hours, "packet_type_totals": type_counts, "total_packets": sum(type_counts.values()), "period": f"{hours} hours", - "data_source": "sqlite" + "data_source": "sqlite", } - + except Exception as e: logger.error(f"Failed to get packet type stats from SQLite: {e}") return {"error": str(e), "data_source": "error"} @@ -756,44 +849,38 @@ class SQLiteHandler: try: cutoff = time.time() - (hours * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - + route_counts = {} - route_names = { - 0: 'Transport Flood', - 1: 'Flood', - 2: 'Direct', - 3: 'Transport Direct' - } + route_names = {0: "Transport Flood", 1: "Flood", 2: "Direct", 3: "Transport Direct"} for route_type in range(4): count = conn.execute( - "SELECT COUNT(*) FROM packets WHERE route = ? AND timestamp > ?", - (route_type, cutoff) + "SELECT COUNT(*) FROM packets WHERE route = ? AND timestamp > ?", + (route_type, cutoff), ).fetchone()[0] - - route_name = route_names.get(route_type, f'Route {route_type}') + + route_name = route_names.get(route_type, f"Route {route_type}") if count > 0: route_counts[route_name] = count - + # Count any other route types > 3 other_count = conn.execute( - "SELECT COUNT(*) FROM packets WHERE route > 3 AND timestamp > ?", - (cutoff,) + "SELECT COUNT(*) FROM packets WHERE route > 3 AND timestamp > ?", (cutoff,) ).fetchone()[0] if other_count > 0: - route_counts['Other Routes (>3)'] = other_count - + route_counts["Other Routes (>3)"] = other_count + return { "hours": hours, "route_totals": route_counts, "total_packets": sum(route_counts.values()), "period": f"{hours} hours", - "data_source": "sqlite" + "data_source": "sqlite", } - + except Exception as e: logger.error(f"Failed to get route stats from SQLite: {e}") return {"error": str(e), "data_source": "error"} @@ -802,19 +889,21 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - - neighbors = conn.execute(""" + + neighbors = conn.execute( + """ SELECT pubkey, node_name, is_repeater, route_type, contact_type, latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, zero_hop FROM adverts a1 WHERE last_seen = ( - SELECT MAX(last_seen) - FROM adverts a2 + SELECT MAX(last_seen) + FROM adverts a2 WHERE a2.pubkey = a1.pubkey ) ORDER BY last_seen DESC - """).fetchall() - + """ + ).fetchall() + result = {} for row in neighbors: result[row["pubkey"]] = { @@ -831,9 +920,9 @@ class SQLiteHandler: "advert_count": row["advert_count"], "zero_hop": bool(row["zero_hop"]), } - + return result - + except Exception as e: logger.error(f"Failed to get neighbors: {e}") return {} @@ -841,29 +930,31 @@ class SQLiteHandler: def get_noise_floor_history(self, hours: int = 24, limit: int = None) -> list: try: cutoff = time.time() - (hours * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - + # Build query with optional limit query = """ SELECT timestamp, noise_floor_dbm - FROM noise_floor + FROM noise_floor WHERE timestamp > ? ORDER BY timestamp DESC """ - + if limit: query += f" LIMIT {int(limit)}" - + measurements = conn.execute(query, (cutoff,)).fetchall() - + # Reverse to get chronological order (oldest to newest) - result = [{"timestamp": row["timestamp"], "noise_floor_dbm": row["noise_floor_dbm"]} - for row in reversed(measurements)] - + result = [ + {"timestamp": row["timestamp"], "noise_floor_dbm": row["noise_floor_dbm"]} + for row in reversed(measurements) + ] + return result - + except Exception as e: logger.error(f"Failed to get noise floor history: {e}") return [] @@ -871,28 +962,31 @@ class SQLiteHandler: def get_noise_floor_stats(self, hours: int = 24) -> dict: try: cutoff = time.time() - (hours * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - - stats = conn.execute(""" - SELECT + + stats = conn.execute( + """ + SELECT COUNT(*) as measurement_count, AVG(noise_floor_dbm) as avg_noise_floor, MIN(noise_floor_dbm) as min_noise_floor, MAX(noise_floor_dbm) as max_noise_floor - FROM noise_floor + FROM noise_floor WHERE timestamp > ? - """, (cutoff,)).fetchone() - + """, + (cutoff,), + ).fetchone() + return { "measurement_count": stats["measurement_count"], "avg_noise_floor": round(stats["avg_noise_floor"] or 0, 1), "min_noise_floor": round(stats["min_noise_floor"] or 0, 1), "max_noise_floor": round(stats["max_noise_floor"] or 0, 1), - "hours": hours + "hours": hours, } - + except Exception as e: logger.error(f"Failed to get noise floor stats: {e}") return {} @@ -900,22 +994,24 @@ class SQLiteHandler: def cleanup_old_data(self, days: int = 7): try: cutoff = time.time() - (days * 24 * 3600) - + with sqlite3.connect(self.sqlite_path) as conn: result = conn.execute("DELETE FROM packets WHERE timestamp < ?", (cutoff,)) packets_deleted = result.rowcount - + result = conn.execute("DELETE FROM adverts WHERE timestamp < ?", (cutoff,)) adverts_deleted = result.rowcount - + result = conn.execute("DELETE FROM noise_floor WHERE timestamp < ?", (cutoff,)) noise_deleted = result.rowcount - + conn.commit() - + if packets_deleted > 0 or adverts_deleted > 0 or noise_deleted > 0: - logger.info(f"Cleaned up {packets_deleted} old packets, {adverts_deleted} old adverts, {noise_deleted} old noise measurements") - + logger.info( + f"Cleaned up {packets_deleted} old packets, {adverts_deleted} old adverts, {noise_deleted} old noise measurements" + ) + except Exception as e: logger.error(f"Failed to cleanup old data: {e}") @@ -924,60 +1020,65 @@ class SQLiteHandler: with sqlite3.connect(self.sqlite_path) as conn: type_counts = {} for i in range(16): - count = conn.execute("SELECT COUNT(*) FROM packets WHERE type = ?", (i,)).fetchone()[0] + count = conn.execute( + "SELECT COUNT(*) FROM packets WHERE type = ?", (i,) + ).fetchone()[0] type_counts[f"type_{i}"] = count - - other_count = conn.execute("SELECT COUNT(*) FROM packets WHERE type > 15").fetchone()[0] + + other_count = conn.execute( + "SELECT COUNT(*) FROM packets WHERE type > 15" + ).fetchone()[0] type_counts["type_other"] = other_count - + rx_total = conn.execute("SELECT COUNT(*) FROM packets").fetchone()[0] - tx_total = conn.execute("SELECT COUNT(*) FROM packets WHERE transmitted = 1").fetchone()[0] - drop_total = conn.execute("SELECT COUNT(*) FROM packets WHERE transmitted = 0").fetchone()[0] - + tx_total = conn.execute( + "SELECT COUNT(*) FROM packets WHERE transmitted = 1" + ).fetchone()[0] + drop_total = conn.execute( + "SELECT COUNT(*) FROM packets WHERE transmitted = 0" + ).fetchone()[0] + return { "rx_total": rx_total, "tx_total": tx_total, "drop_total": drop_total, - "type_counts": type_counts + "type_counts": type_counts, } - + except Exception as e: logger.error(f"Failed to get cumulative counts: {e}") - return { - "rx_total": 0, - "tx_total": 0, - "drop_total": 0, - "type_counts": {} - } + return {"rx_total": 0, "tx_total": 0, "drop_total": 0, "type_counts": {}} + + def get_adverts_by_contact_type( + self, contact_type: str, limit: Optional[int] = None, hours: Optional[int] = None + ) -> List[dict]: - def get_adverts_by_contact_type(self, contact_type: str, limit: Optional[int] = None, hours: Optional[int] = None) -> List[dict]: - try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - + query = """ - SELECT id, timestamp, pubkey, node_name, is_repeater, route_type, - contact_type, latitude, longitude, first_seen, last_seen, + SELECT id, timestamp, pubkey, node_name, is_repeater, route_type, + contact_type, latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, is_new_neighbor, zero_hop - FROM adverts + FROM adverts WHERE contact_type = ? """ params = [contact_type] - + if hours is not None: cutoff = time.time() - (hours * 3600) query += " AND timestamp > ?" params.append(cutoff) - + query += " ORDER BY timestamp DESC" - + if limit is not None: query += " LIMIT ?" params.append(limit) - + rows = conn.execute(query, params).fetchall() - + adverts = [] for row in rows: advert = { @@ -996,12 +1097,12 @@ class SQLiteHandler: "snr": row["snr"], "advert_count": row["advert_count"], "is_new_neighbor": bool(row["is_new_neighbor"]), - "zero_hop": bool(row["zero_hop"]) + "zero_hop": bool(row["zero_hop"]), } adverts.append(advert) - + return adverts - + except Exception as e: logger.error(f"Failed to get adverts by contact_type '{contact_type}': {e}") return [] @@ -1009,50 +1110,70 @@ class SQLiteHandler: def generate_transport_key(self, name: str, key_length_bytes: int = 32) -> str: """ Generate a transport key using the proper MeshCore key derivation. - + Args: name: The key name to derive the key from key_length_bytes: Length of the key in bytes (default: 32 bytes = 256 bits) - + Returns: A base64-encoded transport key derived from the name """ try: from pymc_core.protocol.transport_keys import get_auto_key_for - + # Use the proper MeshCore key derivation function key_bytes = get_auto_key_for(name) - + # Encode to base64 for safe storage and transmission - key = base64.b64encode(key_bytes).decode('utf-8') - - logger.debug(f"Generated transport key for '{name}' with {len(key_bytes)} bytes ({len(key)} base64 chars)") + key = base64.b64encode(key_bytes).decode("utf-8") + + logger.debug( + f"Generated transport key for '{name}' with {len(key_bytes)} bytes ({len(key)} base64 chars)" + ) return key - + except Exception as e: logger.error(f"Failed to generate transport key using get_auto_key_for: {e}") # Fallback to secure random if MeshCore function fails try: random_bytes = secrets.token_bytes(key_length_bytes) - key = base64.b64encode(random_bytes).decode('utf-8') + key = base64.b64encode(random_bytes).decode("utf-8") logger.warning(f"Using fallback random key generation for '{name}'") return key except Exception as fallback_e: logger.error(f"Fallback key generation also failed: {fallback_e}") raise - def create_transport_key(self, name: str, flood_policy: str, transport_key: Optional[str] = None, parent_id: Optional[int] = None, last_used: Optional[float] = None) -> Optional[int]: + def create_transport_key( + self, + name: str, + flood_policy: str, + transport_key: Optional[str] = None, + parent_id: Optional[int] = None, + last_used: Optional[float] = None, + ) -> Optional[int]: try: # Generate key if not provided if transport_key is None: transport_key = self.generate_transport_key(name) - + current_time = time.time() with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ INSERT INTO transport_keys (name, flood_policy, transport_key, parent_id, last_used, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (name, flood_policy, transport_key, parent_id, last_used, current_time, current_time)) + """, + ( + name, + flood_policy, + transport_key, + parent_id, + last_used, + current_time, + current_time, + ), + ) return cursor.lastrowid except Exception as e: logger.error(f"Failed to create transport key: {e}") @@ -1062,22 +1183,27 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - rows = conn.execute(""" + rows = conn.execute( + """ SELECT id, name, flood_policy, transport_key, parent_id, last_used, created_at, updated_at FROM transport_keys ORDER BY created_at ASC - """).fetchall() - - return [{ - "id": row["id"], - "name": row["name"], - "flood_policy": row["flood_policy"], - "transport_key": row["transport_key"], - "parent_id": row["parent_id"], - "last_used": row["last_used"], - "created_at": row["created_at"], - "updated_at": row["updated_at"] - } for row in rows] + """ + ).fetchall() + + return [ + { + "id": row["id"], + "name": row["name"], + "flood_policy": row["flood_policy"], + "transport_key": row["transport_key"], + "parent_id": row["parent_id"], + "last_used": row["last_used"], + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + for row in rows + ] except Exception as e: logger.error(f"Failed to get transport keys: {e}") return [] @@ -1086,11 +1212,14 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - row = conn.execute(""" + row = conn.execute( + """ SELECT id, name, flood_policy, transport_key, parent_id, last_used, created_at, updated_at FROM transport_keys WHERE id = ? - """, (key_id,)).fetchone() - + """, + (key_id,), + ).fetchone() + if row: return { "id": row["id"], @@ -1100,18 +1229,26 @@ class SQLiteHandler: "parent_id": row["parent_id"], "last_used": row["last_used"], "created_at": row["created_at"], - "updated_at": row["updated_at"] + "updated_at": row["updated_at"], } return None except Exception as e: logger.error(f"Failed to get transport key by id: {e}") return None - def update_transport_key(self, key_id: int, name: Optional[str] = None, flood_policy: Optional[str] = None, transport_key: Optional[str] = None, parent_id: Optional[int] = None, last_used: Optional[float] = None) -> bool: + def update_transport_key( + self, + key_id: int, + name: Optional[str] = None, + flood_policy: Optional[str] = None, + transport_key: Optional[str] = None, + parent_id: Optional[int] = None, + last_used: Optional[float] = None, + ) -> bool: try: updates = [] params = [] - + if name is not None: updates.append("name = ?") params.append(name) @@ -1127,19 +1264,22 @@ class SQLiteHandler: if last_used is not None: updates.append("last_used = ?") params.append(last_used) - + if not updates: return False - + updates.append("updated_at = ?") params.append(time.time()) params.append(key_id) - + with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(f""" + cursor = conn.execute( + f""" UPDATE transport_keys SET {', '.join(updates)} WHERE id = ? - """, params) + """, + params, + ) return cursor.rowcount > 0 except Exception as e: logger.error(f"Failed to update transport key: {e}") @@ -1167,40 +1307,58 @@ class SQLiteHandler: # Room Server Methods # ------------------------------------------------------------------ - def insert_room_message(self, room_hash: str, author_pubkey: str, message_text: str, - post_timestamp: float, sender_timestamp: float = None, - txt_type: int = 0) -> Optional[int]: + def insert_room_message( + self, + room_hash: str, + author_pubkey: str, + message_text: str, + post_timestamp: float, + sender_timestamp: float = None, + txt_type: int = 0, + ) -> Optional[int]: """Insert a new room message and return its ID.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ INSERT INTO room_messages ( room_hash, author_pubkey, post_timestamp, sender_timestamp, message_text, txt_type, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?) - """, ( - room_hash, author_pubkey, post_timestamp, sender_timestamp, - message_text, txt_type, time.time() - )) + """, + ( + room_hash, + author_pubkey, + post_timestamp, + sender_timestamp, + message_text, + txt_type, + time.time(), + ), + ) return cursor.lastrowid except Exception as e: logger.error(f"Failed to insert room message: {e}") return None - def get_unsynced_messages(self, room_hash: str, client_pubkey: str, - sync_since: float, limit: int = 100) -> List[Dict]: + def get_unsynced_messages( + self, room_hash: str, client_pubkey: str, sync_since: float, limit: int = 100 + ) -> List[Dict]: """Get messages for a room that client hasn't synced yet.""" try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT * FROM room_messages - WHERE room_hash = ? + WHERE room_hash = ? AND post_timestamp > ? AND author_pubkey != ? ORDER BY post_timestamp ASC LIMIT ? - """, (room_hash, sync_since, client_pubkey, limit)) + """, + (room_hash, sync_since, client_pubkey, limit), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to get unsynced messages: {e}") @@ -1210,12 +1368,15 @@ class SQLiteHandler: """Count unsynced messages for a client.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT COUNT(*) FROM room_messages - WHERE room_hash = ? + WHERE room_hash = ? AND post_timestamp > ? AND author_pubkey != ? - """, (room_hash, sync_since, client_pubkey)) + """, + (room_hash, sync_since, client_pubkey), + ) return cursor.fetchone()[0] except Exception as e: logger.error(f"Failed to count unsynced messages: {e}") @@ -1226,14 +1387,17 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: # Check if exists - cursor = conn.execute(""" - SELECT id FROM room_client_sync + cursor = conn.execute( + """ + SELECT id FROM room_client_sync WHERE room_hash = ? AND client_pubkey = ? - """, (room_hash, client_pubkey)) + """, + (room_hash, client_pubkey), + ) existing = cursor.fetchone() - - kwargs['updated_at'] = time.time() - + + kwargs["updated_at"] = time.time() + if existing: # Update set_clauses = [] @@ -1242,30 +1406,36 @@ class SQLiteHandler: set_clauses.append(f"{key} = ?") values.append(value) values.extend([room_hash, client_pubkey]) - - conn.execute(f""" - UPDATE room_client_sync + + conn.execute( + f""" + UPDATE room_client_sync SET {', '.join(set_clauses)} WHERE room_hash = ? AND client_pubkey = ? - """, values) + """, + values, + ) else: # Insert with defaults - kwargs.setdefault('sync_since', 0) - kwargs.setdefault('pending_ack_crc', 0) - kwargs.setdefault('push_post_timestamp', 0) - kwargs.setdefault('ack_timeout_time', 0) - kwargs.setdefault('push_failures', 0) - kwargs.setdefault('last_activity', time.time()) - - columns = ['room_hash', 'client_pubkey'] + list(kwargs.keys()) - placeholders = ['?'] * len(columns) + kwargs.setdefault("sync_since", 0) + kwargs.setdefault("pending_ack_crc", 0) + kwargs.setdefault("push_post_timestamp", 0) + kwargs.setdefault("ack_timeout_time", 0) + kwargs.setdefault("push_failures", 0) + kwargs.setdefault("last_activity", time.time()) + + columns = ["room_hash", "client_pubkey"] + list(kwargs.keys()) + placeholders = ["?"] * len(columns) values = [room_hash, client_pubkey] + list(kwargs.values()) - - conn.execute(f""" + + conn.execute( + f""" INSERT INTO room_client_sync ({', '.join(columns)}) VALUES ({', '.join(placeholders)}) - """, values) - + """, + values, + ) + conn.commit() return True except Exception as e: @@ -1277,10 +1447,13 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT * FROM room_client_sync WHERE room_hash = ? AND client_pubkey = ? - """, (room_hash, client_pubkey)) + """, + (room_hash, client_pubkey), + ) row = cursor.fetchone() return dict(row) if row else None except Exception as e: @@ -1292,11 +1465,14 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT * FROM room_client_sync WHERE room_hash = ? ORDER BY last_activity DESC - """, (room_hash,)) + """, + (room_hash,), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to get room clients: {e}") @@ -1306,9 +1482,12 @@ class SQLiteHandler: """Get total number of messages in a room.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT COUNT(*) FROM room_messages WHERE room_hash = ? - """, (room_hash,)) + """, + (room_hash,), + ) return cursor.fetchone()[0] except Exception as e: logger.error(f"Failed to get room message count: {e}") @@ -1319,28 +1498,36 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT * FROM room_messages WHERE room_hash = ? ORDER BY post_timestamp DESC LIMIT ? OFFSET ? - """, (room_hash, limit, offset)) + """, + (room_hash, limit, offset), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to get room messages: {e}") return [] - def get_messages_since(self, room_hash: str, since_timestamp: float, limit: int = 50) -> List[Dict]: + def get_messages_since( + self, room_hash: str, since_timestamp: float, limit: int = 50 + ) -> List[Dict]: """Get messages posted after a specific timestamp.""" try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT * FROM room_messages WHERE room_hash = ? AND post_timestamp > ? ORDER BY post_timestamp DESC LIMIT ? - """, (room_hash, since_timestamp, limit)) + """, + (room_hash, since_timestamp, limit), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to get messages since timestamp: {e}") @@ -1350,12 +1537,15 @@ class SQLiteHandler: """Get count of unsynced messages for a client.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT COUNT(*) FROM room_messages - WHERE room_hash = ? + WHERE room_hash = ? AND author_pubkey != ? AND post_timestamp > ? - """, (room_hash, client_pubkey, sync_since)) + """, + (room_hash, client_pubkey, sync_since), + ) return cursor.fetchone()[0] except Exception as e: logger.error(f"Failed to get unsynced count: {e}") @@ -1365,10 +1555,13 @@ class SQLiteHandler: """Delete a specific message by ID.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ DELETE FROM room_messages WHERE room_hash = ? AND id = ? - """, (room_hash, message_id)) + """, + (room_hash, message_id), + ) return cursor.rowcount > 0 except Exception as e: logger.error(f"Failed to delete message: {e}") @@ -1378,9 +1571,12 @@ class SQLiteHandler: """Clear all messages from a room.""" try: with sqlite3.connect(self.sqlite_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ DELETE FROM room_messages WHERE room_hash = ? - """, (room_hash,)) + """, + (room_hash,), + ) return cursor.rowcount except Exception as e: logger.error(f"Failed to clear room messages: {e}") @@ -1391,16 +1587,20 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: # First check if cleanup is needed - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT COUNT(*) FROM room_messages WHERE room_hash = ? - """, (room_hash,)) + """, + (room_hash,), + ) total_count = cursor.fetchone()[0] - + if total_count <= keep_count: return 0 # No cleanup needed - + # Delete old messages - cursor = conn.execute(""" + cursor = conn.execute( + """ DELETE FROM room_messages WHERE room_hash = ? AND id NOT IN ( @@ -1409,7 +1609,9 @@ class SQLiteHandler: ORDER BY post_timestamp DESC LIMIT ? ) - """, (room_hash, room_hash, keep_count)) + """, + (room_hash, room_hash, keep_count), + ) return cursor.rowcount except Exception as e: logger.error(f"Failed to cleanup old messages: {e}") @@ -1421,11 +1623,14 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT pubkey, name, adv_type, flags, out_path_len, out_path, last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since FROM companion_contacts WHERE companion_hash = ? - """, (companion_hash,)) + """, + (companion_hash,), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to load companion contacts: {e}") @@ -1435,29 +1640,34 @@ class SQLiteHandler: """Replace all contacts for a companion in storage.""" try: with sqlite3.connect(self.sqlite_path) as conn: - conn.execute("DELETE FROM companion_contacts WHERE companion_hash = ?", (companion_hash,)) + conn.execute( + "DELETE FROM companion_contacts WHERE companion_hash = ?", (companion_hash,) + ) now = time.time() for c in contacts: - conn.execute(""" + conn.execute( + """ INSERT INTO companion_contacts (companion_hash, pubkey, name, adv_type, flags, out_path_len, out_path, last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - companion_hash, - c.get("pubkey", b""), - c.get("name", ""), - c.get("adv_type", 0), - c.get("flags", 0), - c.get("out_path_len", -1), - c.get("out_path", b""), - c.get("last_advert_timestamp", 0), - c.get("lastmod", 0), - c.get("gps_lat", 0.0), - c.get("gps_lon", 0.0), - c.get("sync_since", 0), - now, - )) + """, + ( + companion_hash, + c.get("pubkey", b""), + c.get("name", ""), + c.get("adv_type", 0), + c.get("flags", 0), + c.get("out_path_len", -1), + c.get("out_path", b""), + c.get("last_advert_timestamp", 0), + c.get("lastmod", 0), + c.get("gps_lat", 0.0), + c.get("gps_lon", 0.0), + c.get("sync_since", 0), + now, + ), + ) conn.commit() return True except Exception as e: @@ -1469,10 +1679,13 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT channel_idx, name, secret FROM companion_channels WHERE companion_hash = ? ORDER BY channel_idx - """, (companion_hash,)) + """, + (companion_hash,), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to load companion channels: {e}") @@ -1482,20 +1695,25 @@ class SQLiteHandler: """Replace all channels for a companion in storage.""" try: with sqlite3.connect(self.sqlite_path) as conn: - conn.execute("DELETE FROM companion_channels WHERE companion_hash = ?", (companion_hash,)) + conn.execute( + "DELETE FROM companion_channels WHERE companion_hash = ?", (companion_hash,) + ) now = time.time() for ch in channels: - conn.execute(""" + conn.execute( + """ INSERT INTO companion_channels (companion_hash, channel_idx, name, secret, updated_at) VALUES (?, ?, ?, ?, ?) - """, ( - companion_hash, - ch.get("channel_idx", 0), - ch.get("name", ""), - ch.get("secret", b""), - now, - )) + """, + ( + companion_hash, + ch.get("channel_idx", 0), + ch.get("name", ""), + ch.get("secret", b""), + now, + ), + ) conn.commit() return True except Exception as e: @@ -1507,11 +1725,14 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len FROM companion_messages WHERE companion_hash = ? ORDER BY created_at ASC LIMIT ? - """, (companion_hash, limit)) + """, + (companion_hash, limit), + ) return [dict(row) for row in cursor.fetchall()] except Exception as e: logger.error(f"Failed to load companion messages: {e}") @@ -1526,29 +1747,35 @@ class SQLiteHandler: sender_key = msg.get("sender_key", b"") with sqlite3.connect(self.sqlite_path) as conn: if packet_hash: - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT id FROM companion_messages WHERE companion_hash = ? AND packet_hash = ? LIMIT 1 - """, (companion_hash, packet_hash)) + """, + (companion_hash, packet_hash), + ) if cursor.fetchone(): return False - conn.execute(""" + conn.execute( + """ INSERT INTO companion_messages (companion_hash, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len, packet_hash, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - companion_hash, - sender_key, - msg.get("txt_type", 0), - msg.get("timestamp", 0), - msg.get("text", ""), - int(msg.get("is_channel", False)), - msg.get("channel_idx", 0), - msg.get("path_len", 0), - packet_hash, - time.time(), - )) + """, + ( + companion_hash, + sender_key, + msg.get("txt_type", 0), + msg.get("timestamp", 0), + msg.get("text", ""), + int(msg.get("is_channel", False)), + msg.get("channel_idx", 0), + msg.get("path_len", 0), + packet_hash, + time.time(), + ), + ) conn.commit() return True except Exception as e: @@ -1560,11 +1787,14 @@ class SQLiteHandler: try: with sqlite3.connect(self.sqlite_path) as conn: conn.row_factory = sqlite3.Row - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT id, sender_key, txt_type, timestamp, text, is_channel, channel_idx, path_len FROM companion_messages WHERE companion_hash = ? ORDER BY created_at ASC LIMIT 1 - """, (companion_hash,)) + """, + (companion_hash,), + ) row = cursor.fetchone() if not row: return None diff --git a/repeater/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py index f139151..1226a3b 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -3,15 +3,14 @@ import logging import time from datetime import datetime from pathlib import Path -from typing import Optional, Dict, Any +from typing import Any, Dict, Optional -from .sqlite_handler import SQLiteHandler -from .rrdtool_handler import RRDToolHandler -from .mqtt_handler import MQTTHandler from .letsmesh_handler import MeshCoreToMqttJwtPusher +from .mqtt_handler import MQTTHandler +from .rrdtool_handler import RRDToolHandler +from .sqlite_handler import SQLiteHandler from .storage_utils import PacketRecord - logger = logging.getLogger("StorageCollector") @@ -62,16 +61,18 @@ class StorageCollector: self.disallowed_packet_types = set() else: self.disallowed_packet_types = set() - + # Initialize hardware stats collector from .hardware_stats import HardwareStatsCollector + self.hardware_stats = HardwareStatsCollector() logger.info("Hardware stats collector initialized") - + # Initialize WebSocket handler for real-time updates self.websocket_available = False try: from .websocket_handler import broadcast_packet, broadcast_stats + self.websocket_broadcast_packet = broadcast_packet self.websocket_broadcast_stats = broadcast_stats self.websocket_available = True @@ -87,23 +88,23 @@ class StorageCollector: "packets_sent": 0, "packets_received": 0, "errors": 0, - "queue_len": 0 + "queue_len": 0, } uptime_secs = int(time.time() - self.repeater_handler.start_time) - + # Get airtime stats airtime_stats = self.repeater_handler.airtime_mgr.get_stats() - + # Get latest noise floor from database noise_floor = None try: recent_noise = self.sqlite_handler.get_noise_floor_history(hours=0.5, limit=1) if recent_noise and len(recent_noise) > 0: - noise_floor = recent_noise[-1].get('noise_floor_dbm') + noise_floor = recent_noise[-1].get("noise_floor_dbm") except Exception as e: logger.debug(f"Could not fetch noise floor: {e}") - + stats = { "uptime_secs": uptime_secs, "packets_sent": self.repeater_handler.forwarded_count, @@ -111,22 +112,22 @@ class StorageCollector: "errors": 0, "queue_len": 0, # N/A for Python repeater } - + # Add airtime stats if airtime_stats: stats["tx_air_secs"] = airtime_stats["total_airtime_ms"] / 1000 stats["current_airtime_ms"] = airtime_stats["current_airtime_ms"] stats["utilization_percent"] = airtime_stats["utilization_percent"] - + # Add noise floor if available if noise_floor is not None: stats["noise_floor"] = noise_floor - + return stats def record_packet(self, packet_record: dict, skip_letsmesh_if_invalid: bool = True): """Record packet to storage and publish to MQTT/LetsMesh - + Args: packet_record: Dictionary containing packet information skip_letsmesh_if_invalid: If True, don't publish packets with drop_reason to LetsMesh @@ -141,28 +142,34 @@ class StorageCollector: cumulative_counts = self.sqlite_handler.get_cumulative_counts() self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts) self.mqtt_handler.publish(packet_record, "packet") - + # Broadcast to WebSocket clients for real-time updates if self.websocket_available: try: self.websocket_broadcast_packet(packet_record) - + # Broadcast 24-hour packet stats (same as /api/packet_stats?hours=24) packet_stats_24h = self.sqlite_handler.get_packet_stats(hours=24) - uptime_seconds = time.time() - self.repeater_handler.start_time if self.repeater_handler else 0 - - self.websocket_broadcast_stats({ - "packet_stats": packet_stats_24h, - "system_stats": { - "uptime_seconds": uptime_seconds, + uptime_seconds = ( + time.time() - self.repeater_handler.start_time if self.repeater_handler else 0 + ) + + self.websocket_broadcast_stats( + { + "packet_stats": packet_stats_24h, + "system_stats": { + "uptime_seconds": uptime_seconds, + }, } - }) + ) except Exception as e: logger.debug(f"WebSocket broadcast failed: {e}") # Publish to LetsMesh if enabled (skip invalid packets if requested) - if skip_letsmesh_if_invalid and packet_record.get('drop_reason'): - logger.debug(f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}") + if skip_letsmesh_if_invalid and packet_record.get("drop_reason"): + logger.debug( + f"Skipping LetsMesh publish for packet with drop_reason: {packet_record.get('drop_reason')}" + ) else: self._publish_to_letsmesh(packet_record) @@ -247,23 +254,24 @@ class StorageCollector: def get_neighbors(self) -> dict: return self.sqlite_handler.get_neighbors() - + def get_node_name_by_pubkey(self, pubkey: str) -> Optional[str]: """ Lookup node name from adverts table by public key. - + Args: pubkey: Public key in hex string format - + Returns: Node name if found, None otherwise """ try: import sqlite3 + with sqlite3.connect(self.sqlite_handler.sqlite_path) as conn: result = conn.execute( "SELECT node_name FROM adverts WHERE pubkey = ? AND node_name IS NOT NULL ORDER BY last_seen DESC LIMIT 1", - (pubkey,) + (pubkey,), ).fetchone() return result[0] if result else None except Exception as e: diff --git a/repeater/data_acquisition/storage_utils.py b/repeater/data_acquisition/storage_utils.py index bd930a5..bde938e 100644 --- a/repeater/data_acquisition/storage_utils.py +++ b/repeater/data_acquisition/storage_utils.py @@ -1,6 +1,6 @@ """Storage utility classes and functions for data acquisition.""" -from dataclasses import dataclass, asdict +from dataclasses import asdict, dataclass from datetime import datetime from typing import Optional diff --git a/repeater/data_acquisition/websocket_handler.py b/repeater/data_acquisition/websocket_handler.py index 67d008a..2e87ebc 100644 --- a/repeater/data_acquisition/websocket_handler.py +++ b/repeater/data_acquisition/websocket_handler.py @@ -1,19 +1,21 @@ """ WebSocket handler for real-time packet updates - simple ws4py implementation """ + import json import logging import threading import time -import cherrypy from urllib.parse import parse_qs -from ws4py.websocket import WebSocket + +import cherrypy from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool +from ws4py.websocket import WebSocket logger = logging.getLogger("WebSocket") # Suppress noisy ws4py error logs for normal disconnections (ConnectionResetError, etc.) -logging.getLogger('ws4py').setLevel(logging.CRITICAL) +logging.getLogger("ws4py").setLevel(logging.CRITICAL) # Global set of connected clients _connected_clients = set() @@ -69,14 +71,18 @@ class PacketWebSocket(WebSocket): # Auth success - store user and add to connected clients self.user = payload.get("sub") # type: ignore[attr-defined] _connected_clients.add(self) - logger.info(f"WebSocket connected ({self.user or 'unknown user'}). Total clients: {len(_connected_clients)}") - + logger.info( + f"WebSocket connected ({self.user or 'unknown user'}). Total clients: {len(_connected_clients)}" + ) + def closed(self, code, reason=None): """Called when a WebSocket connection is closed""" _connected_clients.discard(self) - user = getattr(self, 'user', 'unknown') - logger.info(f"WebSocket disconnected (user: {user}, code: {code}, reason: {reason}). Total clients: {len(_connected_clients)}") - + user = getattr(self, "user", "unknown") + logger.info( + f"WebSocket disconnected (user: {user}, code: {code}, reason: {reason}). Total clients: {len(_connected_clients)}" + ) + def received_message(self, message): """Handle messages from client""" try: diff --git a/repeater/engine.py b/repeater/engine.py index 3a34e69..08cade9 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -16,9 +16,8 @@ from pymc_core.protocol.constants import ( PH_TYPE_SHIFT, ROUTE_TYPE_DIRECT, ROUTE_TYPE_FLOOD, - ROUTE_TYPE_TRANSPORT_FLOOD, ROUTE_TYPE_TRANSPORT_DIRECT, - + ROUTE_TYPE_TRANSPORT_FLOOD, ) from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils @@ -45,7 +44,9 @@ class RepeaterHandler(BaseHandler): self.send_advert_func = send_advert_func self.airtime_mgr = AirtimeManager(config) self.seen_packets = OrderedDict() - self.cache_ttl = max(300, config.get("repeater", {}).get("cache_ttl", 3600)) # Min 5 min, default 1 hour + self.cache_ttl = max( + 300, config.get("repeater", {}).get("cache_ttl", 3600) + ) # Min 5 min, default 1 hour self.max_cache_size = 1000 self.tx_delay_factor = config.get("delays", {}).get("tx_delay_factor", 1.0) self.direct_tx_delay_factor = config.get("delays", {}).get("direct_tx_delay_factor", 0.5) @@ -100,10 +101,12 @@ class RepeaterHandler(BaseHandler): self._transport_keys_cache = None self._transport_keys_cache_time = 0 self._transport_keys_cache_ttl = 60 # Cache for 60 seconds - + self._start_background_tasks() - async def __call__(self, packet: Packet, metadata: Optional[dict] = None, local_transmission: bool = False) -> None: + async def __call__( + self, packet: Packet, metadata: Optional[dict] = None, local_transmission: bool = False + ) -> None: if metadata is None: metadata = {} @@ -132,9 +135,13 @@ class RepeaterHandler(BaseHandler): original_path = list(packet.path) if packet.path else [] # Process for forwarding (skip if in monitor mode or if this is a local transmission) - result = None if (monitor_mode or local_transmission) else self.process_packet(processed_packet, snr) + result = ( + None + if (monitor_mode or local_transmission) + else self.process_packet(processed_packet, snr) + ) forwarded_path = None - + # For local transmissions, create a direct transmission result if local_transmission and not monitor_mode: # Mark local packet as seen to prevent duplicate processing when received back @@ -172,18 +179,18 @@ class RepeaterHandler(BaseHandler): # Wait for transmission to complete to get LBT metadata await tx_task - + # Extract LBT metadata after transmission - tx_metadata = getattr(fwd_pkt, '_tx_metadata', None) + tx_metadata = getattr(fwd_pkt, "_tx_metadata", None) lbt_attempts = 0 lbt_backoff_delays_ms = None lbt_channel_busy = False - + if tx_metadata: - lbt_attempts = tx_metadata.get('lbt_attempts', 0) - lbt_backoff_delays_ms = tx_metadata.get('lbt_backoff_delays_ms', []) - lbt_channel_busy = tx_metadata.get('lbt_channel_busy', False) - + lbt_attempts = tx_metadata.get("lbt_attempts", 0) + lbt_backoff_delays_ms = tx_metadata.get("lbt_backoff_delays_ms", []) + lbt_channel_busy = tx_metadata.get("lbt_channel_busy", False) + if lbt_attempts > 0: total_lbt_delay = sum(lbt_backoff_delays_ms) logger.info( @@ -197,7 +204,9 @@ class RepeaterHandler(BaseHandler): drop_reason = "Monitor mode" else: # Check if packet has a specific drop reason set by handlers - drop_reason = processed_packet.drop_reason or self._get_drop_reason(processed_packet) + drop_reason = processed_packet.drop_reason or self._get_drop_reason( + processed_packet + ) logger.debug(f"Packet not forwarded: {drop_reason}") # Extract packet type and route from header @@ -282,7 +291,9 @@ class RepeaterHandler(BaseHandler): ), "raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None, "lbt_attempts": lbt_attempts if transmitted else 0, - "lbt_backoff_delays_ms": lbt_backoff_delays_ms if transmitted and lbt_backoff_delays_ms else None, + "lbt_backoff_delays_ms": ( + lbt_backoff_delays_ms if transmitted and lbt_backoff_delays_ms else None + ), "lbt_channel_busy": lbt_channel_busy if transmitted else False, } @@ -396,7 +407,10 @@ class RepeaterHandler(BaseHandler): return False, "Empty payload" if len(packet.path or []) >= MAX_PATH_SIZE: - return False, f"Path length {len(packet.path or [])} exceeds MAX_PATH_SIZE ({MAX_PATH_SIZE})" + return ( + False, + f"Path length {len(packet.path or [])} exceeds MAX_PATH_SIZE ({MAX_PATH_SIZE})", + ) return True, "" @@ -408,11 +422,13 @@ class RepeaterHandler(BaseHandler): try: from pymc_core.protocol.transport_keys import calc_transport_code - + # Check cache validity current_time = time.time() - if (self._transport_keys_cache is None or - current_time - self._transport_keys_cache_time > self._transport_keys_cache_ttl): + if ( + self._transport_keys_cache is None + or current_time - self._transport_keys_cache_time > self._transport_keys_cache_ttl + ): # Refresh cache self._transport_keys_cache = self.storage.get_transport_keys() self._transport_keys_cache_time = current_time @@ -425,14 +441,16 @@ class RepeaterHandler(BaseHandler): # Check if packet has transport codes if not packet.has_transport_codes(): return False, "No transport codes present" - transport_code_0 = packet.transport_codes[0] # First transport code - payload = packet.get_payload() - payload_type = packet.get_payload_type() if hasattr(packet, 'get_payload_type') else ((packet.header & 0x3C) >> 2) - + payload_type = ( + packet.get_payload_type() + if hasattr(packet, "get_payload_type") + else ((packet.header & 0x3C) >> 2) + ) + # Check packet against each transport key for key_record in transport_keys: transport_key_encoded = key_record.get("transport_key") @@ -441,41 +459,48 @@ class RepeaterHandler(BaseHandler): if not transport_key_encoded: continue - + try: import base64 + transport_key = base64.b64decode(transport_key_encoded) expected_code = calc_transport_code(transport_key, packet) if transport_code_0 == expected_code: - logger.debug(f"Transport code validated for key '{key_name}' with policy '{flood_policy}'") - + logger.debug( + f"Transport code validated for key '{key_name}' with policy '{flood_policy}'" + ) + # Update last_used timestamp for this key try: key_id = key_record.get("id") if key_id: self.storage.update_transport_key( - key_id=key_id, - last_used=time.time() + key_id=key_id, last_used=time.time() + ) + logger.debug( + f"Updated last_used timestamp for transport key '{key_name}'" ) - logger.debug(f"Updated last_used timestamp for transport key '{key_name}'") except Exception as e: - logger.warning(f"Failed to update last_used for transport key '{key_name}': {e}") - + logger.warning( + f"Failed to update last_used for transport key '{key_name}': {e}" + ) + # Check flood policy for this key if flood_policy == "allow": return True, "" else: return False, f"Transport key '{key_name}' flood policy denied" - except Exception as e: logger.warning(f"Error checking transport key '{key_name}': {e}") continue - + # No matching transport code found - logger.debug(f"Transport code 0x{transport_code_0:04X} denied (checked {len(transport_keys)} keys)") + logger.debug( + f"Transport code 0x{transport_code_0:04X} denied (checked {len(transport_keys)} keys)" + ) return False, "No matching transport code" - + except Exception as e: logger.error(f"Transport code validation error: {e}") return False, f"Transport code validation error: {e}" @@ -649,6 +674,7 @@ class RepeaterHandler(BaseHandler): async def schedule_retransmit(self, fwd_pkt: Packet, delay: float, airtime_ms: float = 0.0): """Schedule a packet retransmission with delay and return the task.""" + async def delayed_send(): await asyncio.sleep(delay) try: @@ -720,13 +746,17 @@ class RepeaterHandler(BaseHandler): "mode": repeater_config.get("mode", "forward"), "use_score_for_tx": repeater_config.get("use_score_for_tx", False), "score_threshold": repeater_config.get("score_threshold", 0.3), - "send_advert_interval_hours": repeater_config.get("send_advert_interval_hours", 10), + "send_advert_interval_hours": repeater_config.get( + "send_advert_interval_hours", 10 + ), "latitude": repeater_config.get("latitude", 0.0), "longitude": repeater_config.get("longitude", 0.0), "max_flood_hops": repeater_config.get("max_flood_hops", 3), "advert_interval_minutes": repeater_config.get("advert_interval_minutes", 120), }, - "radio": self.config.get("radio", {}), # Read from live config, not cached radio_config + "radio": self.config.get( + "radio", {} + ), # Read from live config, not cached radio_config "duty_cycle": { "max_airtime_percent": max_duty_cycle_percent, "enforcement_enabled": duty_cycle_config.get("enforcement_enabled", True), @@ -813,8 +843,10 @@ class RepeaterHandler(BaseHandler): try: # Refresh delay factors self.tx_delay_factor = self.config.get("delays", {}).get("tx_delay_factor", 1.0) - self.direct_tx_delay_factor = self.config.get("delays", {}).get("direct_tx_delay_factor", 0.5) - + self.direct_tx_delay_factor = self.config.get("delays", {}).get( + "direct_tx_delay_factor", 0.5 + ) + # Refresh repeater settings repeater_config = self.config.get("repeater", {}) self.use_score_for_tx = repeater_config.get("use_score_for_tx", False) diff --git a/repeater/handler_helpers/__init__.py b/repeater/handler_helpers/__init__.py index 51da12b..3518ca2 100644 --- a/repeater/handler_helpers/__init__.py +++ b/repeater/handler_helpers/__init__.py @@ -1,11 +1,19 @@ """Handler helper modules for pyMC Repeater.""" -from .trace import TraceHelper -from .discovery import DiscoveryHelper from .advert import AdvertHelper +from .discovery import DiscoveryHelper from .login import LoginHelper -from .text import TextHelper from .path import PathHelper from .protocol_request import ProtocolRequestHelper +from .text import TextHelper +from .trace import TraceHelper -__all__ = ["TraceHelper", "DiscoveryHelper", "AdvertHelper", "LoginHelper", "TextHelper", "PathHelper", "ProtocolRequestHelper"] +__all__ = [ + "TraceHelper", + "DiscoveryHelper", + "AdvertHelper", + "LoginHelper", + "TextHelper", + "PathHelper", + "ProtocolRequestHelper", +] diff --git a/repeater/handler_helpers/acl.py b/repeater/handler_helpers/acl.py index 47f65a8..3351999 100644 --- a/repeater/handler_helpers/acl.py +++ b/repeater/handler_helpers/acl.py @@ -58,7 +58,7 @@ class ACL: sync_since: int = None, target_identity_hash: int = None, target_identity_name: str = None, - target_identity_config: dict = None + target_identity_config: dict = None, ) -> tuple[bool, int]: target_identity_config = target_identity_config or {} @@ -79,9 +79,11 @@ class ACL: # Empty strings are treated as "not set" admin_pwd = identity_settings.get("admin_password") or None guest_pwd = identity_settings.get("guest_password") or None - + if not admin_pwd and not guest_pwd: - logger.error(f"Room server '{target_identity_name}' has no passwords configured! Set admin_password and/or guest_password in settings.") + logger.error( + f"Room server '{target_identity_name}' has no passwords configured! Set admin_password and/or guest_password in settings." + ) return False, 0 else: # Repeater uses global passwords from its own security section @@ -91,10 +93,12 @@ class ACL: f"Repeater passwords - admin: {'SET' if admin_pwd else 'NONE'}, " f"guest: {'SET' if guest_pwd else 'NONE'}" ) - + if target_identity_name: - logger.debug(f"Authenticating for identity '{target_identity_name}' (room_server={is_room_server})") - + logger.debug( + f"Authenticating for identity '{target_identity_name}' (room_server={is_room_server})" + ) + pub_key = client_identity.get_public_key()[:PUB_KEY_SIZE] if not password: @@ -111,8 +115,12 @@ class ACL: permissions = 0 logger.debug(f"Comparing password (len={len(password)}) against admin/guest") - logger.debug(f"Admin pwd len={len(admin_pwd) if admin_pwd else 0}, Guest pwd len={len(guest_pwd) if guest_pwd else 0}") - logger.debug(f"Password comparison: '{password}' vs admin='{admin_pwd[:4]}...' ({len(admin_pwd)} chars)") + logger.debug( + f"Admin pwd len={len(admin_pwd) if admin_pwd else 0}, Guest pwd len={len(guest_pwd) if guest_pwd else 0}" + ) + logger.debug( + f"Password comparison: '{password}' vs admin='{admin_pwd[:4]}...' ({len(admin_pwd)} chars)" + ) if admin_pwd and password == admin_pwd: permissions = PERM_ACL_ADMIN logger.info(f"Admin password validated for '{target_identity_name or 'unknown'}'") diff --git a/repeater/handler_helpers/advert.py b/repeater/handler_helpers/advert.py index 4b0fa62..74354d5 100644 --- a/repeater/handler_helpers/advert.py +++ b/repeater/handler_helpers/advert.py @@ -70,11 +70,12 @@ class AdvertHelper: if pubkey == local_pubkey: logger.debug("Ignoring own advert in neighbor tracking") return - + # Get route type from packet header from pymc_core.protocol.constants import PH_ROUTE_MASK + route_type = packet.header & PH_ROUTE_MASK - + # Check if this is a new neighbor current_time = time.time() if pubkey not in self._known_neighbors: diff --git a/repeater/handler_helpers/discovery.py b/repeater/handler_helpers/discovery.py index e48d50d..4836161 100644 --- a/repeater/handler_helpers/discovery.py +++ b/repeater/handler_helpers/discovery.py @@ -7,6 +7,7 @@ allowing other nodes to discover repeaters on the mesh network. import asyncio import logging + from pymc_core.node.handlers.control import ControlHandler logger = logging.getLogger("DiscoveryHelper") diff --git a/repeater/handler_helpers/login.py b/repeater/handler_helpers/login.py index cedf611..5912866 100644 --- a/repeater/handler_helpers/login.py +++ b/repeater/handler_helpers/login.py @@ -22,9 +22,11 @@ class LoginHelper: self.handlers = {} self.acls = {} # Per-identity ACLs keyed by hash_byte - def register_identity(self, name: str, identity, identity_type: str = "room_server", config: dict = None): + def register_identity( + self, name: str, identity, identity_type: str = "room_server", config: dict = None + ): config = config or {} - + hash_byte = identity.get_public_key()[0] # Create ACL for this identity @@ -79,9 +81,11 @@ class LoginHelper: self.acls[hash_byte] = identity_acl logger.info(f"Created ACL for {identity_type} '{name}': hash=0x{hash_byte:02X}") - + # Create auth callback that uses this identity's ACL - def auth_callback_with_context(client_identity, shared_secret, password, timestamp, sync_since=None): + def auth_callback_with_context( + client_identity, shared_secret, password, timestamp, sync_since=None + ): return identity_acl.authenticate_client( client_identity=client_identity, shared_secret=shared_secret, @@ -90,9 +94,9 @@ class LoginHelper: sync_since=sync_since, target_identity_hash=hash_byte, target_identity_name=name, - target_identity_config=config + target_identity_config=config, ) - + handler = LoginServerHandler( local_identity=identity, log_fn=self.log_fn, @@ -103,11 +107,9 @@ class LoginHelper: handler.set_send_packet_callback(self._send_packet_with_delay) self.handlers[hash_byte] = handler - + logger.info(f"Registered {identity_type} '{name}' login handler: hash=0x{hash_byte:02X}") - - async def process_login_packet(self, packet): try: @@ -123,9 +125,11 @@ class LoginHelper: packet.mark_do_not_retransmit() return True else: - logger.debug(f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward") + logger.debug( + f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward" + ) return False - + except Exception as e: logger.error(f"Error processing login packet: {e}") return False diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py index 2f7dd12..a05bf81 100644 --- a/repeater/handler_helpers/mesh_cli.py +++ b/repeater/handler_helpers/mesh_cli.py @@ -1,8 +1,9 @@ import logging -from typing import Optional, Dict, Any, Callable -import yaml -from pathlib import Path import time +from pathlib import Path +from typing import Any, Callable, Dict, Optional + +import yaml logger = logging.getLogger(__name__) @@ -10,15 +11,15 @@ logger = logging.getLogger(__name__) class MeshCLI: def __init__( - self, - config_path: str, - config: Dict[str, Any], + self, + config_path: str, + config: Dict[str, Any], config_manager, # ConfigManager instance for save & live updates identity_type: str = "repeater", enable_regions: bool = True, send_advert_callback: Optional[Callable] = None, - identity = None, - storage_handler = None + identity=None, + storage_handler=None, ): self.config_path = Path(config_path) @@ -29,39 +30,39 @@ class MeshCLI: self.send_advert_callback = send_advert_callback self.identity = identity self.storage_handler = storage_handler - + # Get repeater config shortcut - self.repeater_config = config.get('repeater', {}) - + self.repeater_config = config.get("repeater", {}) + def handle_command(self, sender_pubkey: bytes, command: str, is_admin: bool) -> str: # Check admin permission first if not is_admin: return "Error: Admin permission required" - + logger.debug(f"handle_command received: '{command}' (len={len(command)})") - + # Extract optional sequence prefix (XX|) prefix = "" - if len(command) > 4 and command[2] == '|': + if len(command) > 4 and command[2] == "|": prefix = command[:3] command = command[3:] logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'") - + # Strip leading/trailing whitespace command = command.strip() logger.debug(f"After strip: '{command}'") - + # Route to appropriate handler reply = self._route_command(command) - + # Add prefix back to reply if present if prefix: return prefix + reply return reply - + def _route_command(self, command: str) -> str: - + # System commands if command == "reboot": return self._cmd_reboot() @@ -79,97 +80,98 @@ class MeshCLI: return self._cmd_clear_stats() elif command == "ver": return self._cmd_version() - + # Get commands elif command.startswith("get "): return self._cmd_get(command[4:]) - + # Set commands elif command.startswith("set "): return self._cmd_set(command[4:]) - + # ACL commands elif command.startswith("setperm "): return self._cmd_setperm(command) elif command == "get acl": return "Error: Use 'get acl' via serial console only" - + # Region commands (repeaters only) elif command.startswith("region"): if self.enable_regions: return self._cmd_region(command) else: return "Error: Region commands not available for room servers" - + # Neighbor commands elif command == "neighbors": return self._cmd_neighbors() elif command.startswith("neighbor.remove "): return self._cmd_neighbor_remove(command) - + # Temporary radio params elif command.startswith("tempradio "): return self._cmd_tempradio(command) - + # Sensor commands elif command.startswith("sensor "): return "Error: Sensor commands not implemented in Python repeater" - + # GPS commands elif command.startswith("gps"): return "Error: GPS commands not implemented in Python repeater" - + # Logging commands elif command.startswith("log "): return self._cmd_log(command) - + # Statistics commands elif command.startswith("stats-"): return "Error: Stats commands not fully implemented yet" - + else: return "Unknown command" - + # ==================== System Commands ==================== - + def _cmd_reboot(self) -> str: """Reboot the repeater process.""" from repeater.service_utils import restart_service - + logger.warning("Reboot command received via mesh CLI") success, message = restart_service() - + if success: return f"OK - {message}" else: return f"Error: {message}" - + def _cmd_advert(self) -> str: """Send self advertisement.""" if not self.send_advert_callback: logger.warning("Advert command received but no callback configured") return "Error: Advert functionality not configured" - + try: import asyncio - + async def delayed_advert(): """Delay advert to let CLI response send first (matches C++ 1500ms delay).""" await asyncio.sleep(1.5) await self.send_advert_callback() - + asyncio.create_task(delayed_advert()) logger.info("Advert scheduled for sending (1.5s delay)") return "OK - Advert sent" except Exception as e: logger.error(f"Failed to schedule advert: {e}", exc_info=True) return f"Error: {e}" - + def _cmd_clock(self, command: str) -> str: """Handle clock commands.""" if command == "clock": # Display current time import datetime + dt = datetime.datetime.utcnow() return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC" elif command == "clock sync": @@ -177,94 +179,94 @@ class MeshCLI: return "OK - clock sync not needed (system time used)" else: return "Unknown clock command" - + def _cmd_time(self, command: str) -> str: """Set time - not supported in Python (use system time).""" return "Error: Time setting not supported (system time is used)" - + def _cmd_password(self, command: str) -> str: """Change admin password.""" new_password = command[9:].strip() - + if not new_password: return "Error: Password cannot be empty" - + # Update security config - if 'security' not in self.config: - self.config['security'] = {} - - self.config['security']['password'] = new_password - + if "security" not in self.config: + self.config["security"] = {} + + self.config["security"]["password"] = new_password + # Save config and live update try: saved, err = self.config_manager.save_to_file() if not saved: logger.error(f"Failed to save password: {err}") return f"Error: Failed to save config: {err}" - self.config_manager.live_update_daemon(['security']) + self.config_manager.live_update_daemon(["security"]) return f"password now: {new_password}" except Exception as e: logger.error(f"Failed to save password: {e}") return "Error: Failed to save password" - + def _cmd_clear_stats(self) -> str: """Clear statistics.""" # TODO: Implement stats clearing return "Error: Not yet implemented" - + def _cmd_version(self) -> str: """Get version information.""" role = "room_server" if self.identity_type == "room_server" else "repeater" - version = self.config.get('version', '1.0.0') + version = self.config.get("version", "1.0.0") return f"pyMC_{role} v{version}" - + # ==================== Get Commands ==================== - + def _cmd_get(self, param: str) -> str: """Handle get commands.""" param = param.strip() logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})") - + if param == "af": - af = self.repeater_config.get('airtime_factor', 1.0) + af = self.repeater_config.get("airtime_factor", 1.0) return f"> {af}" - + elif param == "name": - name = self.repeater_config.get('name', 'Unknown') + name = self.repeater_config.get("name", "Unknown") return f"> {name}" - + elif param == "repeat": - disabled = self.repeater_config.get('disable_forward', False) + disabled = self.repeater_config.get("disable_forward", False) return f"> {'off' if disabled else 'on'}" - + elif param == "lat": - lat = self.repeater_config.get('latitude', 0.0) + lat = self.repeater_config.get("latitude", 0.0) return f"> {lat}" - + elif param == "lon": - lon = self.repeater_config.get('longitude', 0.0) + lon = self.repeater_config.get("longitude", 0.0) return f"> {lon}" - + elif param == "radio": - radio = self.config.get('radio', {}) - freq_hz = radio.get('frequency', 915000000) - bw_hz = radio.get('bandwidth', 125000) - sf = radio.get('spreading_factor', 7) - cr = radio.get('coding_rate', 5) + radio = self.config.get("radio", {}) + freq_hz = radio.get("frequency", 915000000) + bw_hz = radio.get("bandwidth", 125000) + sf = radio.get("spreading_factor", 7) + cr = radio.get("coding_rate", 5) # Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output) freq_mhz = freq_hz / 1_000_000.0 bw_khz = bw_hz / 1_000.0 return f"> {freq_mhz},{bw_khz},{sf},{cr}" - + elif param == "freq": - freq_hz = self.config.get('radio', {}).get('frequency', 915000000) + freq_hz = self.config.get("radio", {}).get("frequency", 915000000) freq_mhz = freq_hz / 1_000_000.0 return f"> {freq_mhz}" - + elif param == "tx": - power = self.config.get('radio', {}).get('tx_power', 20) + power = self.config.get("radio", {}).get("tx_power", 20) return f"> {power}" - + elif param == "public.key": if not self.identity: return "Error: Identity not available" @@ -275,263 +277,263 @@ class MeshCLI: except Exception as e: logger.error(f"Failed to get public key: {e}") return f"Error: {e}" - + elif param == "role": role = "room_server" if self.identity_type == "room_server" else "repeater" return f"> {role}" - + elif param == "guest.password": - guest_pw = self.config.get('security', {}).get('guest_password', '') + guest_pw = self.config.get("security", {}).get("guest_password", "") return f"> {guest_pw}" - + elif param == "allow.read.only": - allow = self.config.get('security', {}).get('allow_read_only', False) + allow = self.config.get("security", {}).get("allow_read_only", False) return f"> {'on' if allow else 'off'}" - + elif param == "advert.interval": - interval = self.repeater_config.get('advert_interval_minutes', 120) + interval = self.repeater_config.get("advert_interval_minutes", 120) return f"> {interval}" - + elif param == "flood.advert.interval": - interval = self.repeater_config.get('flood_advert_interval_hours', 24) + interval = self.repeater_config.get("flood_advert_interval_hours", 24) return f"> {interval}" - + elif param == "flood.max": - max_flood = self.repeater_config.get('max_flood_hops', 3) + max_flood = self.repeater_config.get("max_flood_hops", 3) return f"> {max_flood}" - + elif param == "rxdelay": - delay = self.repeater_config.get('rx_delay_base', 0.0) + delay = self.repeater_config.get("rx_delay_base", 0.0) return f"> {delay}" - + elif param == "txdelay": - delay = self.repeater_config.get('tx_delay_factor', 1.0) + delay = self.repeater_config.get("tx_delay_factor", 1.0) return f"> {delay}" - + elif param == "direct.txdelay": - delay = self.repeater_config.get('direct_tx_delay_factor', 0.5) + delay = self.repeater_config.get("direct_tx_delay_factor", 0.5) return f"> {delay}" - + elif param == "multi.acks": - acks = self.repeater_config.get('multi_acks', 0) + acks = self.repeater_config.get("multi_acks", 0) return f"> {acks}" - + elif param == "int.thresh": - thresh = self.repeater_config.get('interference_threshold', -120) + thresh = self.repeater_config.get("interference_threshold", -120) return f"> {thresh}" - + elif param == "agc.reset.interval": - interval = self.repeater_config.get('agc_reset_interval', 0) + interval = self.repeater_config.get("agc_reset_interval", 0) return f"> {interval}" - + else: return f"??: {param}" - + # ==================== Set Commands ==================== - + def _cmd_set(self, param: str) -> str: """Handle set commands.""" parts = param.split(None, 1) if len(parts) < 2: return "Error: Missing value" - + key, value = parts[0], parts[1] - + try: if key == "af": - self.repeater_config['airtime_factor'] = float(value) + self.repeater_config["airtime_factor"] = float(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "name": - self.repeater_config['node_name'] = value + self.repeater_config["node_name"] = value saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "repeat": disabled = value.lower() == "off" - self.repeater_config['disable_forward'] = disabled + self.repeater_config["disable_forward"] = disabled saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return f"OK - repeat is now {'OFF' if disabled else 'ON'}" - + elif key == "lat": - self.repeater_config['latitude'] = float(value) + self.repeater_config["latitude"] = float(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "lon": - self.repeater_config['longitude'] = float(value) + self.repeater_config["longitude"] = float(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "radio": # Format: freq bw sf cr radio_parts = value.split() if len(radio_parts) != 4: return "Error: Expected freq bw sf cr" - - if 'radio' not in self.config: - self.config['radio'] = {} - - self.config['radio']['frequency'] = float(radio_parts[0]) - self.config['radio']['bandwidth'] = float(radio_parts[1]) - self.config['radio']['spreading_factor'] = int(radio_parts[2]) - self.config['radio']['coding_rate'] = int(radio_parts[3]) + + if "radio" not in self.config: + self.config["radio"] = {} + + self.config["radio"]["frequency"] = float(radio_parts[0]) + self.config["radio"]["bandwidth"] = float(radio_parts[1]) + self.config["radio"]["spreading_factor"] = int(radio_parts[2]) + self.config["radio"]["coding_rate"] = int(radio_parts[3]) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['radio']) + self.config_manager.live_update_daemon(["radio"]) return "OK - restart repeater to apply" - + elif key == "freq": - if 'radio' not in self.config: - self.config['radio'] = {} - self.config['radio']['frequency'] = float(value) + if "radio" not in self.config: + self.config["radio"] = {} + self.config["radio"]["frequency"] = float(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['radio']) + self.config_manager.live_update_daemon(["radio"]) return "OK - restart repeater to apply" - + elif key == "tx": - if 'radio' not in self.config: - self.config['radio'] = {} - self.config['radio']['tx_power'] = int(value) + if "radio" not in self.config: + self.config["radio"] = {} + self.config["radio"]["tx_power"] = int(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['radio']) + self.config_manager.live_update_daemon(["radio"]) return "OK" - + elif key == "guest.password": - if 'security' not in self.config: - self.config['security'] = {} - self.config['security']['guest_password'] = value + if "security" not in self.config: + self.config["security"] = {} + self.config["security"]["guest_password"] = value saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['security']) + self.config_manager.live_update_daemon(["security"]) return "OK" - + elif key == "allow.read.only": - if 'security' not in self.config: - self.config['security'] = {} - self.config['security']['allow_read_only'] = value.lower() == "on" + if "security" not in self.config: + self.config["security"] = {} + self.config["security"]["allow_read_only"] = value.lower() == "on" saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['security']) + self.config_manager.live_update_daemon(["security"]) return "OK" - + elif key == "advert.interval": mins = int(value) if mins > 0 and (mins < 60 or mins > 240): return "Error: interval range is 60-240 minutes" - self.repeater_config['advert_interval_minutes'] = mins + self.repeater_config["advert_interval_minutes"] = mins saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "flood.advert.interval": hours = int(value) if (hours > 0 and hours < 3) or hours > 48: return "Error: interval range is 3-48 hours" - self.repeater_config['flood_advert_interval_hours'] = hours + self.repeater_config["flood_advert_interval_hours"] = hours saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "flood.max": max_val = int(value) if max_val > 64: return "Error: max 64" - self.repeater_config['max_flood_hops'] = max_val + self.repeater_config["max_flood_hops"] = max_val saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "rxdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['rx_delay_base'] = delay + self.repeater_config["rx_delay_base"] = delay saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater', 'delays']) + self.config_manager.live_update_daemon(["repeater", "delays"]) return "OK" - + elif key == "txdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['tx_delay_factor'] = delay + self.repeater_config["tx_delay_factor"] = delay saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater', 'delays']) + self.config_manager.live_update_daemon(["repeater", "delays"]) return "OK" - + elif key == "direct.txdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['direct_tx_delay_factor'] = delay + self.repeater_config["direct_tx_delay_factor"] = delay saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater', 'delays']) + self.config_manager.live_update_daemon(["repeater", "delays"]) return "OK" - + elif key == "multi.acks": - self.repeater_config['multi_acks'] = int(value) + self.repeater_config["multi_acks"] = int(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "int.thresh": - self.repeater_config['interference_threshold'] = int(value) + self.repeater_config["interference_threshold"] = int(value) saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return "OK" - + elif key == "agc.reset.interval": interval = int(value) # Round to nearest multiple of 4 rounded = (interval // 4) * 4 - self.repeater_config['agc_reset_interval'] = rounded + self.repeater_config["agc_reset_interval"] = rounded saved, _ = self.config_manager.save_to_file() - self.config_manager.live_update_daemon(['repeater']) + self.config_manager.live_update_daemon(["repeater"]) return f"OK - interval rounded to {rounded}" - + else: return f"unknown config: {key}" - + except ValueError as e: return f"Error: invalid value - {e}" except Exception as e: logger.error(f"Set command error: {e}") return f"Error: {e}" - + # ==================== ACL Commands ==================== - + def _cmd_setperm(self, command: str) -> str: """Set permissions for a public key.""" # Format: setperm {pubkey-hex} {permissions-int} parts = command[8:].split() if len(parts) < 2: return "Err - bad params" - + pubkey_hex = parts[0] try: permissions = int(parts[1]) except ValueError: return "Err - invalid permissions" - + # TODO: Apply permissions via ACL logger.info(f"setperm command: {pubkey_hex} -> {permissions}") return "Error: Not yet implemented - use config file" - + # ==================== Region Commands ==================== - + def _cmd_region(self, command: str) -> str: """Handle region commands.""" parts = command.split() - + if len(parts) == 1: return "Error: Region commands not implemented in Python repeater" - + subcommand = parts[1] - + if subcommand == "load": return "Error: Region commands not implemented" elif subcommand == "save": @@ -540,80 +542,82 @@ class MeshCLI: return "Error: Region commands not implemented" else: return "Err - ??" - + # ==================== Neighbor Commands ==================== - + def _cmd_neighbors(self) -> str: """List neighbors.""" if not self.storage_handler: return "Error: Storage not available" - + try: neighbors = self.storage_handler.get_neighbors() - + if not neighbors: return "No neighbors discovered yet" - + # Filter to only show repeaters and zero hop nodes filtered_neighbors = { - pubkey: info for pubkey, info in neighbors.items() - if info.get('is_repeater', False) or info.get('zero_hop', False) + pubkey: info + for pubkey, info in neighbors.items() + if info.get("is_repeater", False) or info.get("zero_hop", False) } - + if not filtered_neighbors: return "No repeaters or zero hop neighbors discovered yet" - + # Format output similar to C++ version # Format: " heard Xs ago" import time + current_time = int(time.time()) - + lines = [] for pubkey, info in filtered_neighbors.items(): - last_seen = info.get('last_seen', 0) + last_seen = info.get("last_seen", 0) seconds_ago = int(current_time - last_seen) - + # Get first 4 bytes of pubkey as hex (match C++ format) pubkey_short = pubkey[:8] if len(pubkey) >= 8 else pubkey - snr = info.get('snr', 0) or 0 - + snr = info.get("snr", 0) or 0 + # Format: <4byte_hex>:: (matches C++ format) lines.append(f"{pubkey_short}:{seconds_ago}:{int(snr)}") - + return "\n".join(lines) - + except Exception as e: logger.error(f"Failed to list neighbors: {e}", exc_info=True) return f"Error: {e}" - + def _cmd_neighbor_remove(self, command: str) -> str: """Remove a neighbor.""" pubkey_hex = command[16:].strip() - + if not pubkey_hex: return "ERR: Missing pubkey" - + # TODO: Remove neighbor from routing table logger.info(f"neighbor.remove: {pubkey_hex}") return "Error: Not yet implemented" - + # ==================== Temporary Radio Commands ==================== - + def _cmd_tempradio(self, command: str) -> str: """Apply temporary radio parameters.""" # Format: tempradio {freq} {bw} {sf} {cr} {timeout_mins} parts = command[10:].split() - + if len(parts) < 5: return "Error: Expected freq bw sf cr timeout_mins" - + try: freq = float(parts[0]) bw = float(parts[1]) sf = int(parts[2]) cr = int(parts[3]) timeout_mins = int(parts[4]) - + # Validate if not (300.0 <= freq <= 2500.0): return "Error: invalid frequency" @@ -625,16 +629,16 @@ class MeshCLI: return "Error: invalid coding rate" if timeout_mins <= 0: return "Error: invalid timeout" - + # TODO: Apply temporary radio parameters logger.info(f"tempradio: {freq}MHz {bw}kHz SF{sf} CR4/{cr} for {timeout_mins}min") return "Error: Not yet implemented" - + except ValueError: return "Error, invalid params" - + # ==================== Logging Commands ==================== - + def _cmd_log(self, command: str) -> str: """Handle log commands.""" if command == "log start": diff --git a/repeater/handler_helpers/path.py b/repeater/handler_helpers/path.py index fecacd1..d482118 100644 --- a/repeater/handler_helpers/path.py +++ b/repeater/handler_helpers/path.py @@ -13,20 +13,20 @@ class PathHelper: async def process_path_packet(self, packet): from pymc_core.protocol.crypto import CryptoUtils - + try: if len(packet.payload) < 2: return False - + dest_hash = packet.payload[0] src_hash = packet.payload[1] - + # Get the ACL for this destination identity identity_acl = self.acl_dict.get(dest_hash) if not identity_acl: logger.debug(f"No ACL for dest 0x{dest_hash:02X}, allowing forward") return False - + # Find the client by source hash client = None for client_info in identity_acl.get_all_clients(): @@ -34,57 +34,59 @@ class PathHelper: if pubkey[0] == src_hash: client = client_info break - + if not client: logger.debug(f"PATH packet from unknown client 0x{src_hash:02X}, allowing forward") return False - + # Get shared secret for decryption shared_secret = client.shared_secret if not shared_secret or len(shared_secret) == 0: logger.debug(f"No shared secret for client 0x{src_hash:02X}, cannot decrypt PATH") return False - + # Decrypt the PATH packet payload # Payload format: dest_hash(1) + src_hash(1) + mac(2) + encrypted_data if len(packet.payload) < 4: logger.debug(f"PATH packet too short: {len(packet.payload)} bytes") return False - + mac_and_data = packet.payload[2:] # Skip dest_hash and src_hash aes_key = shared_secret[:16] decrypted = CryptoUtils.mac_then_decrypt(aes_key, shared_secret, mac_and_data) - + if not decrypted: logger.debug(f"Failed to decrypt PATH packet from 0x{src_hash:02X}") return False - + # Parse decrypted PATH data # Format: path_len(1) + path[path_len] + extra_type(1) + extra[...] if len(decrypted) < 1: logger.debug(f"Decrypted PATH data too short") return False - + path_len = decrypted[0] if len(decrypted) < 1 + path_len: - logger.debug(f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}") + logger.debug( + f"PATH data truncated: need {1 + path_len} bytes, got {len(decrypted)}" + ) return False - - path_data = decrypted[1:1 + path_len] - + + path_data = decrypted[1 : 1 + path_len] + # Update client's out_path (same as C++ memcpy) client.out_path = bytearray(path_data) client.out_path_len = path_len client.last_activity = int(time.time()) - + logger.info( f"Updated out_path for client 0x{src_hash:02X} -> 0x{dest_hash:02X}: " f"path_len={path_len}, path={[hex(b) for b in path_data]}" ) - + # Don't mark as do_not_retransmit - let it forward normally return False - + except Exception as e: logger.error(f"Error processing PATH packet: {e}", exc_info=True) return False diff --git a/repeater/handler_helpers/protocol_request.py b/repeater/handler_helpers/protocol_request.py index 376117b..5490a65 100644 --- a/repeater/handler_helpers/protocol_request.py +++ b/repeater/handler_helpers/protocol_request.py @@ -10,12 +10,12 @@ import struct import time from pymc_core.node.handlers.protocol_request import ( - ProtocolRequestHandler, - REQ_TYPE_GET_STATUS, - REQ_TYPE_GET_TELEMETRY_DATA, REQ_TYPE_GET_ACCESS_LIST, REQ_TYPE_GET_NEIGHBOURS, - SERVER_RESPONSE_DELAY_MS + REQ_TYPE_GET_STATUS, + REQ_TYPE_GET_TELEMETRY_DATA, + SERVER_RESPONSE_DELAY_MS, + ProtocolRequestHandler, ) logger = logging.getLogger("ProtocolRequestHelper") @@ -23,8 +23,16 @@ logger = logging.getLogger("ProtocolRequestHelper") class ProtocolRequestHelper: """Provides repeater-specific protocol request handlers.""" - - def __init__(self, identity_manager, packet_injector=None, acl_dict=None, radio=None, engine=None, neighbor_tracker=None): + + def __init__( + self, + identity_manager, + packet_injector=None, + acl_dict=None, + radio=None, + engine=None, + neighbor_tracker=None, + ): self.identity_manager = identity_manager self.packet_injector = packet_injector @@ -71,9 +79,10 @@ class ProtocolRequestHelper: } logger.info(f"Registered protocol request handler for '{name}': hash=0x{hash_byte:02X}") - + def _create_acl_contacts_wrapper(self, acl): """Create contacts wrapper from ACL.""" + class ACLContactsWrapper: def __init__(self, identity_acl): self._acl = identity_acl @@ -138,22 +147,32 @@ class ProtocolRequestHelper: # uint32_t n_direct_dups; # uint32_t n_flood_dups; # uint32_t total_rx_air_time_secs; - + # Get stats from radio/engine noise_floor = int(self.radio.get_noise_floor() * 1.0) if self.radio else -120 - last_rssi = int(self.radio.last_rssi) if self.radio and hasattr(self.radio, 'last_rssi') else -120 - last_snr = int((self.radio.last_snr * 4.0) if self.radio and hasattr(self.radio, 'last_snr') else 0) - + last_rssi = ( + int(self.radio.last_rssi) if self.radio and hasattr(self.radio, "last_rssi") else -120 + ) + last_snr = int( + (self.radio.last_snr * 4.0) if self.radio and hasattr(self.radio, "last_snr") else 0 + ) + # Get packet counts - n_packets_recv = self.radio.packets_received if self.radio and hasattr(self.radio, 'packets_received') else 0 - n_packets_sent = self.radio.packets_sent if self.radio and hasattr(self.radio, 'packets_sent') else 0 - + n_packets_recv = ( + self.radio.packets_received + if self.radio and hasattr(self.radio, "packets_received") + else 0 + ) + n_packets_sent = ( + self.radio.packets_sent if self.radio and hasattr(self.radio, "packets_sent") else 0 + ) + # Get airtime stats total_air_time_secs = 0 total_rx_air_time_secs = 0 - if self.engine and hasattr(self.engine, 'airtime_manager'): + if self.engine and hasattr(self.engine, "airtime_manager"): total_air_time_secs = int(self.engine.airtime_manager.total_tx_airtime_ms / 1000) - + # Get routing stats n_sent_flood = 0 n_sent_direct = 0 @@ -161,18 +180,18 @@ class ProtocolRequestHelper: n_recv_direct = 0 n_direct_dups = 0 n_flood_dups = 0 - + if self.engine: - n_sent_flood = getattr(self.engine, 'sent_flood_count', 0) - n_sent_direct = getattr(self.engine, 'sent_direct_count', 0) - n_recv_flood = getattr(self.engine, 'recv_flood_count', 0) - n_recv_direct = getattr(self.engine, 'recv_direct_count', 0) - n_direct_dups = getattr(self.engine, 'direct_dup_count', 0) - n_flood_dups = getattr(self.engine, 'flood_dup_count', 0) - + n_sent_flood = getattr(self.engine, "sent_flood_count", 0) + n_sent_direct = getattr(self.engine, "sent_direct_count", 0) + n_recv_flood = getattr(self.engine, "recv_flood_count", 0) + n_recv_direct = getattr(self.engine, "recv_direct_count", 0) + n_direct_dups = getattr(self.engine, "direct_dup_count", 0) + n_flood_dups = getattr(self.engine, "flood_dup_count", 0) + # Pack struct (little-endian) stats = struct.pack( - ' str: """ Handle an incoming command from a client. @@ -64,10 +65,10 @@ class MeshCLI: return "Error: Admin permission required" logger.debug(f"handle_command received: '{command}' (len={len(command)})") - + # Extract optional sequence prefix (XX|) prefix = "" - if len(command) > 4 and command[2] == '|': + if len(command) > 4 and command[2] == "|": prefix = command[:3] command = command[3:] logger.debug(f"Extracted prefix: '{prefix}', remaining command: '{command}'") @@ -180,6 +181,7 @@ class MeshCLI: if command == "clock": # Display current time import datetime + dt = datetime.datetime.utcnow() return f"{dt.hour:02d}:{dt.minute:02d} - {dt.day}/{dt.month}/{dt.year} UTC" elif command == "clock sync": @@ -198,13 +200,13 @@ class MeshCLI: if not new_password: return "Error: Password cannot be empty" - + # Update security config - if 'security' not in self.config: - self.config['security'] = {} - - self.config['security']['password'] = new_password - + if "security" not in self.config: + self.config["security"] = {} + + self.config["security"]["password"] = new_password + # Save config try: self.save_config() @@ -221,56 +223,56 @@ class MeshCLI: def _cmd_version(self) -> str: """Get version information.""" role = "room_server" if self.identity_type == "room_server" else "repeater" - version = self.config.get('version', '1.0.0') + version = self.config.get("version", "1.0.0") return f"pyMC_{role} v{version}" - + # ==================== Get Commands ==================== def _cmd_get(self, param: str) -> str: """Handle get commands.""" param = param.strip() logger.debug(f"_cmd_get called with param: '{param}' (len={len(param)})") - + if param == "af": - af = self.repeater_config.get('airtime_factor', 1.0) + af = self.repeater_config.get("airtime_factor", 1.0) return f"> {af}" - + elif param == "name": - name = self.repeater_config.get('name', 'Unknown') + name = self.repeater_config.get("name", "Unknown") return f"> {name}" - + elif param == "repeat": - disabled = self.repeater_config.get('disable_forward', False) + disabled = self.repeater_config.get("disable_forward", False) return f"> {'off' if disabled else 'on'}" - + elif param == "lat": - lat = self.repeater_config.get('latitude', 0.0) + lat = self.repeater_config.get("latitude", 0.0) return f"> {lat}" - + elif param == "lon": - lon = self.repeater_config.get('longitude', 0.0) + lon = self.repeater_config.get("longitude", 0.0) return f"> {lon}" - + elif param == "radio": - radio = self.config.get('radio', {}) - freq_hz = radio.get('frequency', 915000000) - bw_hz = radio.get('bandwidth', 125000) - sf = radio.get('spreading_factor', 7) - cr = radio.get('coding_rate', 5) + radio = self.config.get("radio", {}) + freq_hz = radio.get("frequency", 915000000) + bw_hz = radio.get("bandwidth", 125000) + sf = radio.get("spreading_factor", 7) + cr = radio.get("coding_rate", 5) # Convert Hz to MHz for freq, Hz to kHz for bandwidth (match C++ ftoa output) freq_mhz = freq_hz / 1_000_000.0 bw_khz = bw_hz / 1_000.0 return f"> {freq_mhz},{bw_khz},{sf},{cr}" - + elif param == "freq": - freq_hz = self.config.get('radio', {}).get('frequency', 915000000) + freq_hz = self.config.get("radio", {}).get("frequency", 915000000) freq_mhz = freq_hz / 1_000_000.0 return f"> {freq_mhz}" - + elif param == "tx": - power = self.config.get('radio', {}).get('tx_power', 20) + power = self.config.get("radio", {}).get("tx_power", 20) return f"> {power}" - + elif param == "public.key": # TODO: Get from identity return "Error: Not yet implemented" @@ -278,51 +280,51 @@ class MeshCLI: elif param == "role": role = "room_server" if self.identity_type == "room_server" else "repeater" return f"> {role}" - + elif param == "guest.password": - guest_pw = self.config.get('security', {}).get('guest_password', '') + guest_pw = self.config.get("security", {}).get("guest_password", "") return f"> {guest_pw}" - + elif param == "allow.read.only": - allow = self.config.get('security', {}).get('allow_read_only', False) + allow = self.config.get("security", {}).get("allow_read_only", False) return f"> {'on' if allow else 'off'}" - + elif param == "advert.interval": - interval = self.repeater_config.get('advert_interval_minutes', 120) + interval = self.repeater_config.get("advert_interval_minutes", 120) return f"> {interval}" - + elif param == "flood.advert.interval": - interval = self.repeater_config.get('flood_advert_interval_hours', 24) + interval = self.repeater_config.get("flood_advert_interval_hours", 24) return f"> {interval}" - + elif param == "flood.max": - max_flood = self.repeater_config.get('max_flood_hops', 3) + max_flood = self.repeater_config.get("max_flood_hops", 3) return f"> {max_flood}" - + elif param == "rxdelay": - delay = self.repeater_config.get('rx_delay_base', 0.0) + delay = self.repeater_config.get("rx_delay_base", 0.0) return f"> {delay}" - + elif param == "txdelay": - delay = self.repeater_config.get('tx_delay_factor', 1.0) + delay = self.repeater_config.get("tx_delay_factor", 1.0) return f"> {delay}" - + elif param == "direct.txdelay": - delay = self.repeater_config.get('direct_tx_delay_factor', 0.5) + delay = self.repeater_config.get("direct_tx_delay_factor", 0.5) return f"> {delay}" - + elif param == "multi.acks": - acks = self.repeater_config.get('multi_acks', 0) + acks = self.repeater_config.get("multi_acks", 0) return f"> {acks}" - + elif param == "int.thresh": - thresh = self.repeater_config.get('interference_threshold', -120) + thresh = self.repeater_config.get("interference_threshold", -120) return f"> {thresh}" - + elif param == "agc.reset.interval": - interval = self.repeater_config.get('agc_reset_interval', 0) + interval = self.repeater_config.get("agc_reset_interval", 0) return f"> {interval}" - + else: return f"??: {param}" @@ -335,144 +337,144 @@ class MeshCLI: return "Error: Missing value" key, value = parts[0], parts[1] - + try: if key == "af": - self.repeater_config['airtime_factor'] = float(value) + self.repeater_config["airtime_factor"] = float(value) self.save_config() return "OK" - + elif key == "name": - self.repeater_config['name'] = value + self.repeater_config["name"] = value self.save_config() return "OK" - + elif key == "repeat": disabled = value.lower() == "off" - self.repeater_config['disable_forward'] = disabled + self.repeater_config["disable_forward"] = disabled self.save_config() return f"OK - repeat is now {'OFF' if disabled else 'ON'}" - + elif key == "lat": - self.repeater_config['latitude'] = float(value) + self.repeater_config["latitude"] = float(value) self.save_config() return "OK" - + elif key == "lon": - self.repeater_config['longitude'] = float(value) + self.repeater_config["longitude"] = float(value) self.save_config() return "OK" - + elif key == "radio": # Format: freq bw sf cr radio_parts = value.split() if len(radio_parts) != 4: return "Error: Expected freq bw sf cr" - - if 'radio' not in self.config: - self.config['radio'] = {} - - self.config['radio']['frequency'] = float(radio_parts[0]) - self.config['radio']['bandwidth'] = float(radio_parts[1]) - self.config['radio']['spreading_factor'] = int(radio_parts[2]) - self.config['radio']['coding_rate'] = int(radio_parts[3]) + + if "radio" not in self.config: + self.config["radio"] = {} + + self.config["radio"]["frequency"] = float(radio_parts[0]) + self.config["radio"]["bandwidth"] = float(radio_parts[1]) + self.config["radio"]["spreading_factor"] = int(radio_parts[2]) + self.config["radio"]["coding_rate"] = int(radio_parts[3]) self.save_config() return "OK - restart repeater to apply" - + elif key == "freq": - if 'radio' not in self.config: - self.config['radio'] = {} - self.config['radio']['frequency'] = float(value) + if "radio" not in self.config: + self.config["radio"] = {} + self.config["radio"]["frequency"] = float(value) self.save_config() return "OK - restart repeater to apply" - + elif key == "tx": - if 'radio' not in self.config: - self.config['radio'] = {} - self.config['radio']['tx_power'] = int(value) + if "radio" not in self.config: + self.config["radio"] = {} + self.config["radio"]["tx_power"] = int(value) self.save_config() return "OK" - + elif key == "guest.password": - if 'security' not in self.config: - self.config['security'] = {} - self.config['security']['guest_password'] = value + if "security" not in self.config: + self.config["security"] = {} + self.config["security"]["guest_password"] = value self.save_config() return "OK" - + elif key == "allow.read.only": - if 'security' not in self.config: - self.config['security'] = {} - self.config['security']['allow_read_only'] = value.lower() == "on" + if "security" not in self.config: + self.config["security"] = {} + self.config["security"]["allow_read_only"] = value.lower() == "on" self.save_config() return "OK" - + elif key == "advert.interval": mins = int(value) if mins > 0 and (mins < 60 or mins > 240): return "Error: interval range is 60-240 minutes" - self.repeater_config['advert_interval_minutes'] = mins + self.repeater_config["advert_interval_minutes"] = mins self.save_config() return "OK" - + elif key == "flood.advert.interval": hours = int(value) if (hours > 0 and hours < 3) or hours > 48: return "Error: interval range is 3-48 hours" - self.repeater_config['flood_advert_interval_hours'] = hours + self.repeater_config["flood_advert_interval_hours"] = hours self.save_config() return "OK" - + elif key == "flood.max": max_val = int(value) if max_val > 64: return "Error: max 64" - self.repeater_config['max_flood_hops'] = max_val + self.repeater_config["max_flood_hops"] = max_val self.save_config() return "OK" - + elif key == "rxdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['rx_delay_base'] = delay + self.repeater_config["rx_delay_base"] = delay self.save_config() return "OK" - + elif key == "txdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['tx_delay_factor'] = delay + self.repeater_config["tx_delay_factor"] = delay self.save_config() return "OK" - + elif key == "direct.txdelay": delay = float(value) if delay < 0: return "Error: cannot be negative" - self.repeater_config['direct_tx_delay_factor'] = delay + self.repeater_config["direct_tx_delay_factor"] = delay self.save_config() return "OK" - + elif key == "multi.acks": - self.repeater_config['multi_acks'] = int(value) + self.repeater_config["multi_acks"] = int(value) self.save_config() return "OK" - + elif key == "int.thresh": - self.repeater_config['interference_threshold'] = int(value) + self.repeater_config["interference_threshold"] = int(value) self.save_config() return "OK" - + elif key == "agc.reset.interval": interval = int(value) # Round to nearest multiple of 4 rounded = (interval // 4) * 4 - self.repeater_config['agc_reset_interval'] = rounded + self.repeater_config["agc_reset_interval"] = rounded self.save_config() return f"OK - interval rounded to {rounded}" - + else: return f"unknown config: {key}" diff --git a/repeater/handler_helpers/room_server.py b/repeater/handler_helpers/room_server.py index 01e0ebd..f65b0c6 100644 --- a/repeater/handler_helpers/room_server.py +++ b/repeater/handler_helpers/room_server.py @@ -1,9 +1,9 @@ import asyncio import logging import time -from typing import Optional, Dict +from typing import Dict, Optional -from pymc_core.protocol import PacketBuilder, CryptoUtils +from pymc_core.protocol import CryptoUtils, PacketBuilder from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG logger = logging.getLogger("RoomServer") @@ -51,7 +51,7 @@ class GlobalRateLimiter: self.min_gap = min_gap_seconds # Minimum gap between consecutive messages self.lock = asyncio.Lock() # Only one transmission at a time self.last_release_time = 0 - + async def acquire(self): async with self.lock: @@ -64,7 +64,7 @@ class GlobalRateLimiter: await asyncio.sleep(wait_time) # Lock is now held - caller can transmit # Will be released when context exits - + def release(self): self.last_release_time = time.time() @@ -82,43 +82,48 @@ class RoomServer: max_posts: int = 32, config_path: str = None, config: dict = None, - config_manager = None, - send_advert_callback = None + config_manager=None, + send_advert_callback=None, ): - + self.room_hash = room_hash self.room_name = room_name self.local_identity = local_identity self.db = sqlite_handler self.packet_injector = packet_injector self.acl = acl - + # Create send_advert callback for this room server async def send_room_advert(): """Send advertisement for this specific room server.""" if not packet_injector or not local_identity: - logger.error(f"Room '{room_name}': Cannot send advert - missing injector or identity") + logger.error( + f"Room '{room_name}': Cannot send advert - missing injector or identity" + ) return False - + try: from pymc_core.protocol import PacketBuilder - from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_ROOM_SERVER - + from pymc_core.protocol.constants import ( + ADVERT_FLAG_HAS_NAME, + ADVERT_FLAG_IS_ROOM_SERVER, + ) + # Get room config - room_config = config.get('identities', {}).get('room_servers', []) + room_config = config.get("identities", {}).get("room_servers", []) room_settings = {} for rs in room_config: - if rs.get('name') == room_name: - room_settings = rs.get('settings', {}) + if rs.get("name") == room_name: + room_settings = rs.get("settings", {}) break - + # Use room-specific name and location - node_name = room_settings.get('room_name', room_name) - latitude = room_settings.get('latitude', 0.0) - longitude = room_settings.get('longitude', 0.0) - + node_name = room_settings.get("room_name", room_name) + latitude = room_settings.get("latitude", 0.0) + longitude = room_settings.get("longitude", 0.0) + flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME - + packet = PacketBuilder.create_advert( local_identity=local_identity, name=node_name, @@ -129,21 +134,24 @@ class RoomServer: flags=flags, route_type="flood", ) - + # Send via packet injector await packet_injector(packet, wait_for_ack=False) - - logger.info(f"Room '{room_name}': Sent flood advert '{node_name}' at ({latitude:.6f}, {longitude:.6f})") + + logger.info( + f"Room '{room_name}': Sent flood advert '{node_name}' at ({latitude:.6f}, {longitude:.6f})" + ) return True - + except Exception as e: logger.error(f"Room '{room_name}': Failed to send advert: {e}", exc_info=True) return False - + # Initialize CLI handler for room server commands self.cli = None if config_path and config and config_manager: from .mesh_cli import MeshCLI + self.cli = MeshCLI( config_path, config, @@ -152,10 +160,10 @@ class RoomServer: enable_regions=False, # Room servers don't support region commands send_advert_callback=send_room_advert, identity=local_identity, - storage_handler=sqlite_handler + storage_handler=sqlite_handler, ) logger.info(f"Room '{room_name}': Initialized CLI handler with identity and storage") - + # Enforce hard limit (match C++ MAX_UNSYNCED_POSTS) if max_posts > MAX_UNSYNCED_POSTS: logger.warning( @@ -164,45 +172,45 @@ class RoomServer: ) max_posts = MAX_UNSYNCED_POSTS self.max_posts = max_posts - + # Round-robin state self.next_client_idx = 0 self.next_push_time = 0 - + # Cleanup tracking self.last_cleanup_time = time.time() self.cleanup_interval = 600 # Cleanup every 10 minutes - + # Safety and monitoring self.client_post_times = {} # Track last N post times per client for rate limiting self.consecutive_sync_errors = 0 # Circuit breaker counter self.last_eviction_check = time.time() self.eviction_check_interval = 300 # Check every 5 minutes - + # Initialize global rate limiter (singleton) global _global_push_limiter if _global_push_limiter is None: _global_push_limiter = GlobalRateLimiter(GLOBAL_MIN_GAP_BETWEEN_MESSAGES) self.global_limiter = _global_push_limiter - + # Background task handle self._sync_task = None self._running = False - + logger.info( f"RoomServer initialized: name='{room_name}', " f"hash=0x{room_hash:02X}, max_posts={max_posts}" ) - + async def start(self): if self._running: logger.warning(f"Room '{self.room_name}' sync loop already running") return - + self._running = True self._sync_task = asyncio.create_task(self._sync_loop()) logger.info(f"Room '{self.room_name}' sync loop started") - + async def stop(self): self._running = False if self._sync_task: @@ -212,14 +220,14 @@ class RoomServer: except asyncio.CancelledError: pass logger.info(f"Room '{self.room_name}' sync loop stopped") - + async def add_post( self, client_pubkey: bytes, message_text: str, sender_timestamp: int, txt_type: int = TXT_TYPE_PLAIN, - allow_server_author: bool = False + allow_server_author: bool = False, ) -> bool: try: @@ -230,20 +238,19 @@ class RoomServer: f"exceeds max length ({len(message_text)} > {MAX_MESSAGE_LENGTH}), truncating" ) message_text = message_text[:MAX_MESSAGE_LENGTH] - + # SAFETY: Rate limit per client client_key = client_pubkey.hex() now = time.time() - + if client_key not in self.client_post_times: self.client_post_times[client_key] = [] - + # Remove timestamps older than 1 minute self.client_post_times[client_key] = [ - t for t in self.client_post_times[client_key] - if now - t < 60 + t for t in self.client_post_times[client_key] if now - t < 60 ] - + # Check rate limit if len(self.client_post_times[client_key]) >= MAX_POSTS_PER_CLIENT_PER_MINUTE: logger.warning( @@ -251,13 +258,13 @@ class RoomServer: f"exceeded rate limit ({MAX_POSTS_PER_CLIENT_PER_MINUTE} posts/min), dropping message" ) return False - + # Record this post time self.client_post_times[client_key].append(now) - + # Use our RTC time for post_timestamp post_timestamp = time.time() - + # Store to database msg_id = self.db.insert_room_message( room_hash=f"0x{self.room_hash:02X}", @@ -265,22 +272,22 @@ class RoomServer: message_text=message_text, post_timestamp=post_timestamp, sender_timestamp=sender_timestamp, - txt_type=txt_type + txt_type=txt_type, ) - + if msg_id: logger.info( f"Room '{self.room_name}': New post #{msg_id} from " f"{client_pubkey[:4].hex()}: {message_text[:50]}" ) - + # Log authenticated clients count for debugging distribution all_clients = self.acl.get_all_clients() logger.info( f"Room '{self.room_name}': Message stored, will distribute to " f"{len(all_clients)} authenticated client(s)" ) - + # Update client's sync_since to this message's timestamp # This prevents the author from receiving their own message back # Also update activity timestamp (they're clearly active if posting) @@ -292,43 +299,43 @@ class RoomServer: room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex(), sync_since=post_timestamp, # Don't send this message back to author - last_activity=time.time() + last_activity=time.time(), ) - + # Trigger push notification self.next_push_time = time.time() + (PUSH_NOTIFY_DELAY_MS / 1000.0) - + return True else: logger.error(f"Failed to store message to database") return False - + except Exception as e: logger.error(f"Error adding post: {e}", exc_info=True) return False - + async def push_post_to_client(self, client_info, post: Dict) -> bool: - + try: # SAFETY: Global transmission lock - only ONE message on radio at a time # This is critical because LoRa is serial (0.5-9s airtime per message) await self.global_limiter.acquire() - + # SAFETY: Check client failure backoff sync_state = self.db.get_client_sync( room_hash=f"0x{self.room_hash:02X}", - client_pubkey=client_info.id.get_public_key().hex() + client_pubkey=client_info.id.get_public_key().hex(), ) - + if sync_state: - failures = sync_state.get('push_failures', 0) + failures = sync_state.get("push_failures", 0) if failures > 0: # Apply exponential backoff backoff_idx = min(failures, len(RETRY_BACKOFF_SCHEDULE) - 1) backoff_delay = RETRY_BACKOFF_SCHEDULE[backoff_idx] - last_failure_time = sync_state.get('updated_at', 0) + last_failure_time = sync_state.get("updated_at", 0) time_since_failure = time.time() - last_failure_time - + if time_since_failure < backoff_delay: wait_time = backoff_delay - time_since_failure logger.debug( @@ -336,33 +343,30 @@ class RoomServer: f"in backoff (failure {failures}), waiting {wait_time:.0f}s" ) return False # Skip this client for now - + # Build message payload timestamp = int(time.time()) - flags = (TXT_TYPE_SIGNED_PLAIN << 2) # Include author prefix - + flags = TXT_TYPE_SIGNED_PLAIN << 2 # Include author prefix + # Author prefix (first 4 bytes of pubkey) - author_pubkey = bytes.fromhex(post['author_pubkey']) + author_pubkey = bytes.fromhex(post["author_pubkey"]) author_prefix = author_pubkey[:4] - + # Plaintext: timestamp(4) + flags(1) + author_prefix(4) + text - message_bytes = post['message_text'].encode('utf-8') + message_bytes = post["message_text"].encode("utf-8") plaintext = ( - timestamp.to_bytes(4, 'little') + - bytes([flags]) + - author_prefix + - message_bytes + timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes ) - + # Calculate expected ACK (same algorithm as pymc_core) attempt = 0 pack_data = PacketBuilder._pack_timestamp_data(timestamp, attempt, message_bytes) ack_hash = CryptoUtils.sha256(pack_data + client_info.id.get_public_key())[:4] - expected_ack_crc = int.from_bytes(ack_hash, 'little') - + expected_ack_crc = int.from_bytes(ack_hash, "little") + # Determine routing based on stored out_path route_type = "flood" if client_info.out_path_len < 0 else "direct" - + # Create datagram packet = PacketBuilder.create_datagram( ptype=PAYLOAD_TYPE_TXT_MSG, @@ -370,41 +374,42 @@ class RoomServer: local_identity=self.local_identity, secret=client_info.shared_secret, plaintext=plaintext, - route_type=route_type + route_type=route_type, ) - + # Add stored path for direct routing if route_type == "direct" and len(client_info.out_path) > 0: - packet.path = bytearray(client_info.out_path[:client_info.out_path_len]) + packet.path = bytearray(client_info.out_path[: client_info.out_path_len]) packet.path_len = client_info.out_path_len - + # Calculate ACK timeout if route_type == "flood": ack_timeout = PUSH_ACK_TIMEOUT_FLOOD_MS / 1000.0 else: path_len = client_info.out_path_len if client_info.out_path_len >= 0 else 0 - ack_timeout = (PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1)) / 1000.0 - + ack_timeout = ( + PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1) + ) / 1000.0 + # Update client sync state with pending ACK self.db.upsert_client_sync( room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_info.id.get_public_key().hex(), pending_ack_crc=expected_ack_crc, - push_post_timestamp=post['post_timestamp'], - ack_timeout_time=time.time() + ack_timeout + push_post_timestamp=post["post_timestamp"], + ack_timeout_time=time.time() + ack_timeout, ) # Send packet (dispatcher will track ACK automatically) # This blocks for the entire transmission duration (0.5-9 seconds) success = await self.packet_injector(packet, wait_for_ack=True) - + # SAFETY: Release transmission lock AFTER send completes self.global_limiter.release() - + if success: # ACK received! Update sync state await self._handle_ack_received( - client_info.id.get_public_key(), - post['post_timestamp'] + client_info.id.get_public_key(), post["post_timestamp"] ) logger.info( f"Room '{self.room_name}': Pushed post to " @@ -417,13 +422,13 @@ class RoomServer: f"Room '{self.room_name}': Push to " f"0x{client_info.id.get_public_key()[0]:02X} timed out" ) - + return success - + except Exception as e: logger.error(f"Error pushing post to client: {e}", exc_info=True) return False - + async def _handle_ack_received(self, client_pubkey: bytes, post_timestamp: float): try: @@ -434,29 +439,28 @@ class RoomServer: sync_since=post_timestamp, pending_ack_crc=0, push_failures=0, - last_activity=time.time() + last_activity=time.time(), ) except Exception as e: logger.error(f"Error handling ACK received: {e}") - + async def _handle_ack_timeout(self, client_pubkey: bytes): try: # Get current sync state sync_state = self.db.get_client_sync( - room_hash=f"0x{self.room_hash:02X}", - client_pubkey=client_pubkey.hex() + room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex() ) - + if sync_state: # Increment failure counter, clear pending_ack - failures = sync_state.get('push_failures', 0) + 1 + failures = sync_state.get("push_failures", 0) + 1 self.db.upsert_client_sync( room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex(), push_failures=failures, - pending_ack_crc=0 + pending_ack_crc=0, ) - + if failures >= 3: logger.warning( f"Room '{self.room_name}': Client 0x{client_pubkey[0]:02X} " @@ -464,86 +468,86 @@ class RoomServer: ) except Exception as e: logger.error(f"Error handling ACK timeout: {e}") - + def get_unsynced_count(self, client_pubkey: bytes) -> int: try: # Get client's sync state sync_state = self.db.get_client_sync( - room_hash=f"0x{self.room_hash:02X}", - client_pubkey=client_pubkey.hex() + room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex() ) - - sync_since = sync_state['sync_since'] if sync_state else 0 - + + sync_since = sync_state["sync_since"] if sync_state else 0 + return self.db.get_unsynced_count( room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey.hex(), - sync_since=sync_since + sync_since=sync_since, ) except Exception as e: logger.error(f"Error getting unsynced count: {e}") return 0 - + async def _evict_failed_clients(self): try: now = time.time() all_sync_states = self.db.get_all_room_clients(f"0x{self.room_hash:02X}") - + for sync_state in all_sync_states: - client_pubkey_hex = sync_state['client_pubkey'] - push_failures = sync_state.get('push_failures', 0) - last_activity = sync_state.get('last_activity', 0) - + client_pubkey_hex = sync_state["client_pubkey"] + push_failures = sync_state.get("push_failures", 0) + last_activity = sync_state.get("last_activity", 0) + # Skip already-evicted clients (marked with last_activity=0) if last_activity == 0: continue - + evict = False reason = "" - + # Check max failures if push_failures >= MAX_PUSH_FAILURES: evict = True reason = f"max failures ({push_failures})" - + # Check inactivity timeout elif now - last_activity > INACTIVE_CLIENT_TIMEOUT: evict = True reason = f"inactive for {(now - last_activity) / 60:.0f} minutes" - + if evict: # Remove from database self.db.upsert_client_sync( room_hash=f"0x{self.room_hash:02X}", client_pubkey=client_pubkey_hex, - last_activity=0 # Mark as evicted + last_activity=0, # Mark as evicted ) - + # Remove from ACL client_pubkey = bytes.fromhex(client_pubkey_hex) self.acl.remove_client(client_pubkey) - + logger.info( f"Room '{self.room_name}': Evicted client " f"0x{client_pubkey[0]:02X} ({reason})" ) - + except Exception as e: logger.error(f"Error evicting failed clients: {e}", exc_info=True) - + async def _sync_loop(self): # SAFETY: Stagger room startup to prevent thundering herd import random + startup_delay = random.uniform(0, 5) # 0-5 second random delay await asyncio.sleep(startup_delay) - + logger.info(f"Room '{self.room_name}' sync loop starting (delayed {startup_delay:.1f}s)") - + while self._running: try: await asyncio.sleep(SYNC_PUSH_INTERVAL_MS / 1000.0) - + # SAFETY: Circuit breaker - stop if too many consecutive errors if self.consecutive_sync_errors >= MAX_CONSECUTIVE_SYNC_ERRORS: logger.error( @@ -553,21 +557,21 @@ class RoomServer: await asyncio.sleep(DB_ERROR_RETRY_DELAY) self.consecutive_sync_errors = 0 # Reset after pause continue - + # SAFETY: Periodic eviction check (every 5 minutes) if time.time() - self.last_eviction_check > self.eviction_check_interval: await self._evict_failed_clients() self.last_eviction_check = time.time() - + # Periodic cleanup check (every 10 minutes) if time.time() - self.last_cleanup_time > self.cleanup_interval: await self._cleanup_old_messages() self.last_cleanup_time = time.time() - + # Check if it's time to push if time.time() < self.next_push_time: continue - + # Get all clients for this room all_clients = self.acl.get_all_clients() if not all_clients: @@ -575,60 +579,66 @@ class RoomServer: # to avoid log spam when room is idle self.next_push_time = time.time() + 1.0 # Check again in 1 second continue - + # SAFETY: Limit number of clients if len(all_clients) > MAX_CLIENTS_PER_ROOM: logger.warning( f"Room '{self.room_name}': Too many clients ({len(all_clients)} > {MAX_CLIENTS_PER_ROOM})" ) all_clients = all_clients[:MAX_CLIENTS_PER_ROOM] - + # Check for ACK timeouts first await self._check_ack_timeouts() - + # Track how many clients we've checked in this iteration clients_checked = 0 max_checks = len(all_clients) - + # Round-robin: find next active client while clients_checked < max_checks: # Get next client if self.next_client_idx >= len(all_clients): self.next_client_idx = 0 - + client = all_clients[self.next_client_idx] self.next_client_idx = (self.next_client_idx + 1) % len(all_clients) clients_checked += 1 - + # Get client sync state sync_state = self.db.get_client_sync( room_hash=f"0x{self.room_hash:02X}", - client_pubkey=client.id.get_public_key().hex() + client_pubkey=client.id.get_public_key().hex(), ) - + # Skip if already waiting for ACK, evicted, or max failures if sync_state: - pending_ack = sync_state.get('pending_ack_crc', 0) - last_activity = sync_state.get('last_activity', 0) - push_failures = sync_state.get('push_failures', 0) - + pending_ack = sync_state.get("pending_ack_crc", 0) + last_activity = sync_state.get("last_activity", 0) + push_failures = sync_state.get("push_failures", 0) + if pending_ack != 0: - logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (waiting for ACK)") + logger.debug( + f"Skipping client 0x{client.id.get_public_key()[0]:02X} (waiting for ACK)" + ) continue - + if last_activity == 0: - logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (evicted)") + logger.debug( + f"Skipping client 0x{client.id.get_public_key()[0]:02X} (evicted)" + ) continue - + if push_failures >= 3: - logger.debug(f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)") + logger.debug( + f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)" + ) continue - - sync_since = sync_state.get('sync_since', 0) + + sync_since = sync_state.get("sync_since", 0) else: # Initialize sync state for new client # Use sync_since from ACL client (sent during login) if available - sync_since = client.sync_since if hasattr(client, 'sync_since') else 0 + sync_since = client.sync_since if hasattr(client, "sync_since") else 0 logger.info( f"Room '{self.room_name}': Initializing client " f"0x{client.id.get_public_key()[0]:02X} with sync_since={sync_since}" @@ -637,17 +647,17 @@ class RoomServer: room_hash=f"0x{self.room_hash:02X}", client_pubkey=client.id.get_public_key().hex(), sync_since=sync_since, - last_activity=time.time() + last_activity=time.time(), ) - + # Find next unsynced message for this client unsynced = self.db.get_unsynced_messages( room_hash=f"0x{self.room_hash:02X}", client_pubkey=client.id.get_public_key().hex(), sync_since=sync_since, - limit=1 + limit=1, ) - + if unsynced: post = unsynced[0] logger.debug( @@ -656,7 +666,7 @@ class RoomServer: ) # Check if enough time has passed since post creation now = time.time() - if now >= post['post_timestamp'] + POST_SYNC_DELAY_SECS: + if now >= post["post_timestamp"] + POST_SYNC_DELAY_SECS: # Push this post await self.push_post_to_client(client, post) self.next_push_time = time.time() + (SYNC_PUSH_INTERVAL_MS / 1000.0) @@ -668,15 +678,15 @@ class RoomServer: else: # No unsynced posts for this client, try next client continue - + # If we checked all clients and none were active/ready if clients_checked >= max_checks: # All clients skipped or no messages - wait longer before next check self.next_push_time = time.time() + 5.0 # Wait 5 seconds - + # SAFETY: Reset error counter on successful iteration self.consecutive_sync_errors = 0 - + except asyncio.CancelledError: break except Exception as e: @@ -684,35 +694,34 @@ class RoomServer: self.consecutive_sync_errors += 1 logger.error( f"Room '{self.room_name}': Sync loop error #{self.consecutive_sync_errors}: {e}", - exc_info=True + exc_info=True, ) - + # SAFETY: Back off on errors backoff = min(self.consecutive_sync_errors, 10) # Cap at 10 seconds await asyncio.sleep(backoff) - + logger.info(f"Room '{self.room_name}' sync loop stopped") - + async def _check_ack_timeouts(self): try: now = time.time() all_sync_states = self.db.get_all_room_clients(f"0x{self.room_hash:02X}") - + for sync_state in all_sync_states: - if sync_state['pending_ack_crc'] != 0: - timeout_time = sync_state.get('ack_timeout_time', 0) + if sync_state["pending_ack_crc"] != 0: + timeout_time = sync_state.get("ack_timeout_time", 0) if now >= timeout_time: # ACK timeout - client_pubkey = bytes.fromhex(sync_state['client_pubkey']) + client_pubkey = bytes.fromhex(sync_state["client_pubkey"]) await self._handle_ack_timeout(client_pubkey) except Exception as e: logger.error(f"Error checking ACK timeouts: {e}") - + async def _cleanup_old_messages(self): try: deleted = self.db.cleanup_old_messages( - room_hash=f"0x{self.room_hash:02X}", - keep_count=self.max_posts + room_hash=f"0x{self.room_hash:02X}", keep_count=self.max_posts ) if deleted > 0: logger.info(f"Room '{self.room_name}': Cleaned up {deleted} old messages") diff --git a/repeater/handler_helpers/text.py b/repeater/handler_helpers/text.py index d854817..9d6f59d 100644 --- a/repeater/handler_helpers/text.py +++ b/repeater/handler_helpers/text.py @@ -12,6 +12,7 @@ import struct import time from pymc_core.node.handlers.text import TextMessageHandler + from .mesh_cli import MeshCLI from .room_server import RoomServer @@ -24,9 +25,18 @@ TXT_TYPE_CLI_DATA = 0x01 class TextHelper: - def __init__(self, identity_manager, packet_injector=None, acl_dict=None, log_fn=None, - config_path: str = None, config: dict = None, config_manager=None, - sqlite_handler=None, send_advert_callback=None): + def __init__( + self, + identity_manager, + packet_injector=None, + acl_dict=None, + log_fn=None, + config_path: str = None, + config: dict = None, + config_manager=None, + sqlite_handler=None, + send_advert_callback=None, + ): self.identity_manager = identity_manager self.packet_injector = packet_injector @@ -34,47 +44,43 @@ class TextHelper: self.acl_dict = acl_dict or {} # Per-identity ACLs keyed by hash_byte self.sqlite_handler = sqlite_handler # For room server database operations self.send_advert_callback = send_advert_callback # Callback to send repeater advert - + # Dictionary of handlers keyed by dest_hash self.handlers = {} - + # Dictionary of room servers keyed by dest_hash self.room_servers = {} - + # Track repeater identity for CLI commands self.repeater_hash = None - + # Store config for later use self.config_path = config_path self.config = config self.config_manager = config_manager - + # Store for later CLI initialization (needs identity and storage) self.config_path = config_path self.config = config - + # Initialize CLI handler later when repeater identity is registered self.cli = None def register_identity( - self, - name: str, - identity, - identity_type: str = "room_server", - radio_config=None + self, name: str, identity, identity_type: str = "room_server", radio_config=None ): hash_byte = identity.get_public_key()[0] - + # Get ACL for this identity identity_acl = self.acl_dict.get(hash_byte) if not identity_acl: logger.warning(f"Cannot register identity '{name}': no ACL for hash 0x{hash_byte:02X}") return - + # Create a contacts wrapper from this identity's ACL acl_contacts = self._create_acl_contacts_wrapper(identity_acl) - + # Create TextMessageHandler for this identity handler = TextMessageHandler( local_identity=identity, @@ -83,7 +89,7 @@ class TextHelper: send_packet_fn=self._send_packet, radio_config=radio_config, ) - + # Register by dest hash hash_byte = identity.get_public_key()[0] self.handlers[hash_byte] = { @@ -92,12 +98,12 @@ class TextHelper: "name": name, "type": identity_type, } - + # Track repeater identity for CLI commands if identity_type == "repeater": self.repeater_hash = hash_byte logger.info(f"Set repeater hash for CLI: 0x{hash_byte:02X}") - + # Initialize CLI handler now that we have the repeater identity if self.config_path and self.config and self.config_manager: self.cli = MeshCLI( @@ -108,18 +114,20 @@ class TextHelper: enable_regions=True, send_advert_callback=self.send_advert_callback, identity=identity, - storage_handler=self.sqlite_handler + storage_handler=self.sqlite_handler, ) - logger.info("Initialized CLI handler for repeater commands with identity and storage") - + logger.info( + "Initialized CLI handler for repeater commands with identity and storage" + ) + # Create RoomServer instance for room_server identities if identity_type == "room_server" and self.sqlite_handler: try: from .room_server import MAX_UNSYNCED_POSTS - + room_config = radio_config or {} - max_posts = room_config.get('max_posts', MAX_UNSYNCED_POSTS) - + max_posts = room_config.get("max_posts", MAX_UNSYNCED_POSTS) + # Enforce hard limit if max_posts > MAX_UNSYNCED_POSTS: logger.warning( @@ -127,7 +135,7 @@ class TextHelper: f"of {MAX_UNSYNCED_POSTS}, capping to {MAX_UNSYNCED_POSTS}" ) max_posts = MAX_UNSYNCED_POSTS - + room_server = RoomServer( room_hash=hash_byte, room_name=name, @@ -138,31 +146,29 @@ class TextHelper: max_posts=max_posts, config_path=self.config_path, config=self.config, - config_manager=self.config_manager + config_manager=self.config_manager, ) - + self.room_servers[hash_byte] = room_server - + # Start sync loop asyncio.create_task(room_server.start()) - + logger.info( f"Registered room server '{name}': hash=0x{hash_byte:02X}, " f"max_posts={max_posts}" ) except Exception as e: logger.error(f"Failed to create room server '{name}': {e}", exc_info=True) - - logger.info( - f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}" - ) - + + logger.info(f"Registered {identity_type} '{name}' text handler: hash=0x{hash_byte:02X}") + def _create_acl_contacts_wrapper(self, acl): class ACLContactsWrapper: def __init__(self, identity_acl): self._acl = identity_acl - + @property def contacts(self): contact_list = [] @@ -172,10 +178,10 @@ class TextHelper: def __init__(self, client): self.public_key = client.id.get_public_key().hex() self.name = f"client_{self.public_key[:8]}" - + contact_list.append(ContactProxy(client_info)) return contact_list - + return ACLContactsWrapper(acl) async def process_text_packet(self, packet): @@ -183,20 +189,20 @@ class TextHelper: try: if len(packet.payload) < 2: return False - + dest_hash = packet.payload[0] src_hash = packet.payload[1] - + handler_info = self.handlers.get(dest_hash) if handler_info: logger.debug( f"Routing text message to '{handler_info['name']}': " f"dest=0x{dest_hash:02X}, src=0x{src_hash:02X}" ) - + # Let handler decrypt the message first await handler_info["handler"](packet) - + # Call placeholder for custom processing await self._on_message_received( identity_name=handler_info["name"], @@ -205,16 +211,14 @@ class TextHelper: dest_hash=dest_hash, src_hash=src_hash, ) - + # Mark packet as handled packet.mark_do_not_retransmit() return True else: - logger.debug( - f"No text handler for hash 0x{dest_hash:02X}, allowing forward" - ) + logger.debug(f"No text handler for hash 0x{dest_hash:02X}, allowing forward") return False - + except Exception as e: logger.error(f"Error processing text packet: {e}") return False @@ -230,128 +234,137 @@ class TextHelper: # Placeholder - can be overridden or callback can be added logger.debug( - f"Message received for {identity_type} '{identity_name}' " - f"from 0x{src_hash:02X}" + f"Message received for {identity_type} '{identity_name}' " f"from 0x{src_hash:02X}" ) - + # Extract decrypted message if available if hasattr(packet, "decrypted") and packet.decrypted: message_text = packet.decrypted.get("text", "") - + # Clean message text - remove null bytes and trailing whitespace - message_text = message_text.rstrip('\x00').rstrip() - - logger.info( - f"[{identity_type}:{identity_name}] Message: {message_text}" - ) - + message_text = message_text.rstrip("\x00").rstrip() + + logger.info(f"[{identity_type}:{identity_name}] Message: {message_text}") + # Handle room server messages if identity_type == "room_server" and dest_hash in self.room_servers: room_server = self.room_servers[dest_hash] - + # Check if this is a CLI command FIRST (before storing as post) if self._is_cli_command(message_text): # Handle CLI command - do NOT store as post if room_server and room_server.cli: try: # Check admin permission - is_admin = self._check_admin_permission_for_identity(src_hash, dest_hash) - + is_admin = self._check_admin_permission_for_identity( + src_hash, dest_hash + ) + if not is_admin: - logger.warning(f"Room '{identity_name}': CLI command denied from 0x{src_hash:02X} (not admin)") + logger.warning( + f"Room '{identity_name}': CLI command denied from 0x{src_hash:02X} (not admin)" + ) return - + # Get sender's full pubkey identity_acl = self.acl_dict.get(dest_hash) - sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default + sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default if identity_acl: for client_info in identity_acl.get_all_clients(): if client_info.id.get_public_key()[0] == src_hash: sender_pubkey = client_info.id.get_public_key() break - + # Handle CLI command reply = room_server.cli.handle_command( - sender_pubkey=sender_pubkey, - command=message_text, - is_admin=is_admin + sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin ) - - logger.info(f"Room '{identity_name}': CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}") - + + logger.info( + f"Room '{identity_name}': CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}" + ) + # Send reply back to sender handler_info = self.handlers.get(dest_hash) if handler_info: await self._send_cli_reply(packet, reply, handler_info) - + except Exception as e: - logger.error(f"Error processing room server CLI command: {e}", exc_info=True) - + logger.error( + f"Error processing room server CLI command: {e}", exc_info=True + ) + # CLI command handled, don't store as post return - + # NOT a CLI command - store as regular room post try: # Get sender's full pubkey identity_acl = self.acl_dict.get(dest_hash) - sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default + sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default if identity_acl: for client_info in identity_acl.get_all_clients(): if client_info.id.get_public_key()[0] == src_hash: sender_pubkey = client_info.id.get_public_key() break - + # Store message as post sender_timestamp = int(time.time()) success = await room_server.add_post( client_pubkey=sender_pubkey, message_text=message_text, sender_timestamp=sender_timestamp, - txt_type=TXT_TYPE_PLAIN + txt_type=TXT_TYPE_PLAIN, ) - + if success: - logger.info(f"Room '{identity_name}': New post from {sender_pubkey[:4].hex()}: {message_text[:50]}") - + logger.info( + f"Room '{identity_name}': New post from {sender_pubkey[:4].hex()}: {message_text[:50]}" + ) + except Exception as e: logger.error(f"Error storing room post: {e}", exc_info=True) - + return - + # Check if this is a CLI command to the repeater (AFTER decryption) if dest_hash == self.repeater_hash and self.cli and self._is_cli_command(message_text): try: # Check admin permission - is_admin = self._check_admin_permission_for_identity(src_hash, self.repeater_hash) - + is_admin = self._check_admin_permission_for_identity( + src_hash, self.repeater_hash + ) + # If not admin, log and return without sending reply if not is_admin: - logger.warning(f"CLI command denied from 0x{src_hash:02X} (not admin): {message_text[:50]}") + logger.warning( + f"CLI command denied from 0x{src_hash:02X} (not admin): {message_text[:50]}" + ) return - + # Get client for full public key repeater_acl = self.acl_dict.get(self.repeater_hash) - sender_pubkey = bytes([src_hash]) + b'\x00' * 31 # Default + sender_pubkey = bytes([src_hash]) + b"\x00" * 31 # Default if repeater_acl: for client_info in repeater_acl.get_all_clients(): if client_info.id.get_public_key()[0] == src_hash: sender_pubkey = client_info.id.get_public_key() break - + # Handle CLI command reply = self.cli.handle_command( - sender_pubkey=sender_pubkey, - command=message_text, - is_admin=is_admin + sender_pubkey=sender_pubkey, command=message_text, is_admin=is_admin ) - - logger.info(f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}") - + + logger.info( + f"CLI command from 0x{src_hash:02X}: {message_text[:50]} -> {reply[:100]}" + ) + # Send reply back to sender handler_info = self.handlers.get(dest_hash) if handler_info: await self._send_cli_reply(packet, reply, handler_info) - + except Exception as e: logger.error(f"Error processing CLI command: {e}", exc_info=True) @@ -381,7 +394,7 @@ class TextHelper: } for hash_byte, info in self.handlers.items() ] - + async def cleanup(self): """Cleanup room servers and handlers.""" # Stop all room server sync loops @@ -390,52 +403,68 @@ class TextHelper: await room_server.stop() except Exception as e: logger.error(f"Error stopping room server: {e}") - + logger.info("TextHelper cleanup complete") - + def _is_cli_command(self, message: str) -> bool: """Check if message looks like a CLI command.""" # Strip optional sequence prefix (XX|) - if len(message) > 4 and message[2] == '|': + if len(message) > 4 and message[2] == "|": message = message[3:].strip() - + # Check for known command prefixes command_prefixes = [ - "get ", "set ", "reboot", "advert", "clock", "time ", - "password ", "clear ", "ver", "board", "neighbors", "neighbor.", - "tempradio ", "setperm ", "region", "sensor ", "gps", "log ", - "stats-", "start ota" + "get ", + "set ", + "reboot", + "advert", + "clock", + "time ", + "password ", + "clear ", + "ver", + "board", + "neighbors", + "neighbor.", + "tempradio ", + "setperm ", + "region", + "sensor ", + "gps", + "log ", + "stats-", + "start ota", ] - + return any(message.startswith(prefix) for prefix in command_prefixes) - + def _check_admin_permission(self, src_hash: int) -> bool: """Check if sender has admin permissions for repeater (legacy method).""" return self._check_admin_permission_for_identity(src_hash, self.repeater_hash) - + def _check_admin_permission_for_identity(self, src_hash: int, identity_hash: int) -> bool: """Check if sender has admin permissions (bit 0x02) for a specific identity.""" # Get the identity's ACL identity_acl = self.acl_dict.get(identity_hash) if not identity_acl: return False - + # Get client by hash byte clients = identity_acl.get_all_clients() for client_info in clients: pubkey = client_info.id.get_public_key() if pubkey[0] == src_hash: # Check admin bit (0x02 = PERM_ACL_ADMIN) - permissions = getattr(client_info, 'permissions', 0) + permissions = getattr(client_info, "permissions", 0) PERM_ACL_ADMIN = 0x02 return (permissions & 0x02) == PERM_ACL_ADMIN - + return False - + async def _send_cli_reply(self, original_packet, reply_text: str, handler_info: dict): """ Send CLI reply back to sender using TXT_MSG datagram. - + Follows the C++ pattern (lines 603-609 in MyMesh.cpp): - Creates TXT_MSG datagram with TXT_TYPE_CLI_DATA flag - Encrypts with shared secret from ACL client @@ -443,77 +472,87 @@ class TextHelper: * if out_path_len < 0: sendFlood() * else: sendDirect() with stored out_path """ - from pymc_core.protocol import PacketBuilder, Identity - from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG import time - + + from pymc_core.protocol import Identity, PacketBuilder + from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG + try: src_hash = original_packet.payload[1] dest_hash = original_packet.payload[0] - + incoming_route = original_packet.get_route_type() - logger.debug(f"CLI reply: original packet dest=0x{dest_hash:02X}, src=0x{src_hash:02X}, incoming_route={incoming_route}") - + logger.debug( + f"CLI reply: original packet dest=0x{dest_hash:02X}, src=0x{src_hash:02X}, incoming_route={incoming_route}" + ) + # Find the client in the DESTINATION identity's ACL (not always repeater!) # dest_hash is the identity that received the command (repeater OR room server) identity_acl = self.acl_dict.get(dest_hash) if not identity_acl: logger.error(f"No ACL found for identity 0x{dest_hash:02X} for CLI reply") return - + client = None for client_info in identity_acl.get_all_clients(): pubkey = client_info.id.get_public_key() if pubkey[0] == src_hash: client = client_info break - + if not client: - logger.error(f"Client 0x{src_hash:02X} not found in identity 0x{dest_hash:02X} ACL for CLI reply") + logger.error( + f"Client 0x{src_hash:02X} not found in identity 0x{dest_hash:02X} ACL for CLI reply" + ) return - + # Get shared secret from client shared_secret = client.shared_secret if not shared_secret or len(shared_secret) == 0: logger.error(f"No shared secret for client 0x{src_hash:02X}") return - + # Build reply packet payload # Format: timestamp(4) + flags(1) + reply_text timestamp = int(time.time()) TXT_TYPE_CLI_DATA = 0x01 - flags = (TXT_TYPE_CLI_DATA << 2) # Upper 6 bits are txt_type - - reply_bytes = reply_text.encode('utf-8') - plaintext = timestamp.to_bytes(4, 'little') + bytes([flags]) + reply_bytes - + flags = TXT_TYPE_CLI_DATA << 2 # Upper 6 bits are txt_type + + reply_bytes = reply_text.encode("utf-8") + plaintext = timestamp.to_bytes(4, "little") + bytes([flags]) + reply_bytes + # Decide routing based on client->out_path_len (C++ pattern) # out_path is populated by PATH packets, NOT from incoming text message route route_type = "flood" if client.out_path_len < 0 else "direct" - logger.debug(f"CLI reply: client.out_path_len={client.out_path_len}, using route_type={route_type}") - + logger.debug( + f"CLI reply: client.out_path_len={client.out_path_len}, using route_type={route_type}" + ) + reply_packet = PacketBuilder.create_datagram( ptype=PAYLOAD_TYPE_TXT_MSG, dest=client.id, local_identity=handler_info["identity"], secret=shared_secret, plaintext=plaintext, - route_type=route_type + route_type=route_type, ) - - + # Add path for direct routing if available from PATH packets if client.out_path_len >= 0 and len(client.out_path) > 0: - reply_packet.path = bytearray(client.out_path[:client.out_path_len]) + reply_packet.path = bytearray(client.out_path[: client.out_path_len]) reply_packet.path_len = client.out_path_len - logger.debug(f"CLI reply: Added stored out_path - path_len={reply_packet.path_len}, path={[hex(b) for b in reply_packet.path]}") - + logger.debug( + f"CLI reply: Added stored out_path - path_len={reply_packet.path_len}, path={[hex(b) for b in reply_packet.path]}" + ) + # Send with delay (CLI_REPLY_DELAY_MILLIS = 600ms in C++) CLI_REPLY_DELAY_MS = 600 await asyncio.sleep(CLI_REPLY_DELAY_MS / 1000.0) - + await self._send_packet(reply_packet, wait_for_ack=False) - logger.info(f"CLI reply sent to 0x{src_hash:02X} via {route_type.upper()}: {reply_text[:50]}") - + logger.info( + f"CLI reply sent to 0x{src_hash:02X} via {route_type.upper()}: {reply_text[:50]}" + ) + except Exception as e: logger.error(f"Error sending CLI reply: {e}", exc_info=True) diff --git a/repeater/handler_helpers/trace.py b/repeater/handler_helpers/trace.py index 6b38658..9224d46 100644 --- a/repeater/handler_helpers/trace.py +++ b/repeater/handler_helpers/trace.py @@ -9,7 +9,7 @@ of packets through the mesh network. import asyncio import logging import time -from typing import Dict, Any +from typing import Any, Dict from pymc_core.hardware.signal_utils import snr_register_to_db from pymc_core.node.handlers.trace import TraceHandler @@ -34,10 +34,12 @@ class TraceHelper: self.local_hash = local_hash self.repeater_handler = repeater_handler self.packet_injector = packet_injector # Function to inject packets into router - + # Ping callback system - track pending ping requests by tag - self.pending_pings = {} # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}} - + self.pending_pings = ( + {} + ) # {tag: {'event': asyncio.Event(), 'result': dict, 'target': int, 'sent_at': float}} + # Optional: when trace reaches final node, call this (packet, parsed_data) to push 0x89 to companions self.on_trace_complete = None # async (packet, parsed_data) -> None @@ -63,9 +65,7 @@ class TraceHelper: parsed_data = self.trace_handler._parse_trace_payload(packet.payload) if not parsed_data.get("valid", False): - logger.warning( - f"Invalid trace packet: {parsed_data.get('error', 'Unknown error')}" - ) + logger.warning(f"Invalid trace packet: {parsed_data.get('error', 'Unknown error')}") return trace_path = parsed_data["trace_path"] @@ -76,14 +76,14 @@ class TraceHelper: if trace_tag in self.pending_pings: ping_info = self.pending_pings[trace_tag] # Store response data - ping_info['result'] = { - 'path': trace_path, - 'snr': packet.get_snr(), - 'rssi': getattr(packet, "rssi", 0), - 'received_at': time.time() + ping_info["result"] = { + "path": trace_path, + "snr": packet.get_snr(), + "rssi": getattr(packet, "rssi", 0), + "received_at": time.time(), } # Signal the waiting coroutine - ping_info['event'].set() + ping_info["event"].set() logger.info(f"Ping response received for tag {trace_tag}") # Record the trace packet for dashboard/statistics @@ -149,27 +149,37 @@ class TraceHelper: # Add detailed SNR info if we have the corresponding hash if i < len(trace_path): - path_snr_details.append({ - "hash": f"{trace_path[i]:02X}", - "snr_raw": snr_val, - "snr_db": snr_db - }) + path_snr_details.append( + {"hash": f"{trace_path[i]:02X}", "snr_raw": snr_val, "snr_db": snr_db} + ) return { "timestamp": time.time(), - "header": f"0x{packet.header:02X}" if hasattr(packet, "header") and packet.header is not None else None, - "payload": packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None, - "payload_length": len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0, + "header": ( + f"0x{packet.header:02X}" + if hasattr(packet, "header") and packet.header is not None + else None + ), + "payload": ( + packet.payload.hex() if hasattr(packet, "payload") and packet.payload else None + ), + "payload_length": ( + len(packet.payload) if hasattr(packet, "payload") and packet.payload else 0 + ), "type": packet.get_payload_type(), # 0x09 for trace - "route": packet.get_route_type(), # Should be direct (1) + "route": packet.get_route_type(), # Should be direct (1) "length": len(packet.payload or b""), "rssi": getattr(packet, "rssi", 0), "snr": getattr(packet, "snr", 0.0), - "score": self.repeater_handler.calculate_packet_score( - getattr(packet, "snr", 0.0), - len(packet.payload or b""), - self.repeater_handler.radio_config.get("spreading_factor", 8) - ) if self.repeater_handler else 0.0, + "score": ( + self.repeater_handler.calculate_packet_score( + getattr(packet, "snr", 0.0), + len(packet.payload or b""), + self.repeater_handler.radio_config.get("spreading_factor", 8), + ) + if self.repeater_handler + else 0.0 + ), "tx_delay_ms": 0, "transmitted": False, "is_duplicate": False, @@ -226,21 +236,24 @@ class TraceHelper: True if the packet should be forwarded, False otherwise """ # Use the exact logic from the original working code - return (packet.path_len < trace_path_len and - len(trace_path) > packet.path_len and - trace_path[packet.path_len] == self.local_hash and - self.repeater_handler and not self.repeater_handler.is_duplicate(packet)) + return ( + packet.path_len < trace_path_len + and len(trace_path) > packet.path_len + and trace_path[packet.path_len] == self.local_hash + and self.repeater_handler + and not self.repeater_handler.is_duplicate(packet) + ) async def _forward_trace_packet(self, packet, trace_path_len: int) -> None: """ Forward a trace packet by appending SNR and sending via injection. - + Args: packet: The trace packet to forward trace_path_len: The length of the trace path """ # Update the packet record to show it will be transmitted - if self.repeater_handler and hasattr(self.repeater_handler, 'recent_packets'): + if self.repeater_handler and hasattr(self.repeater_handler, "recent_packets"): packet_hash = packet.calculate_packet_hash().hex().upper()[:16] for record in reversed(self.repeater_handler.recent_packets): if record.get("packet_hash") == packet_hash: @@ -293,41 +306,44 @@ class TraceHelper: elif len(trace_path) <= packet.path_len: logger.info("Path index out of bounds") elif trace_path[packet.path_len] != self.local_hash: - expected_hash = trace_path[packet.path_len] if packet.path_len < len(trace_path) else None + expected_hash = ( + trace_path[packet.path_len] if packet.path_len < len(trace_path) else None + ) logger.info(f"Not our turn (next hop: 0x{expected_hash:02x})") elif self.repeater_handler and self.repeater_handler.is_duplicate(packet): logger.info("Duplicate packet, ignoring") def register_ping(self, tag: int, target_hash: int) -> asyncio.Event: """Register a ping request and return an event to wait on. - + Args: tag: The unique trace tag for this ping target_hash: The hash of the target node - + Returns: asyncio.Event that will be set when response is received """ event = asyncio.Event() self.pending_pings[tag] = { - 'event': event, - 'result': None, - 'target': target_hash, - 'sent_at': time.time() + "event": event, + "result": None, + "target": target_hash, + "sent_at": time.time(), } logger.debug(f"Registered ping with tag {tag} for target 0x{target_hash:02x}") return event def cleanup_stale_pings(self, max_age_seconds: int = 30): """Remove pending pings older than max_age_seconds. - + Args: max_age_seconds: Maximum age in seconds before a ping is considered stale """ current_time = time.time() stale_tags = [ - tag for tag, info in self.pending_pings.items() - if current_time - info['sent_at'] > max_age_seconds + tag + for tag, info in self.pending_pings.items() + if current_time - info["sent_at"] > max_age_seconds ] for tag in stale_tags: self.pending_pings.pop(tag) diff --git a/repeater/identity_manager.py b/repeater/identity_manager.py index d626adf..d5dfe6c 100644 --- a/repeater/identity_manager.py +++ b/repeater/identity_manager.py @@ -1,20 +1,20 @@ import logging -from typing import Dict, Optional, Tuple, Any +from typing import Any, Dict, Optional, Tuple logger = logging.getLogger("IdentityManager") class IdentityManager: - + def __init__(self, config: dict): self.config = config self.identities: Dict[int, Tuple[Any, dict, str]] = {} self.named_identities: Dict[str, Tuple[Any, dict, str]] = {} self.registered_hashes: Dict[int, str] = {} - + def register_identity(self, name: str, identity, config: dict, identity_type: str): hash_byte = identity.get_public_key()[0] - + if hash_byte in self.identities: existing_name = self.registered_hashes.get(hash_byte, "unknown") logger.error( @@ -22,40 +22,42 @@ class IdentityManager: f"conflicts with existing identity '{existing_name}'" ) return False - + self.identities[hash_byte] = (identity, config, identity_type) self.named_identities[name] = (identity, config, identity_type) self.registered_hashes[hash_byte] = f"{identity_type}:{name}" - + logger.info( f"Identity registered: name={name}, hash=0x{hash_byte:02X}, type={identity_type}" ) return True - + def get_identity_by_hash(self, hash_byte: int) -> Optional[Tuple[Any, dict, str]]: return self.identities.get(hash_byte) - + def get_identity_by_name(self, name: str) -> Optional[Tuple[Any, dict, str]]: return self.named_identities.get(name) - + def has_identity(self, hash_byte: int) -> bool: return hash_byte in self.identities - + def list_identities(self) -> list: identities = [] for hash_byte, (identity, config, id_type) in self.identities.items(): name = self.registered_hashes.get(hash_byte, "unknown") - identities.append({ - "hash": f"0x{hash_byte:02X}", - "name": name, - "type": id_type, - "address": identity.get_address_bytes().hex() if identity else "N/A" - }) + identities.append( + { + "hash": f"0x{hash_byte:02X}", + "name": name, + "type": id_type, + "address": identity.get_address_bytes().hex() if identity else "N/A", + } + ) return identities - + def has_identity_type(self, identity_type: str) -> bool: return any(id_type == identity_type for _, _, id_type in self.identities.values()) - + def get_identities_by_type(self, identity_type: str) -> list: results = [] for name, (identity, config, id_type) in self.named_identities.items(): diff --git a/repeater/main.py b/repeater/main.py index 5e05575..00538ae 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -7,10 +7,18 @@ import time from repeater.config import get_radio_for_board, load_config from repeater.config_manager import ConfigManager from repeater.engine import RepeaterHandler -from repeater.web.http_server import HTTPStatsServer, _log_buffer -from repeater.handler_helpers import TraceHelper, DiscoveryHelper, AdvertHelper, LoginHelper, TextHelper, PathHelper, ProtocolRequestHelper -from repeater.packet_router import PacketRouter +from repeater.handler_helpers import ( + AdvertHelper, + DiscoveryHelper, + LoginHelper, + PathHelper, + ProtocolRequestHelper, + TextHelper, + TraceHelper, +) from repeater.identity_manager import IdentityManager +from repeater.packet_router import PacketRouter +from repeater.web.http_server import HTTPStatsServer, _log_buffer logger = logging.getLogger("RepeaterDaemon") @@ -40,7 +48,6 @@ class RepeaterDaemon: self.companion_bridges: dict[int, object] = {} self.companion_frame_servers: list = [] - log_level = config.get("logging", {}).get("level", "INFO") logging.basicConfig( level=getattr(logging, log_level), @@ -64,35 +71,35 @@ class RepeaterDaemon: if hasattr(self.radio, "set_event_loop"): self.radio.set_event_loop(asyncio.get_running_loop()) - if hasattr(self.radio, 'set_custom_cad_thresholds'): + if hasattr(self.radio, "set_custom_cad_thresholds"): # Load CAD settings from config, with defaults cad_config = self.config.get("radio", {}).get("cad", {}) peak_threshold = cad_config.get("peak_threshold", 23) min_threshold = cad_config.get("min_threshold", 11) - + self.radio.set_custom_cad_thresholds(peak=peak_threshold, min_val=min_threshold) - logger.info(f"CAD thresholds set from config: peak={peak_threshold}, min={min_threshold}") + logger.info( + f"CAD thresholds set from config: peak={peak_threshold}, min={min_threshold}" + ) else: logger.warning("Radio does not support CAD configuration") - - if hasattr(self.radio, 'get_frequency'): + if hasattr(self.radio, "get_frequency"): logger.info(f"Radio config - Freq: {self.radio.get_frequency():.1f}MHz") - if hasattr(self.radio, 'get_spreading_factor'): + if hasattr(self.radio, "get_spreading_factor"): logger.info(f"Radio config - SF: {self.radio.get_spreading_factor()}") - if hasattr(self.radio, 'get_bandwidth'): + if hasattr(self.radio, "get_bandwidth"): logger.info(f"Radio config - BW: {self.radio.get_bandwidth()}kHz") - if hasattr(self.radio, 'get_coding_rate'): + if hasattr(self.radio, "get_coding_rate"): logger.info(f"Radio config - CR: {self.radio.get_coding_rate()}") - if hasattr(self.radio, 'get_tx_power'): + if hasattr(self.radio, "get_tx_power"): logger.info(f"Radio config - TX Power: {self.radio.get_tx_power()}dBm") - + logger.info("Radio hardware initialized") except Exception as e: logger.error(f"Failed to initialize radio hardware: {e}") raise RuntimeError("Repeater requires real LoRa hardware") from e - try: from pymc_core import LocalIdentity from pymc_core.node.dispatcher import Dispatcher @@ -116,7 +123,7 @@ class RepeaterDaemon: pubkey = local_identity.get_public_key() self.local_hash = pubkey[0] - + logger.info(f"Local identity set: {local_identity.get_address_bytes().hex()}") local_hash_hex = f"0x{self.local_hash:02x}" logger.info(f"Local node hash (from identity): {local_hash_hex}") @@ -133,7 +140,7 @@ class RepeaterDaemon: # Create router self.router = PacketRouter(self) await self.router.start() - + # Register router as entry point for ALL packets via fallback handler # All received packets flow through router → helpers → repeater engine self.dispatcher.register_fallback_handler(self._router_callback) @@ -147,7 +154,7 @@ class RepeaterDaemon: log_fn=logger.info, ) logger.info("Trace processing helper initialized") - + # Create advert helper for neighbor tracking self.advert_helper = AdvertHelper( local_identity=self.local_identity, @@ -176,73 +183,81 @@ class RepeaterDaemon: packet_injector=self.router.inject_packet, log_fn=logger.info, ) - + # Register default repeater identity self.login_helper.register_identity( name="repeater", identity=self.local_identity, identity_type="repeater", - config=self.config # Pass full config so repeater can access top-level security section + config=self.config, # Pass full config so repeater can access top-level security section ) - + # Register room server identities with their configs - for name, identity, config in self.identity_manager.get_identities_by_type("room_server"): + for name, identity, config in self.identity_manager.get_identities_by_type( + "room_server" + ): self.login_helper.register_identity( - name=name, - identity=identity, + name=name, + identity=identity, identity_type="room_server", - config=config # Pass room-specific config + config=config, # Pass room-specific config ) - + logger.info("Login processing helper initialized") - + # Initialize ConfigManager for centralized config management self.config_manager = ConfigManager( - config_path=getattr(self, 'config_path', '/etc/pymc_repeater/config.yaml'), + config_path=getattr(self, "config_path", "/etc/pymc_repeater/config.yaml"), config=self.config, - daemon_instance=self + daemon_instance=self, ) logger.info("Config manager initialized") - + # Initialize text message helper with per-identity ACLs self.text_helper = TextHelper( identity_manager=self.identity_manager, packet_injector=self.router.inject_packet, acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs log_fn=logger.info, - config_path=getattr(self, 'config_path', None), # For CLI to save changes + config_path=getattr(self, "config_path", None), # For CLI to save changes config=self.config, # For CLI to read/modify settings config_manager=self.config_manager, # New centralized config manager - sqlite_handler=self.repeater_handler.storage.sqlite_handler if self.repeater_handler and self.repeater_handler.storage else None, # For room server database + sqlite_handler=( + self.repeater_handler.storage.sqlite_handler + if self.repeater_handler and self.repeater_handler.storage + else None + ), # For room server database send_advert_callback=self.send_advert, # For CLI advert command ) - + # Register default repeater identity for text messages self.text_helper.register_identity( name="repeater", identity=self.local_identity, identity_type="repeater", - radio_config=self.config.get("radio", {}) + radio_config=self.config.get("radio", {}), ) - + # Register room server identities for text messages - for name, identity, config in self.identity_manager.get_identities_by_type("room_server"): + for name, identity, config in self.identity_manager.get_identities_by_type( + "room_server" + ): self.text_helper.register_identity( name=name, identity=identity, identity_type="room_server", - radio_config=config # Pass room-specific config (includes max_posts, etc.) + radio_config=config, # Pass room-specific config (includes max_posts, etc.) ) - + logger.info("Text message processing helper initialized") - + # Initialize PATH packet helper for updating client out_path self.path_helper = PathHelper( acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs log_fn=logger.info, ) logger.info("PATH packet processing helper initialized") - + # Initialize protocol request handler for status/telemetry requests self.protocol_request_helper = ProtocolRequestHelper( identity_manager=self.identity_manager, @@ -254,9 +269,7 @@ class RepeaterDaemon: ) # Register repeater identity for protocol requests self.protocol_request_helper.register_identity( - name="repeater", - identity=self.local_identity, - identity_type="repeater" + name="repeater", identity=self.local_identity, identity_type="repeater" ) logger.info("Protocol request handler initialized") @@ -280,22 +293,20 @@ class RepeaterDaemon: async def _load_additional_identities(self): from pymc_core import LocalIdentity - + identities_config = self.config.get("identities", {}) - + # Load room server identities room_servers = identities_config.get("room_servers") or [] for room_config in room_servers: try: name = room_config.get("name") identity_key = room_config.get("identity_key") - + if not name or not identity_key: - logger.warning( - f"Skipping room server config: missing name or identity_key" - ) + logger.warning(f"Skipping room server config: missing name or identity_key") continue - + # Convert identity_key to bytes if it's a hex string if isinstance(identity_key, bytes): identity_key_bytes = identity_key @@ -303,36 +314,40 @@ class RepeaterDaemon: try: identity_key_bytes = bytes.fromhex(identity_key) if len(identity_key_bytes) != 32: - logger.error(f"Identity key for '{name}' is invalid length: {len(identity_key_bytes)} bytes (expected 32)") + logger.error( + f"Identity key for '{name}' is invalid length: {len(identity_key_bytes)} bytes (expected 32)" + ) continue except ValueError as e: logger.error(f"Identity key for '{name}' is not valid hex: {e}") continue else: - logger.error(f"Identity key for '{name}' has unknown type: {type(identity_key)}") + logger.error( + f"Identity key for '{name}' has unknown type: {type(identity_key)}" + ) continue - + # Create the identity room_identity = LocalIdentity(seed=identity_key_bytes) - + # Register with the manager and all helpers success = self._register_identity_everywhere( name=name, identity=room_identity, config=room_config, - identity_type="room_server" + identity_type="room_server", ) - + if success: room_hash = room_identity.get_public_key()[0] logger.info( f"Loaded room server '{name}': hash=0x{room_hash:02x}, " f"address={room_identity.get_address_bytes().hex()}" ) - + except Exception as e: logger.error(f"Failed to load room server identity '{name}': {e}") - + # Summary logging total_identities = len(self.identity_manager.list_identities()) logger.info(f"Identity manager loaded {total_identities} total identities") @@ -341,7 +356,7 @@ class RepeaterDaemon: """Load companion identities from config and create CompanionBridge + frame server for each.""" from pymc_core import LocalIdentity from pymc_core.companion import CompanionBridge - from pymc_core.companion.models import Contact, Channel + from pymc_core.companion.models import Channel, Contact from repeater.companion import CompanionFrameServer @@ -353,7 +368,11 @@ class RepeaterDaemon: if self.repeater_handler and self.repeater_handler.storage: sqlite_handler = self.repeater_handler.storage.sqlite_handler - radio_config = self.repeater_handler.radio_config if self.repeater_handler else self.config.get("radio", {}) + radio_config = ( + self.repeater_handler.radio_config + if self.repeater_handler + else self.config.get("radio", {}) + ) for comp_config in companions_config: try: @@ -415,28 +434,40 @@ class RepeaterDaemon: for row in channel_rows: ch = Channel( name=row.get("name", ""), - secret=row.get("secret", b"") if isinstance(row.get("secret"), bytes) else (bytes.fromhex(row.get("secret", "")) if row.get("secret") else b""), + secret=( + row.get("secret", b"") + if isinstance(row.get("secret"), bytes) + else ( + bytes.fromhex(row.get("secret", "")) + if row.get("secret") + else b"" + ) + ), ) bridge.channels.set(row.get("channel_idx", 0), ch) # Preload queued messages from SQLite into bridge for msg_dict in sqlite_handler.companion_load_messages(companion_hash_str): from pymc_core.companion.models import QueuedMessage + sk = msg_dict.get("sender_key", b"") if isinstance(sk, str): sk = bytes.fromhex(sk) - bridge.message_queue.push(QueuedMessage( - sender_key=sk, - txt_type=msg_dict.get("txt_type", 0), - timestamp=msg_dict.get("timestamp", 0), - text=msg_dict.get("text", ""), - is_channel=bool(msg_dict.get("is_channel", False)), - channel_idx=msg_dict.get("channel_idx", 0), - path_len=msg_dict.get("path_len", 0), - )) + bridge.message_queue.push( + QueuedMessage( + sender_key=sk, + txt_type=msg_dict.get("txt_type", 0), + timestamp=msg_dict.get("timestamp", 0), + text=msg_dict.get("text", ""), + is_channel=bool(msg_dict.get("is_channel", False)), + channel_idx=msg_dict.get("channel_idx", 0), + path_len=msg_dict.get("path_len", 0), + ) + ) # Ensure public channel (0) exists with default key for new companions from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET + if bridge.get_channel(0) is None: bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET) @@ -450,7 +481,9 @@ class RepeaterDaemon: sqlite_handler=sqlite_handler, local_hash=self.local_hash, stats_getter=self._get_companion_stats, - control_handler=self.discovery_helper.control_handler if self.discovery_helper else None, + control_handler=( + self.discovery_helper.control_handler if self.discovery_helper else None + ), ) await frame_server.start() self.companion_frame_servers.append(frame_server) @@ -500,7 +533,9 @@ class RepeaterDaemon: tag = int.from_bytes(payload_bytes[2:6], "little") if len(payload_bytes) >= 6 else 0 logger.debug( "Delivering discovery response to %s companion(s): tag=0x%08X, len=%s", - len(servers), tag, len(payload_bytes), + len(servers), + tag, + len(payload_bytes), ) for fs in servers: try: @@ -530,11 +565,7 @@ class RepeaterDaemon: logger.debug("Push trace data to companion: %s", e) def _register_identity_everywhere( - self, - name: str, - identity, - config: dict, - identity_type: str + self, name: str, identity, config: dict, identity_type: str ) -> bool: """ Register an identity with the manager and all helpers in one place. @@ -542,39 +573,31 @@ class RepeaterDaemon: """ # Register with identity manager success = self.identity_manager.register_identity( - name=name, - identity=identity, - config=config, - identity_type=identity_type + name=name, identity=identity, config=config, identity_type=identity_type ) - + if not success: return False - + # Register with all helpers if self.login_helper: self.login_helper.register_identity( - name=name, - identity=identity, - identity_type=identity_type, - config=config + name=name, identity=identity, identity_type=identity_type, config=config ) - + if self.text_helper: self.text_helper.register_identity( name=name, identity=identity, identity_type=identity_type, - radio_config=self.config.get("radio", {}) + radio_config=self.config.get("radio", {}), ) - + if self.protocol_request_helper: self.protocol_request_helper.register_identity( - name=name, - identity=identity, - identity_type=identity_type + name=name, identity=identity, identity_type=identity_type ) - + return True async def _router_callback(self, packet): @@ -587,19 +610,15 @@ class RepeaterDaemon: await self.router.enqueue(packet) except Exception as e: logger.error(f"Error enqueuing packet in router: {e}", exc_info=True) - + def register_text_handler_for_identity( - self, - name: str, - identity, - identity_type: str = "room_server", - radio_config: dict = None + self, name: str, identity, identity_type: str = "room_server", radio_config: dict = None ): if not self.text_helper: logger.warning("Text helper not initialized, cannot register identity") return False - + try: self.text_helper.register_identity( name=name, @@ -612,10 +631,10 @@ class RepeaterDaemon: except Exception as e: logger.error(f"Failed to register text handler for '{name}': {e}") return False - + def get_stats(self) -> dict: stats = {} - + if self.repeater_handler: stats = self.repeater_handler.get_stats() # Add public key if available @@ -625,12 +644,17 @@ class RepeaterDaemon: stats["public_key"] = pubkey.hex() except Exception: stats["public_key"] = None - + return stats def _get_companion_stats(self, stats_type: int) -> dict: """Return stats dict for companion CMD_GET_STATS (format expected by frame_server + meshcore_py).""" - from repeater.companion.constants import STATS_TYPE_CORE, STATS_TYPE_RADIO, STATS_TYPE_PACKETS + from repeater.companion.constants import ( + STATS_TYPE_CORE, + STATS_TYPE_PACKETS, + STATS_TYPE_RADIO, + ) + if not self.repeater_handler: return {} engine = self.repeater_handler @@ -751,10 +775,10 @@ class RepeaterDaemon: node_name=node_name, pub_key=pub_key_formatted, send_advert_func=self.send_advert, - config=self.config, - event_loop=current_loop, - daemon_instance=self, - config_path=getattr(self, 'config_path', '/etc/pymc_repeater/config.yaml'), + config=self.config, + event_loop=current_loop, + daemon_instance=self, + config_path=getattr(self, "config_path", "/etc/pymc_repeater/config.yaml"), ) try: @@ -804,14 +828,13 @@ def main(): # Load configuration config = load_config(args.config) - config_path = args.config if args.config else '/etc/pymc_repeater/config.yaml' + config_path = args.config if args.config else "/etc/pymc_repeater/config.yaml" if args.log_level: if "logging" not in config: config["logging"] = {} config["logging"]["level"] = args.log_level - # Don't initialize radio here - it will be done inside the async event loop daemon = RepeaterDaemon(config, radio=None) daemon.config_path = config_path diff --git a/repeater/packet_router.py b/repeater/packet_router.py index a1007a4..8a8e878 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -1,19 +1,21 @@ import asyncio import logging -from pymc_core.node.handlers.trace import TraceHandler -from pymc_core.node.handlers.control import ControlHandler -from pymc_core.node.handlers.advert import AdvertHandler from pymc_core.node.handlers.ack import AckHandler +from pymc_core.node.handlers.advert import AdvertHandler +from pymc_core.node.handlers.control import ControlHandler +from pymc_core.node.handlers.group_text import GroupTextHandler +from pymc_core.node.handlers.login_response import LoginResponseHandler from pymc_core.node.handlers.login_server import LoginServerHandler -from pymc_core.node.handlers.text import TextMessageHandler from pymc_core.node.handlers.path import PathHandler from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler -from pymc_core.node.handlers.group_text import GroupTextHandler from pymc_core.node.handlers.protocol_response import ProtocolResponseHandler -from pymc_core.node.handlers.login_response import LoginResponseHandler +from pymc_core.node.handlers.text import TextMessageHandler +from pymc_core.node.handlers.trace import TraceHandler + logger = logging.getLogger("PacketRouter") + class PacketRouter: def __init__(self, daemon_instance): @@ -56,7 +58,9 @@ class PacketRouter: await self.enqueue(packet) packet_len = len(packet.payload) if packet.payload else 0 - logger.debug(f"Injected packet processed by engine as local transmission ({packet_len} bytes)") + logger.debug( + f"Injected packet processed by engine as local transmission ({packet_len} bytes)" + ) return True except Exception as e: @@ -72,8 +76,7 @@ class PacketRouter: continue except Exception as e: logger.error(f"Router error: {e}", exc_info=True) - - + async def _route_packet(self, packet): payload_type = packet.get_payload_type() @@ -98,7 +101,11 @@ class PacketRouter: snr = getattr(packet, "_snr", None) or getattr(packet, "snr", 0.0) rssi = getattr(packet, "_rssi", None) or getattr(packet, "rssi", 0) path_len = getattr(packet, "path_len", 0) or 0 - path_bytes = (bytes(getattr(packet, "path", [])) if getattr(packet, "path", None) is not None else b"")[:path_len] + path_bytes = ( + bytes(getattr(packet, "path", [])) + if getattr(packet, "path", None) is not None + else b"" + )[:path_len] payload_bytes = bytes(packet.payload) if packet.payload else b"" await deliver(snr, rssi, path_len, path_bytes, payload_bytes) diff --git a/repeater/service_utils.py b/repeater/service_utils.py index 78ba7e8..5e465ee 100644 --- a/repeater/service_utils.py +++ b/repeater/service_utils.py @@ -2,6 +2,7 @@ Service management utilities for pyMC Repeater. Provides functions for service control operations like restart. """ + import logging import subprocess from typing import Tuple @@ -21,12 +22,9 @@ def restart_service() -> Tuple[bool, str]: """ try: result = subprocess.run( - ['systemctl', 'restart', 'pymc-repeater'], - capture_output=True, - text=True, - timeout=5 + ["systemctl", "restart", "pymc-repeater"], capture_output=True, text=True, timeout=5 ) - + if result.returncode == 0: logger.info("Service restart command executed successfully") return True, "Service restart initiated" diff --git a/repeater/web/__init__.py b/repeater/web/__init__.py index 77ae3f9..7ec398e 100644 --- a/repeater/web/__init__.py +++ b/repeater/web/__init__.py @@ -1,12 +1,12 @@ -from .http_server import HTTPStatsServer, StatsApp, LogBuffer, _log_buffer from .api_endpoints import APIEndpoints from .cad_calibration_engine import CADCalibrationEngine +from .http_server import HTTPStatsServer, LogBuffer, StatsApp, _log_buffer __all__ = [ - 'HTTPStatsServer', - 'StatsApp', - 'LogBuffer', - 'APIEndpoints', - 'CADCalibrationEngine', - '_log_buffer' -] \ No newline at end of file + "HTTPStatsServer", + "StatsApp", + "LogBuffer", + "APIEndpoints", + "CADCalibrationEngine", + "_log_buffer", +] diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 23ccde9..6a464dd 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -5,14 +5,17 @@ import time from datetime import datetime from pathlib import Path from typing import Callable, Optional + import cherrypy +from pymc_core.protocol import CryptoUtils + from repeater import __version__ from repeater.config import update_global_flood_policy -from .cad_calibration_engine import CADCalibrationEngine + from .auth.middleware import require_auth from .auth_endpoints import AuthAPIEndpoints +from .cad_calibration_engine import CADCalibrationEngine from .companion_endpoints import CompanionAPIEndpoints -from pymc_core.protocol import CryptoUtils logger = logging.getLogger("HTTPServer") @@ -47,7 +50,7 @@ logger = logging.getLogger("HTTPServer") # Packets # GET /api/packet_stats?hours=24 - Get packet statistics -# GET /api/packet_type_stats?hours=24 - Get packet type statistics +# GET /api/packet_type_stats?hours=24 - Get packet type statistics # GET /api/route_stats?hours=24 - Get route statistics # GET /api/recent_packets?limit=100 - Get recent packets # GET /api/filtered_packets?type=4&route=1&start_timestamp=X&end_timestamp=Y&limit=1000 - Get filtered packets @@ -124,26 +127,32 @@ logger = logging.getLogger("HTTPServer") # ============================================================================ - class APIEndpoints: - - def __init__(self, stats_getter: Optional[Callable] = None, send_advert_func: Optional[Callable] = None, config: Optional[dict] = None, event_loop=None, daemon_instance=None, config_path=None): + + def __init__( + self, + stats_getter: Optional[Callable] = None, + send_advert_func: Optional[Callable] = None, + config: Optional[dict] = None, + event_loop=None, + daemon_instance=None, + config_path=None, + ): self.stats_getter = stats_getter self.send_advert_func = send_advert_func self.config = config or {} self.event_loop = event_loop self.daemon_instance = daemon_instance - self._config_path = config_path or '/etc/pymc_repeater/config.yaml' + self._config_path = config_path or "/etc/pymc_repeater/config.yaml" self.cad_calibration = CADCalibrationEngine(daemon_instance, event_loop) - + # Initialize ConfigManager for centralized config management from repeater.config_manager import ConfigManager + self.config_manager = ConfigManager( - config_path=self._config_path, - config=self.config, - daemon_instance=daemon_instance + config_path=self._config_path, config=self.config, daemon_instance=daemon_instance ) - + # Create nested auth object for /api/auth/* routes self.auth = AuthAPIEndpoints() @@ -155,28 +164,38 @@ class APIEndpoints: def _set_cors_headers(self): if self._is_cors_enabled(): - cherrypy.response.headers['Access-Control-Allow-Origin'] = '*' - cherrypy.response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization' + cherrypy.response.headers["Access-Control-Allow-Origin"] = "*" + cherrypy.response.headers["Access-Control-Allow-Methods"] = ( + "GET, POST, PUT, DELETE, OPTIONS" + ) + cherrypy.response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization" + ) @cherrypy.expose def default(self, *args, **kwargs): """Handle default requests""" if cherrypy.request.method == "OPTIONS": return "" - + raise cherrypy.HTTPError(404) def _get_storage(self): if not self.daemon_instance: raise Exception("Daemon not available") - - if not hasattr(self.daemon_instance, 'repeater_handler') or not self.daemon_instance.repeater_handler: + + if ( + not hasattr(self.daemon_instance, "repeater_handler") + or not self.daemon_instance.repeater_handler + ): raise Exception("Repeater handler not initialized") - - if not hasattr(self.daemon_instance.repeater_handler, 'storage') or not self.daemon_instance.repeater_handler.storage: + + if ( + not hasattr(self.daemon_instance.repeater_handler, "storage") + or not self.daemon_instance.repeater_handler.storage + ): raise Exception("Storage not initialized in repeater handler") - + return self.daemon_instance.repeater_handler.storage def _success(self, data, **kwargs): @@ -203,7 +222,7 @@ class APIEndpoints: def _require_post(self): if cherrypy.request.method != "POST": cherrypy.response.status = 405 # Method Not Allowed - cherrypy.response.headers['Allow'] = 'POST' + cherrypy.response.headers["Allow"] = "POST" raise cherrypy.HTTPError(405, "Method not allowed. This endpoint requires POST.") def _get_time_range(self, hours): @@ -239,21 +258,26 @@ class APIEndpoints: config = self.config # Check for default values that indicate first-time setup - node_name = config.get('repeater', {}).get('node_name', '') - has_default_name = node_name in ['mesh-repeater-01', ''] + node_name = config.get("repeater", {}).get("node_name", "") + has_default_name = node_name in ["mesh-repeater-01", ""] - admin_password = config.get('repeater', {}).get('security', {}).get('admin_password', '') - has_default_password = admin_password in ['admin123', ''] + admin_password = ( + config.get("repeater", {}).get("security", {}).get("admin_password", "") + ) + has_default_password = admin_password in ["admin123", ""] needs_setup = has_default_name or has_default_password - return {'needs_setup': needs_setup, 'reasons': { - 'default_name': has_default_name, - 'default_password': has_default_password - }} + return { + "needs_setup": needs_setup, + "reasons": { + "default_name": has_default_name, + "default_password": has_default_password, + }, + } except Exception as e: logger.error(f"Error checking setup status: {e}") - return {'needs_setup': False, 'error': str(e)} + return {"needs_setup": False, "error": str(e)} @cherrypy.expose @cherrypy.tools.json_out() @@ -265,37 +289,41 @@ class APIEndpoints: # Check config-based location first, then development location storage_cfg = self.config.get("storage", {}) config_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) - installed_path = config_dir / 'radio-settings.json' - dev_path = os.path.join(os.path.dirname(__file__), '..', '..', 'radio-settings.json') - + installed_path = config_dir / "radio-settings.json" + dev_path = os.path.join(os.path.dirname(__file__), "..", "..", "radio-settings.json") + hardware_file = str(installed_path) if installed_path.exists() else dev_path hardware_list = [] if os.path.exists(hardware_file): - with open(hardware_file, 'r') as f: + with open(hardware_file, "r") as f: hardware_data = json.load(f) - hardware_configs = hardware_data.get('hardware', {}) + hardware_configs = hardware_data.get("hardware", {}) for hw_key, hw_config in hardware_configs.items(): if isinstance(hw_config, dict): - hardware_list.append({ - 'key': hw_key, - 'name': hw_config.get('name', hw_key), - 'description': hw_config.get('description', ''), - 'config': hw_config - }) + hardware_list.append( + { + "key": hw_key, + "name": hw_config.get("name", hw_key), + "description": hw_config.get("description", ""), + "config": hw_config, + } + ) # Add MeshCore KISS modem option (serial TNC) - hardware_list.append({ - 'key': 'kiss', - 'name': 'KISS modem (serial)', - 'description': 'MeshCore KISS modem over serial – requires pyMC_core with KISS support', - 'config': {} - }) + hardware_list.append( + { + "key": "kiss", + "name": "KISS modem (serial)", + "description": "MeshCore KISS modem over serial – requires pyMC_core with KISS support", + "config": {}, + } + ) - return {'hardware': hardware_list} + return {"hardware": hardware_list} except Exception as e: logger.error(f"Error loading hardware options: {e}") - return {'error': str(e)} + return {"error": str(e)} @cherrypy.expose @cherrypy.tools.json_out() @@ -303,29 +331,33 @@ class APIEndpoints: """Get radio preset configurations from local file""" try: import json - + # Check config-based location first, then development location storage_cfg = self.config.get("storage", {}) config_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) - installed_path = config_dir / 'radio-presets.json' - dev_path = os.path.join(os.path.dirname(__file__), '..', '..', 'radio-presets.json') - + installed_path = config_dir / "radio-presets.json" + dev_path = os.path.join(os.path.dirname(__file__), "..", "..", "radio-presets.json") + presets_file = str(installed_path) if installed_path.exists() else dev_path - + if not os.path.exists(presets_file): logger.error(f"Presets file not found. Tried: {installed_path}, {dev_path}") - return {'error': 'Radio presets file not found'} - - with open(presets_file, 'r') as f: + return {"error": "Radio presets file not found"} + + with open(presets_file, "r") as f: presets_data = json.load(f) - + # Extract entries from local file - entries = presets_data.get('config', {}).get('suggested_radio_settings', {}).get('entries', []) - return {'presets': entries, 'source': 'local'} - + entries = ( + presets_data.get("config", {}) + .get("suggested_radio_settings", {}) + .get("entries", []) + ) + return {"presets": entries, "source": "local"} + except Exception as e: logger.error(f"Error loading radio presets: {e}") - return {'error': str(e)} + return {"error": str(e)} @cherrypy.expose @cherrypy.tools.json_out() @@ -335,117 +367,123 @@ class APIEndpoints: try: self._require_post() data = cherrypy.request.json - + # Validate required fields - node_name = data.get('node_name', '').strip() + node_name = data.get("node_name", "").strip() if not node_name: - return {'success': False, 'error': 'Node name is required'} + return {"success": False, "error": "Node name is required"} # Validate UTF-8 byte length (31 bytes max + 1 null terminator = 32 bytes total) - if len(node_name.encode('utf-8')) > 31: - return {'success': False, 'error': 'Node name too long (max 31 bytes in UTF-8)'} - - hardware_key = data.get('hardware_key', '').strip() + if len(node_name.encode("utf-8")) > 31: + return {"success": False, "error": "Node name too long (max 31 bytes in UTF-8)"} + + hardware_key = data.get("hardware_key", "").strip() if not hardware_key: - return {'success': False, 'error': 'Hardware selection is required'} - - radio_preset = data.get('radio_preset', {}) + return {"success": False, "error": "Hardware selection is required"} + + radio_preset = data.get("radio_preset", {}) if not radio_preset: - return {'success': False, 'error': 'Radio preset selection is required'} - - admin_password = data.get('admin_password', '').strip() + return {"success": False, "error": "Radio preset selection is required"} + + admin_password = data.get("admin_password", "").strip() if not admin_password or len(admin_password) < 6: - return {'success': False, 'error': 'Admin password must be at least 6 characters'} - + return {"success": False, "error": "Admin password must be at least 6 characters"} + import json + import yaml # Read current config first so we can update it - with open(self._config_path, 'r') as f: + with open(self._config_path, "r") as f: config_yaml = yaml.safe_load(f) # Update repeater settings - if 'repeater' not in config_yaml: - config_yaml['repeater'] = {} - config_yaml['repeater']['node_name'] = node_name + if "repeater" not in config_yaml: + config_yaml["repeater"] = {} + config_yaml["repeater"]["node_name"] = node_name - if 'security' not in config_yaml['repeater']: - config_yaml['repeater']['security'] = {} - config_yaml['repeater']['security']['admin_password'] = admin_password + if "security" not in config_yaml["repeater"]: + config_yaml["repeater"]["security"] = {} + config_yaml["repeater"]["security"]["admin_password"] = admin_password # Update radio settings - convert MHz/kHz to Hz (used for both SX1262 and KISS modem) - if 'radio' not in config_yaml: - config_yaml['radio'] = {} - freq_mhz = float(radio_preset.get('frequency', 0)) - bw_khz = float(radio_preset.get('bandwidth', 0)) - config_yaml['radio']['frequency'] = int(freq_mhz * 1000000) - config_yaml['radio']['spreading_factor'] = int(radio_preset.get('spreading_factor', 7)) - config_yaml['radio']['bandwidth'] = int(bw_khz * 1000) - config_yaml['radio']['coding_rate'] = int(radio_preset.get('coding_rate', 5)) + if "radio" not in config_yaml: + config_yaml["radio"] = {} + freq_mhz = float(radio_preset.get("frequency", 0)) + bw_khz = float(radio_preset.get("bandwidth", 0)) + config_yaml["radio"]["frequency"] = int(freq_mhz * 1000000) + config_yaml["radio"]["spreading_factor"] = int(radio_preset.get("spreading_factor", 7)) + config_yaml["radio"]["bandwidth"] = int(bw_khz * 1000) + config_yaml["radio"]["coding_rate"] = int(radio_preset.get("coding_rate", 5)) - if hardware_key == 'kiss': + if hardware_key == "kiss": # KISS modem: set radio_type and kiss section (port/baud from request or defaults) - config_yaml['radio_type'] = 'kiss' - kiss_port = (data.get('kiss_port') or '').strip() or '/dev/ttyUSB0' - kiss_baud = int(data.get('kiss_baud_rate', data.get('kiss_baud', 115200))) - config_yaml['kiss'] = {'port': kiss_port, 'baud_rate': kiss_baud} - config_yaml['radio']['tx_power'] = int(radio_preset.get('tx_power', 14)) - if 'preamble_length' not in config_yaml['radio']: - config_yaml['radio']['preamble_length'] = 17 + config_yaml["radio_type"] = "kiss" + kiss_port = (data.get("kiss_port") or "").strip() or "/dev/ttyUSB0" + kiss_baud = int(data.get("kiss_baud_rate", data.get("kiss_baud", 115200))) + config_yaml["kiss"] = {"port": kiss_port, "baud_rate": kiss_baud} + config_yaml["radio"]["tx_power"] = int(radio_preset.get("tx_power", 14)) + if "preamble_length" not in config_yaml["radio"]: + config_yaml["radio"]["preamble_length"] = 17 else: # SX1262: load hardware config from radio-settings.json storage_cfg = self.config.get("storage", {}) config_dir = Path(storage_cfg.get("storage_dir", "/var/lib/pymc_repeater")) - installed_path = config_dir / 'radio-settings.json' - dev_path = os.path.join(os.path.dirname(__file__), '..', '..', 'radio-settings.json') + installed_path = config_dir / "radio-settings.json" + dev_path = os.path.join( + os.path.dirname(__file__), "..", "..", "radio-settings.json" + ) hardware_file = str(installed_path) if installed_path.exists() else dev_path if not os.path.exists(hardware_file): - return {'success': False, 'error': 'Hardware configuration file not found'} - with open(hardware_file, 'r') as f: + return {"success": False, "error": "Hardware configuration file not found"} + with open(hardware_file, "r") as f: hardware_data = json.load(f) - hardware_configs = hardware_data.get('hardware', {}) + hardware_configs = hardware_data.get("hardware", {}) hw_config = hardware_configs.get(hardware_key, {}) if not hw_config: - return {'success': False, 'error': f'Hardware configuration not found: {hardware_key}'} + return { + "success": False, + "error": f"Hardware configuration not found: {hardware_key}", + } - config_yaml['radio_type'] = 'sx1262' - if 'tx_power' in hw_config: - config_yaml['radio']['tx_power'] = hw_config.get('tx_power', 22) - if 'preamble_length' in hw_config: - config_yaml['radio']['preamble_length'] = hw_config.get('preamble_length', 17) + config_yaml["radio_type"] = "sx1262" + if "tx_power" in hw_config: + config_yaml["radio"]["tx_power"] = hw_config.get("tx_power", 22) + if "preamble_length" in hw_config: + config_yaml["radio"]["preamble_length"] = hw_config.get("preamble_length", 17) - if 'sx1262' not in config_yaml: - config_yaml['sx1262'] = {} - if 'bus_id' in hw_config: - config_yaml['sx1262']['bus_id'] = hw_config.get('bus_id', 0) - if 'cs_id' in hw_config: - config_yaml['sx1262']['cs_id'] = hw_config.get('cs_id', 0) - if 'reset_pin' in hw_config: - config_yaml['sx1262']['reset_pin'] = hw_config.get('reset_pin', 22) - if 'busy_pin' in hw_config: - config_yaml['sx1262']['busy_pin'] = hw_config.get('busy_pin', 17) - if 'irq_pin' in hw_config: - config_yaml['sx1262']['irq_pin'] = hw_config.get('irq_pin', 16) - if 'txen_pin' in hw_config: - config_yaml['sx1262']['txen_pin'] = hw_config.get('txen_pin', -1) - if 'rxen_pin' in hw_config: - config_yaml['sx1262']['rxen_pin'] = hw_config.get('rxen_pin', -1) - if 'cs_pin' in hw_config: - config_yaml['sx1262']['cs_pin'] = hw_config.get('cs_pin', -1) - if 'txled_pin' in hw_config: - config_yaml['sx1262']['txled_pin'] = hw_config.get('txled_pin', -1) - if 'rxled_pin' in hw_config: - config_yaml['sx1262']['rxled_pin'] = hw_config.get('rxled_pin', -1) - if 'use_dio3_tcxo' in hw_config: - config_yaml['sx1262']['use_dio3_tcxo'] = hw_config.get('use_dio3_tcxo', False) - if 'use_dio2_rf' in hw_config: - config_yaml['sx1262']['use_dio2_rf'] = hw_config.get('use_dio2_rf', False) - if 'is_waveshare' in hw_config: - config_yaml['sx1262']['is_waveshare'] = hw_config.get('is_waveshare', False) + if "sx1262" not in config_yaml: + config_yaml["sx1262"] = {} + if "bus_id" in hw_config: + config_yaml["sx1262"]["bus_id"] = hw_config.get("bus_id", 0) + if "cs_id" in hw_config: + config_yaml["sx1262"]["cs_id"] = hw_config.get("cs_id", 0) + if "reset_pin" in hw_config: + config_yaml["sx1262"]["reset_pin"] = hw_config.get("reset_pin", 22) + if "busy_pin" in hw_config: + config_yaml["sx1262"]["busy_pin"] = hw_config.get("busy_pin", 17) + if "irq_pin" in hw_config: + config_yaml["sx1262"]["irq_pin"] = hw_config.get("irq_pin", 16) + if "txen_pin" in hw_config: + config_yaml["sx1262"]["txen_pin"] = hw_config.get("txen_pin", -1) + if "rxen_pin" in hw_config: + config_yaml["sx1262"]["rxen_pin"] = hw_config.get("rxen_pin", -1) + if "cs_pin" in hw_config: + config_yaml["sx1262"]["cs_pin"] = hw_config.get("cs_pin", -1) + if "txled_pin" in hw_config: + config_yaml["sx1262"]["txled_pin"] = hw_config.get("txled_pin", -1) + if "rxled_pin" in hw_config: + config_yaml["sx1262"]["rxled_pin"] = hw_config.get("rxled_pin", -1) + if "use_dio3_tcxo" in hw_config: + config_yaml["sx1262"]["use_dio3_tcxo"] = hw_config.get("use_dio3_tcxo", False) + if "use_dio2_rf" in hw_config: + config_yaml["sx1262"]["use_dio2_rf"] = hw_config.get("use_dio2_rf", False) + if "is_waveshare" in hw_config: + config_yaml["sx1262"]["is_waveshare"] = hw_config.get("is_waveshare", False) # Write updated config - with open(self._config_path, 'w') as f: + with open(self._config_path, "w") as f: yaml.dump(config_yaml, f, default_flow_style=False, sort_keys=False) - + logger.info( f"Setup wizard completed: node_name={node_name}, hardware={hardware_key}, freq={freq_mhz}MHz" ) @@ -453,39 +491,44 @@ class APIEndpoints: # Trigger service restart after setup import subprocess import threading - + def delayed_restart(): import time + time.sleep(2) # Give time for response to be sent try: # Use systemctl without sudo - polkit rules allow the repeater user to restart the service - subprocess.run(['systemctl', 'restart', 'pymc-repeater'], check=False) + subprocess.run(["systemctl", "restart", "pymc-repeater"], check=False) except Exception as e: logger.error(f"Failed to restart service: {e}") - + # Start restart in background thread restart_thread = threading.Thread(target=delayed_restart, daemon=True) restart_thread.start() - + result_config = { - 'node_name': node_name, - 'hardware': hardware_key, - 'radio_type': config_yaml.get('radio_type', 'sx1262'), - 'frequency': freq_mhz, - 'spreading_factor': radio_preset.get('spreading_factor'), - 'bandwidth': radio_preset.get('bandwidth'), - 'coding_rate': radio_preset.get('coding_rate') + "node_name": node_name, + "hardware": hardware_key, + "radio_type": config_yaml.get("radio_type", "sx1262"), + "frequency": freq_mhz, + "spreading_factor": radio_preset.get("spreading_factor"), + "bandwidth": radio_preset.get("bandwidth"), + "coding_rate": radio_preset.get("coding_rate"), } - if hardware_key == 'kiss': - result_config['kiss_port'] = config_yaml.get('kiss', {}).get('port') - result_config['kiss_baud_rate'] = config_yaml.get('kiss', {}).get('baud_rate') - return {'success': True, 'message': 'Setup completed successfully. Service is restarting...', 'config': result_config} - + if hardware_key == "kiss": + result_config["kiss_port"] = config_yaml.get("kiss", {}).get("port") + result_config["kiss_baud_rate"] = config_yaml.get("kiss", {}).get("baud_rate") + return { + "success": True, + "message": "Setup completed successfully. Service is restarting...", + "config": result_config, + } + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error completing setup wizard: {e}", exc_info=True) - return {'success': False, 'error': str(e)} + return {"success": False, "error": str(e)} # ============================================================================ # SYSTEM ENDPOINTS @@ -499,6 +542,7 @@ class APIEndpoints: stats["version"] = __version__ try: import pymc_core + stats["core_version"] = pymc_core.__version__ except ImportError: stats["core_version"] = "unknown" @@ -512,10 +556,10 @@ class APIEndpoints: def send_advert(self): # Enable CORS for this endpoint self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() if not self.send_advert_func: @@ -523,9 +567,14 @@ class APIEndpoints: if self.event_loop is None: return self._error("Event loop not available") import asyncio + future = asyncio.run_coroutine_threadsafe(self.send_advert_func(), self.event_loop) result = future.result(timeout=10) - return self._success("Advert sent successfully") if result else self._error("Failed to send advert") + return ( + self._success("Advert sent successfully") + if result + else self._error("Failed to send advert") + ) except cherrypy.HTTPError: # Re-raise HTTP errors (like 405 Method Not Allowed) without logging raise @@ -539,10 +588,10 @@ class APIEndpoints: def set_mode(self): # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json @@ -567,10 +616,10 @@ class APIEndpoints: def set_duty_cycle(self): # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json @@ -592,20 +641,20 @@ class APIEndpoints: @cherrypy.tools.json_in() def update_duty_cycle_config(self): self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json or {} - + applied = [] - + # Ensure config section exists if "duty_cycle" not in self.config: self.config["duty_cycle"] = {} - + # Update max airtime percentage if "max_airtime_percent" in data: percent = float(data["max_airtime_percent"]) @@ -615,21 +664,19 @@ class APIEndpoints: max_airtime_ms = int((percent / 100) * 60000) self.config["duty_cycle"]["max_airtime_per_minute"] = max_airtime_ms applied.append(f"max_airtime={percent}%") - + # Update enforcement enabled/disabled if "enforcement_enabled" in data: enabled = bool(data["enforcement_enabled"]) self.config["duty_cycle"]["enforcement_enabled"] = enabled applied.append(f"enforcement={'enabled' if enabled else 'disabled'}") - + if not applied: return self._error("No valid settings provided") - + # Save to config file and live update daemon result = self.config_manager.update_and_save( - updates={}, - live_update=True, - live_update_sections=['duty_cycle'] + updates={}, live_update=True, live_update_sections=["duty_cycle"] ) if not result.get("saved", False): @@ -637,14 +684,16 @@ class APIEndpoints: logger.info(f"Duty cycle config updated: {', '.join(applied)}") - return self._success({ - "applied": applied, - "persisted": True, - "live_update": result.get("live_updated", False), - "restart_required": False, - "message": "Duty cycle settings applied immediately." - }) - + return self._success( + { + "applied": applied, + "persisted": True, + "live_update": result.get("live_updated", False), + "restart_required": False, + "message": "Duty cycle settings applied immediately.", + } + ) + except cherrypy.HTTPError: raise except Exception as e: @@ -656,55 +705,51 @@ class APIEndpoints: def check_pymc_console(self): """Check if PyMC Console directory exists.""" self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - pymc_console_path = '/opt/pymc_console/web/html' + pymc_console_path = "/opt/pymc_console/web/html" exists = os.path.isdir(pymc_console_path) - - return self._success({ - "exists": exists, - "path": pymc_console_path - }) + + return self._success({"exists": exists, "path": pymc_console_path}) except Exception as e: logger.error(f"Error checking PyMC Console directory: {e}") return self._error(str(e)) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def update_web_config(self): """Update web configuration (CORS, frontend path) using ConfigManager.""" self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() updates = cherrypy.request.json or {} - + if not updates: return self._error("No configuration updates provided") - + # Use ConfigManager to update and save configuration # Web changes (CORS, web_path) don't require live update - result = self.config_manager.update_and_save( - updates=updates, - live_update=False - ) - + result = self.config_manager.update_and_save(updates=updates, live_update=False) + if result.get("success"): logger.info(f"Web configuration updated: {list(updates.keys())}") - return self._success({ - "persisted": result.get("saved", False), - "message": "Web configuration saved successfully. Restart required for changes to take effect." - }) + return self._success( + { + "persisted": result.get("saved", False), + "message": "Web configuration saved successfully. Restart required for changes to take effect.", + } + ) else: return self._error(result.get("error", "Failed to update web configuration")) - + except cherrypy.HTTPError: raise except Exception as e: @@ -718,22 +763,22 @@ class APIEndpoints: """Restart the pymc-repeater service via systemctl.""" # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() from repeater.service_utils import restart_service as do_restart - + logger.warning("Service restart requested via API") success, message = do_restart() - + if success: return {"success": True, "message": message} else: return self._error(message) - + except cherrypy.HTTPError: raise except Exception as e: @@ -744,6 +789,7 @@ class APIEndpoints: @cherrypy.tools.json_out() def logs(self): from .http_server import _log_buffer + try: logs = list(_log_buffer.logs) return { @@ -794,7 +840,9 @@ class APIEndpoints: if processes: return self._success(processes) else: - return self._error("Process information not available (psutil may not be installed)") + return self._error( + "Process information not available (psutil may not be installed)" + ) else: return self._error("Storage collector not available") except Exception as e: @@ -856,7 +904,7 @@ class APIEndpoints: # Enforce reasonable limits limit = min(int(limit), 10000) offset = max(int(offset), 0) - + # Get packets from storage with TRUE DB-level pagination # Uses SQL "LIMIT ? OFFSET ?" - no Python slicing needed! storage = self._get_storage() @@ -866,32 +914,34 @@ class APIEndpoints: start_timestamp=float(start_timestamp) if start_timestamp else None, end_timestamp=float(end_timestamp) if end_timestamp else None, limit=limit, - offset=offset + offset=offset, ) - + response = { "success": True, "data": packets, "count": len(packets), "offset": offset, "limit": limit, - "compressed": True + "compressed": True, } - + return response - + except Exception as e: logger.error(f"Error getting bulk packets: {e}") return self._error(e) @cherrypy.expose @cherrypy.tools.json_out() - def filtered_packets(self, start_timestamp=None, end_timestamp=None, limit=1000, type=None, route=None): + def filtered_packets( + self, start_timestamp=None, end_timestamp=None, limit=1000, type=None, route=None + ): # Handle OPTIONS request for CORS preflight if cherrypy.request.method == "OPTIONS": self._set_cors_headers() return "" - + try: # Convert 'type' parameter to 'packet_type' for storage method packet_type = int(type) if type is not None else None @@ -899,21 +949,25 @@ class APIEndpoints: start_ts = float(start_timestamp) if start_timestamp is not None else None end_ts = float(end_timestamp) if end_timestamp is not None else None limit_int = int(limit) if limit is not None else 1000 - + packets = self._get_storage().get_filtered_packets( packet_type=packet_type, route=route_int, start_timestamp=start_ts, end_timestamp=end_ts, - limit=limit_int + limit=limit_int, + ) + return self._success( + packets, + count=len(packets), + filters={ + "type": packet_type, + "route": route_int, + "start_timestamp": start_ts, + "end_timestamp": end_ts, + "limit": limit_int, + }, ) - return self._success(packets, count=len(packets), filters={ - 'type': packet_type, - 'route': route_int, - 'start_timestamp': start_ts, - 'end_timestamp': end_ts, - 'limit': limit_int - }) except ValueError as e: return self._error(f"Invalid parameter format: {e}") except Exception as e: @@ -947,11 +1001,9 @@ class APIEndpoints: @cherrypy.tools.json_out() def rrd_data(self): try: - params = self._get_params({ - 'start_time': None, - 'end_time': None, - 'resolution': 'average' - }) + params = self._get_params( + {"start_time": None, "end_time": None, "resolution": "average"} + ) data = self._get_storage().get_rrd_data(**params) return self._success(data) if data else self._error("No RRD data available") except ValueError as e: @@ -962,33 +1014,40 @@ class APIEndpoints: @cherrypy.expose @cherrypy.tools.json_out() - def packet_type_graph_data(self, hours=24, resolution='average', types='all'): - + def packet_type_graph_data(self, hours=24, resolution="average", types="all"): + try: hours = int(hours) start_time, end_time = self._get_time_range(hours) - + storage = self._get_storage() - + stats = storage.sqlite_handler.get_packet_type_stats(hours) - if 'error' in stats: - return self._error(stats['error']) - - packet_type_totals = stats.get('packet_type_totals', {}) - + if "error" in stats: + return self._error(stats["error"]) + + packet_type_totals = stats.get("packet_type_totals", {}) + # Create simple bar chart data format for packet types series = [] for type_name, count in packet_type_totals.items(): if count > 0: # Only include types with actual data - series.append({ - "name": type_name, - "type": type_name.lower().replace(' ', '_').replace('(', '').replace(')', ''), - "data": [[end_time * 1000, count]] # Single data point with total count - }) - + series.append( + { + "name": type_name, + "type": type_name.lower() + .replace(" ", "_") + .replace("(", "") + .replace(")", ""), + "data": [ + [end_time * 1000, count] + ], # Single data point with total count + } + ) + # Sort series by count (descending) - series.sort(key=lambda x: x['data'][0][1], reverse=True) - + series.sort(key=lambda x: x["data"][0][1], reverse=True) + graph_data = { "start_time": start_time, "end_time": end_time, @@ -996,11 +1055,11 @@ class APIEndpoints: "timestamps": [start_time, end_time], "series": series, "data_source": "sqlite", - "chart_type": "bar" # Indicate this is bar chart data + "chart_type": "bar", # Indicate this is bar chart data } - + return self._success(graph_data) - + except ValueError as e: return self._error(f"Invalid parameter format: {e}") except Exception as e: @@ -1009,59 +1068,69 @@ class APIEndpoints: @cherrypy.expose @cherrypy.tools.json_out() - def metrics_graph_data(self, hours=24, resolution='average', metrics='all'): - + def metrics_graph_data(self, hours=24, resolution="average", metrics="all"): + try: hours = int(hours) start_time, end_time = self._get_time_range(hours) - + rrd_data = self._get_storage().get_rrd_data( start_time=start_time, end_time=end_time, resolution=resolution ) - - if not rrd_data or 'metrics' not in rrd_data: + + if not rrd_data or "metrics" not in rrd_data: return self._error("No RRD data available") - + metric_names = { - 'rx_count': 'Received Packets', 'tx_count': 'Transmitted Packets', - 'drop_count': 'Dropped Packets', 'avg_rssi': 'Average RSSI (dBm)', - 'avg_snr': 'Average SNR (dB)', 'avg_length': 'Average Packet Length', - 'avg_score': 'Average Score', 'neighbor_count': 'Neighbor Count' + "rx_count": "Received Packets", + "tx_count": "Transmitted Packets", + "drop_count": "Dropped Packets", + "avg_rssi": "Average RSSI (dBm)", + "avg_snr": "Average SNR (dB)", + "avg_length": "Average Packet Length", + "avg_score": "Average Score", + "neighbor_count": "Neighbor Count", } - - counter_metrics = ['rx_count', 'tx_count', 'drop_count'] - - if metrics != 'all': - requested_metrics = [m.strip() for m in metrics.split(',')] + + counter_metrics = ["rx_count", "tx_count", "drop_count"] + + if metrics != "all": + requested_metrics = [m.strip() for m in metrics.split(",")] else: - requested_metrics = list(rrd_data['metrics'].keys()) - - timestamps_ms = [ts * 1000 for ts in rrd_data['timestamps']] + requested_metrics = list(rrd_data["metrics"].keys()) + + timestamps_ms = [ts * 1000 for ts in rrd_data["timestamps"]] series = [] - + for metric_key in requested_metrics: - if metric_key in rrd_data['metrics']: + if metric_key in rrd_data["metrics"]: if metric_key in counter_metrics: - chart_data = self._process_counter_data(rrd_data['metrics'][metric_key], timestamps_ms) + chart_data = self._process_counter_data( + rrd_data["metrics"][metric_key], timestamps_ms + ) else: - chart_data = self._process_gauge_data(rrd_data['metrics'][metric_key], timestamps_ms) - - series.append({ - "name": metric_names.get(metric_key, metric_key), - "type": metric_key, - "data": chart_data - }) - + chart_data = self._process_gauge_data( + rrd_data["metrics"][metric_key], timestamps_ms + ) + + series.append( + { + "name": metric_names.get(metric_key, metric_key), + "type": metric_key, + "data": chart_data, + } + ) + graph_data = { - "start_time": rrd_data['start_time'], - "end_time": rrd_data['end_time'], - "step": rrd_data['step'], - "timestamps": rrd_data['timestamps'], - "series": series + "start_time": rrd_data["start_time"], + "end_time": rrd_data["end_time"], + "step": rrd_data["step"], + "timestamps": rrd_data["timestamps"], + "series": series, } - + return self._success(graph_data) - + except ValueError as e: return self._error(f"Invalid parameter format: {e}") except Exception as e: @@ -1069,10 +1138,10 @@ class APIEndpoints: return self._error(e) @cherrypy.expose - @cherrypy.tools.json_out() + @cherrypy.tools.json_out() @cherrypy.tools.json_in() def cad_calibration_start(self): - + try: self._require_post() data = cherrypy.request.json or {} @@ -1088,11 +1157,11 @@ class APIEndpoints: except Exception as e: logger.error(f"Error starting CAD calibration: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def cad_calibration_stop(self): - + try: self._require_post() self.cad_calibration.stop_calibration() @@ -1103,45 +1172,51 @@ class APIEndpoints: except Exception as e: logger.error(f"Error stopping CAD calibration: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def save_cad_settings(self): - + try: self._require_post() data = cherrypy.request.json or {} peak = data.get("peak") min_val = data.get("min_val") detection_rate = data.get("detection_rate", 0) - + if peak is None or min_val is None: return self._error("Missing peak or min_val parameters") - - if self.daemon_instance and hasattr(self.daemon_instance, 'radio') and self.daemon_instance.radio: - if hasattr(self.daemon_instance.radio, 'set_custom_cad_thresholds'): + + if ( + self.daemon_instance + and hasattr(self.daemon_instance, "radio") + and self.daemon_instance.radio + ): + if hasattr(self.daemon_instance.radio, "set_custom_cad_thresholds"): self.daemon_instance.radio.set_custom_cad_thresholds(peak=peak, min_val=min_val) logger.info(f"Applied CAD settings to radio: peak={peak}, min={min_val}") - + if "radio" not in self.config: self.config["radio"] = {} if "cad" not in self.config["radio"]: self.config["radio"]["cad"] = {} - + self.config["radio"]["cad"]["peak_threshold"] = peak self.config["radio"]["cad"]["min_threshold"] = min_val - - config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml') + + config_path = getattr(self, "_config_path", "/etc/pymc_repeater/config.yaml") saved, err = self.config_manager.save_to_file() if not saved: return self._error(err or "Failed to save configuration to file") - logger.info(f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%") + logger.info( + f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%" + ) return { - "success": True, + "success": True, "message": f"CAD settings saved: peak={peak}, min={min_val}", - "settings": {"peak": peak, "min_val": min_val, "detection_rate": detection_rate} + "settings": {"peak": peak, "min_val": min_val, "detection_rate": detection_rate}, } except cherrypy.HTTPError: # Re-raise HTTP errors (like 405 Method Not Allowed) without logging @@ -1155,7 +1230,7 @@ class APIEndpoints: @cherrypy.tools.json_in() def update_radio_config(self): """Update radio and repeater configuration with live updates. - + POST /api/update_radio_config Body: { "tx_power": 22, # TX power in dBm (2-30) @@ -1173,23 +1248,23 @@ class APIEndpoints: "flood_advert_interval_hours": 10, # Flood advert interval (0 or 3-48) "advert_interval_minutes": 120 # Local advert interval (0 or 1-10080) } - + Note: Radio hardware changes (frequency, bandwidth, SF, CR) require restart to apply. - + Returns: {"success": true, "data": {"applied": [...], "live_update": true}} """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json or {} - + applied = [] - + # Ensure config sections exist if "radio" not in self.config: self.config["radio"] = {} @@ -1197,7 +1272,7 @@ class APIEndpoints: self.config["delays"] = {} if "repeater" not in self.config: self.config["repeater"] = {} - + # Update TX power (up to 30 dBm for high-power radios) if "tx_power" in data: power = int(data["tx_power"]) @@ -1205,7 +1280,7 @@ class APIEndpoints: return self._error("TX power must be 2-30 dBm") self.config["radio"]["tx_power"] = power applied.append(f"power={power}dBm") - + # Update frequency (in Hz) if "frequency" in data: freq = float(data["frequency"]) @@ -1213,7 +1288,7 @@ class APIEndpoints: return self._error("Frequency must be 100-1000 MHz") self.config["radio"]["frequency"] = freq applied.append(f"freq={freq/1_000_000:.3f}MHz") - + # Update bandwidth (in Hz) if "bandwidth" in data: bw = int(float(data["bandwidth"])) @@ -1222,7 +1297,7 @@ class APIEndpoints: return self._error(f"Bandwidth must be one of {[b/1000 for b in valid_bw]} kHz") self.config["radio"]["bandwidth"] = bw applied.append(f"bw={bw/1000}kHz") - + # Update spreading factor if "spreading_factor" in data: sf = int(data["spreading_factor"]) @@ -1230,7 +1305,7 @@ class APIEndpoints: return self._error("Spreading factor must be 5-12") self.config["radio"]["spreading_factor"] = sf applied.append(f"sf={sf}") - + # Update coding rate if "coding_rate" in data: cr = int(data["coding_rate"]) @@ -1238,7 +1313,7 @@ class APIEndpoints: return self._error("Coding rate must be 5-8 (for 4/5 to 4/8)") self.config["radio"]["coding_rate"] = cr applied.append(f"cr=4/{cr}") - + # Update TX delay factor if "tx_delay_factor" in data: tdf = float(data["tx_delay_factor"]) @@ -1246,7 +1321,7 @@ class APIEndpoints: return self._error("TX delay factor must be 0.0-5.0") self.config["delays"]["tx_delay_factor"] = tdf applied.append(f"txdelay={tdf}") - + # Update direct TX delay factor if "direct_tx_delay_factor" in data: dtdf = float(data["direct_tx_delay_factor"]) @@ -1254,7 +1329,7 @@ class APIEndpoints: return self._error("Direct TX delay factor must be 0.0-5.0") self.config["delays"]["direct_tx_delay_factor"] = dtdf applied.append(f"direct.txdelay={dtdf}") - + # Update RX delay base if "rx_delay_base" in data: rxd = float(data["rx_delay_base"]) @@ -1262,18 +1337,18 @@ class APIEndpoints: return self._error("RX delay cannot be negative") self.config["delays"]["rx_delay_base"] = rxd applied.append(f"rxdelay={rxd}") - + # Update node name if "node_name" in data: name = str(data["node_name"]).strip() if not name: return self._error("Node name cannot be empty") # Validate UTF-8 byte length (31 bytes max + 1 null terminator = 32 bytes total) - if len(name.encode('utf-8')) > 31: + if len(name.encode("utf-8")) > 31: return self._error("Node name too long (max 31 bytes in UTF-8)") self.config["repeater"]["node_name"] = name applied.append(f"name={name}") - + # Update latitude if "latitude" in data: lat = float(data["latitude"]) @@ -1281,7 +1356,7 @@ class APIEndpoints: return self._error("Latitude must be -90 to 90") self.config["repeater"]["latitude"] = lat applied.append(f"lat={lat}") - + # Update longitude if "longitude" in data: lon = float(data["longitude"]) @@ -1289,7 +1364,7 @@ class APIEndpoints: return self._error("Longitude must be -180 to 180") self.config["repeater"]["longitude"] = lon applied.append(f"lon={lon}") - + # Update max flood hops if "max_flood_hops" in data: hops = int(data["max_flood_hops"]) @@ -1297,7 +1372,7 @@ class APIEndpoints: return self._error("Max flood hops must be 0-64") self.config["repeater"]["max_flood_hops"] = hops applied.append(f"flood.max={hops}") - + # Update flood advert interval (hours) if "flood_advert_interval_hours" in data: hours = int(data["flood_advert_interval_hours"]) @@ -1305,7 +1380,7 @@ class APIEndpoints: return self._error("Flood advert interval must be 0 (off) or 3-48 hours") self.config["repeater"]["send_advert_interval_hours"] = hours applied.append(f"flood.advert.interval={hours}h") - + # Update local advert interval (minutes) if "advert_interval_minutes" in data: mins = int(data["advert_interval_minutes"]) @@ -1326,18 +1401,18 @@ class APIEndpoints: if "kiss_baud_rate" in data: self.config["kiss"]["baud_rate"] = int(data["kiss_baud_rate"]) applied.append("kiss.baud_rate") - + if not applied: return self._error("No valid settings provided") - - live_sections = ['repeater', 'delays', 'radio'] + + live_sections = ["repeater", "delays", "radio"] if "kiss" in self.config: live_sections.append("kiss") # Save to config file and live update daemon in one operation result = self.config_manager.update_and_save( updates={}, # Updates already applied to self.config above live_update=True, - live_update_sections=live_sections + live_update_sections=live_sections, ) if not result.get("saved", False): @@ -1345,14 +1420,20 @@ class APIEndpoints: logger.info(f"Radio config updated: {', '.join(applied)}") - return self._success({ - "applied": applied, - "persisted": True, - "live_update": result.get("live_updated", False), - "restart_required": not result.get("live_updated", False), - "message": "Settings applied immediately." if result.get("live_updated") else "Settings saved. Restart service to apply changes." - }) - + return self._success( + { + "applied": applied, + "persisted": True, + "live_update": result.get("live_updated", False), + "restart_required": not result.get("live_updated", False), + "message": ( + "Settings applied immediately." + if result.get("live_updated") + else "Settings saved. Restart service to apply changes." + ), + } + ) + except cherrypy.HTTPError: raise except Exception as e: @@ -1362,79 +1443,69 @@ class APIEndpoints: @cherrypy.expose @cherrypy.tools.json_out() def noise_floor_history(self, hours: int = 24, limit: int = None): - + try: storage = self._get_storage() hours = int(hours) limit = int(limit) if limit else None history = storage.get_noise_floor_history(hours=hours, limit=limit) - - return self._success({ - "history": history, - "hours": hours, - "count": len(history) - }) + + return self._success({"history": history, "hours": hours, "count": len(history)}) except Exception as e: logger.error(f"Error fetching noise floor history: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def noise_floor_stats(self, hours: int = 24): - + try: storage = self._get_storage() hours = int(hours) stats = storage.get_noise_floor_stats(hours=hours) - - return self._success({ - "stats": stats, - "hours": hours - }) + + return self._success({"stats": stats, "hours": hours}) except Exception as e: logger.error(f"Error fetching noise floor stats: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def noise_floor_chart_data(self, hours: int = 24): - + try: storage = self._get_storage() hours = int(hours) chart_data = storage.get_noise_floor_rrd(hours=hours) - - return self._success({ - "chart_data": chart_data, - "hours": hours - }) + + return self._success({"chart_data": chart_data, "hours": hours}) except Exception as e: logger.error(f"Error fetching noise floor chart data: {e}") return self._error(e) @cherrypy.expose def cad_calibration_stream(self): - cherrypy.response.headers['Content-Type'] = 'text/event-stream' - cherrypy.response.headers['Cache-Control'] = 'no-cache' - cherrypy.response.headers['Connection'] = 'keep-alive' - - if not hasattr(self.cad_calibration, 'message_queue'): + cherrypy.response.headers["Content-Type"] = "text/event-stream" + cherrypy.response.headers["Cache-Control"] = "no-cache" + cherrypy.response.headers["Connection"] = "keep-alive" + + if not hasattr(self.cad_calibration, "message_queue"): self.cad_calibration.message_queue = [] - + def generate(): try: yield f"data: {json.dumps({'type': 'connected', 'message': 'Connected to CAD calibration stream'})}\n\n" - + if self.cad_calibration.running: - config = getattr(self.cad_calibration.daemon_instance, 'config', {}) + config = getattr(self.cad_calibration.daemon_instance, "config", {}) radio_config = config.get("radio", {}) sf = radio_config.get("spreading_factor", 8) - + peak_range, min_range = self.cad_calibration.get_test_ranges(sf) total_tests = len(peak_range) * len(min_range) - + status_message = { - "type": "status", + "type": "status", "message": f"Calibration in progress: SF{sf}, {total_tests} tests", "test_ranges": { "peak_min": min(peak_range), @@ -1442,13 +1513,13 @@ class APIEndpoints: "min_min": min(min_range), "min_max": max(min_range), "spreading_factor": sf, - "total_tests": total_tests - } + "total_tests": total_tests, + }, } yield f"data: {json.dumps(status_message)}\n\n" - + last_message_index = len(self.cad_calibration.message_queue) - + while True: current_queue_length = len(self.cad_calibration.message_queue) if current_queue_length > last_message_index: @@ -1458,43 +1529,39 @@ class APIEndpoints: last_message_index = current_queue_length else: yield f"data: {json.dumps({'type': 'keepalive'})}\n\n" - + time.sleep(0.5) - + except Exception as e: logger.error(f"SSE stream error: {e}") - + return generate() - cad_calibration_stream._cp_config = {'response.stream': True} + cad_calibration_stream._cp_config = {"response.stream": True} @cherrypy.expose @cherrypy.tools.json_out() def adverts_by_contact_type(self, contact_type=None, limit=None, hours=None): - + try: if not contact_type: return self._error("contact_type parameter is required") - + limit_int = int(limit) if limit is not None else None hours_int = int(hours) if hours is not None else None - + storage = self._get_storage() adverts = storage.sqlite_handler.get_adverts_by_contact_type( - contact_type=contact_type, - limit=limit_int, - hours=hours_int + contact_type=contact_type, limit=limit_int, hours=hours_int ) - - return self._success(adverts, - count=len(adverts), - contact_type=contact_type, - filters={ - "contact_type": contact_type, - "limit": limit_int, - "hours": hours_int - }) - + + return self._success( + adverts, + count=len(adverts), + contact_type=contact_type, + filters={"contact_type": contact_type, "limit": limit_int, "hours": hours_int}, + ) + except ValueError as e: return self._error(f"Invalid parameter format: {e}") except Exception as e: @@ -1505,7 +1572,7 @@ class APIEndpoints: @cherrypy.tools.json_out() @cherrypy.tools.json_in() def transport_keys(self): - + if cherrypy.request.method == "GET": try: storage = self._get_storage() @@ -1514,7 +1581,7 @@ class APIEndpoints: except Exception as e: logger.error(f"Error getting transport keys: {e}") return self._error(e) - + elif cherrypy.request.method == "POST": try: data = cherrypy.request.json or {} @@ -1523,30 +1590,35 @@ class APIEndpoints: transport_key = data.get("transport_key") # Optional now parent_id = data.get("parent_id") last_used = data.get("last_used") - + if not name or not flood_policy: return self._error("Missing required fields: name, flood_policy") - + if flood_policy not in ["allow", "deny"]: return self._error("flood_policy must be 'allow' or 'deny'") - + # Convert ISO timestamp string to float if provided if last_used: try: from datetime import datetime - dt = datetime.fromisoformat(last_used.replace('Z', '+00:00')) + + dt = datetime.fromisoformat(last_used.replace("Z", "+00:00")) last_used = dt.timestamp() except (ValueError, AttributeError): # If conversion fails, use current time last_used = time.time() else: last_used = time.time() - + storage = self._get_storage() - key_id = storage.create_transport_key(name, flood_policy, transport_key, parent_id, last_used) - + key_id = storage.create_transport_key( + name, flood_policy, transport_key, parent_id, last_used + ) + if key_id: - return self._success({"id": key_id}, message="Transport key created successfully") + return self._success( + {"id": key_id}, message="Transport key created successfully" + ) else: return self._error("Failed to create transport key") except Exception as e: @@ -1557,7 +1629,7 @@ class APIEndpoints: @cherrypy.tools.json_out() @cherrypy.tools.json_in() def transport_key(self, key_id): - + if cherrypy.request.method == "GET": try: key_id = int(key_id) @@ -1572,35 +1644,39 @@ class APIEndpoints: except Exception as e: logger.error(f"Error getting transport key: {e}") return self._error(e) - + elif cherrypy.request.method == "PUT": try: key_id = int(key_id) data = cherrypy.request.json or {} - + name = data.get("name") flood_policy = data.get("flood_policy") transport_key = data.get("transport_key") parent_id = data.get("parent_id") last_used = data.get("last_used") - + if flood_policy and flood_policy not in ["allow", "deny"]: return self._error("flood_policy must be 'allow' or 'deny'") - + # Convert ISO timestamp string to float if provided if last_used: try: - dt = datetime.fromisoformat(last_used.replace('Z', '+00:00')) + dt = datetime.fromisoformat(last_used.replace("Z", "+00:00")) last_used = dt.timestamp() except (ValueError, AttributeError): # If conversion fails, leave as None to not update last_used = None - + storage = self._get_storage() - success = storage.update_transport_key(key_id, name, flood_policy, transport_key, parent_id, last_used) - + success = storage.update_transport_key( + key_id, name, flood_policy, transport_key, parent_id, last_used + ) + if success: - return self._success({"id": key_id}, message="Transport key updated successfully") + return self._success( + {"id": key_id}, message="Transport key updated successfully" + ) else: return self._error("Failed to update transport key or key not found") except ValueError: @@ -1608,15 +1684,17 @@ class APIEndpoints: except Exception as e: logger.error(f"Error updating transport key: {e}") return self._error(e) - + elif cherrypy.request.method == "DELETE": try: key_id = int(key_id) storage = self._get_storage() success = storage.delete_transport_key(key_id) - + if success: - return self._success({"id": key_id}, message="Transport key deleted successfully") + return self._success( + {"id": key_id}, message="Transport key deleted successfully" + ) else: return self._error("Failed to delete transport key or key not found") except ValueError: @@ -1629,10 +1707,9 @@ class APIEndpoints: @cherrypy.tools.json_out() @cherrypy.tools.json_in() def global_flood_policy(self): - """ Update global flood policy configuration - + POST /global_flood_policy Body: {"global_flood_allow": true/false} """ @@ -1640,42 +1717,44 @@ class APIEndpoints: try: data = cherrypy.request.json or {} global_flood_allow = data.get("global_flood_allow") - + if global_flood_allow is None: return self._error("Missing required field: global_flood_allow") - + if not isinstance(global_flood_allow, bool): return self._error("global_flood_allow must be a boolean value") - + # Update the running configuration first (like CAD settings) if "mesh" not in self.config: self.config["mesh"] = {} self.config["mesh"]["global_flood_allow"] = global_flood_allow - + # Get the actual config path from daemon instance (same as CAD settings) - config_path = getattr(self, '_config_path', '/etc/pymc_repeater/config.yaml') - if self.daemon_instance and hasattr(self.daemon_instance, 'config_path'): + config_path = getattr(self, "_config_path", "/etc/pymc_repeater/config.yaml") + if self.daemon_instance and hasattr(self.daemon_instance, "config_path"): config_path = self.daemon_instance.config_path - + logger.info(f"Using config path for global flood policy: {config_path}") - + # Update the configuration file using ConfigManager try: saved, err = self.config_manager.save_to_file() if saved: - logger.info(f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}") + logger.info( + f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}" + ) else: logger.error(f"Failed to save global flood policy to file: {err}") return self._error(err or "Failed to save configuration to file") except Exception as e: logger.error(f"Failed to save global flood policy to file: {e}") return self._error(f"Failed to save configuration to file: {e}") - + return self._success( {"global_flood_allow": global_flood_allow}, - message=f"Global flood policy updated to {'allow' if global_flood_allow else 'deny'} (live and saved)" + message=f"Global flood policy updated to {'allow' if global_flood_allow else 'deny'} (live and saved)", ) - + except Exception as e: logger.error(f"Error updating global flood policy: {e}") return self._error(e) @@ -1688,7 +1767,7 @@ class APIEndpoints: def advert(self, advert_id): # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" elif cherrypy.request.method == "DELETE": @@ -1696,7 +1775,7 @@ class APIEndpoints: advert_id = int(advert_id) storage = self._get_storage() success = storage.delete_advert(advert_id) - + if success: return self._success({"id": advert_id}, message="Neighbor deleted successfully") else: @@ -1717,20 +1796,20 @@ class APIEndpoints: # Enable CORS for this endpoint only if configured self._set_cors_headers() - + # Handle OPTIONS request for CORS preflight if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json or {} target_id = data.get("target_id") timeout = int(data.get("timeout", 10)) - + if not target_id: return self._error("Missing target_id parameter") - + # Parse target hash (accepts hex string like "0xA5" or "a5") try: target_hash = int(target_id, 16) if isinstance(target_id, str) else int(target_id) @@ -1738,86 +1817,88 @@ class APIEndpoints: return self._error("target_id must be a valid byte (0x00-0xFF)") except ValueError: return self._error(f"Invalid target_id format: {target_id}") - + # Check if router and trace_helper are available - if not hasattr(self.daemon_instance, 'router'): + if not hasattr(self.daemon_instance, "router"): return self._error("Packet router not available") - + router = self.daemon_instance.router - if not hasattr(self.daemon_instance, 'trace_helper'): + if not hasattr(self.daemon_instance, "trace_helper"): return self._error("Trace helper not available") - + trace_helper = self.daemon_instance.trace_helper - + # Generate unique tag for this ping import random + trace_tag = random.randint(0, 0xFFFFFFFF) - + # Create trace packet from pymc_core.protocol import PacketBuilder + packet = PacketBuilder.create_trace( - tag=trace_tag, - auth_code=0x12345678, - flags=0x00, - path=[target_hash] + tag=trace_tag, auth_code=0x12345678, flags=0x00, path=[target_hash] ) - + # Wait for response with timeout import asyncio - + async def send_and_wait(): """Async helper to send ping and wait for response""" # Register ping with TraceHelper (must be done in async context) event = trace_helper.register_ping(trace_tag, target_hash) - + # Send packet via router await router.inject_packet(packet) logger.info(f"Ping sent to 0x{target_hash:02x} with tag {trace_tag}") - + try: await asyncio.wait_for(event.wait(), timeout=timeout) return True except asyncio.TimeoutError: return False - + # Run the async send and wait in the daemon's event loop try: if self.event_loop is None: return self._error("Event loop not available") - + future = asyncio.run_coroutine_threadsafe(send_and_wait(), self.event_loop) response_received = future.result(timeout=timeout + 1) except Exception as e: logger.error(f"Error waiting for ping response: {e}") trace_helper.pending_pings.pop(trace_tag, None) return self._error(f"Error waiting for response: {str(e)}") - + if response_received: # Get result ping_info = trace_helper.pending_pings.pop(trace_tag, None) if not ping_info: return self._error("Ping info not found after response") - - result = ping_info.get('result') + + result = ping_info.get("result") if result: # Calculate round-trip time - rtt_ms = (result['received_at'] - ping_info['sent_at']) * 1000 - - return self._success({ - "target_id": f"0x{target_hash:02x}", - "rtt_ms": round(rtt_ms, 2), - "snr_db": result['snr'], - "rssi": result['rssi'], - "path": [f"0x{h:02x}" for h in result['path']], - "tag": trace_tag - }, message="Ping successful") + rtt_ms = (result["received_at"] - ping_info["sent_at"]) * 1000 + + return self._success( + { + "target_id": f"0x{target_hash:02x}", + "rtt_ms": round(rtt_ms, 2), + "snr_db": result["snr"], + "rssi": result["rssi"], + "path": [f"0x{h:02x}" for h in result["path"]], + "tag": trace_tag, + }, + message="Ping successful", + ) else: return self._error("Received response but no data") else: # Timeout trace_helper.pending_pings.pop(trace_tag, None) return self._error(f"Ping timeout after {timeout}s") - + except cherrypy.HTTPError: raise except Exception as e: @@ -1825,68 +1906,73 @@ class APIEndpoints: return self._error(str(e)) # ========== Identity Management Endpoints ========== - + @cherrypy.expose @cherrypy.tools.json_out() def identities(self): """ GET /api/identities - List all registered identities - + Returns both the in-memory registered identities and the configured ones from YAML """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - if not self.daemon_instance or not hasattr(self.daemon_instance, 'identity_manager'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "identity_manager"): return self._error("Identity manager not available") - + # Get runtime registered identities identity_manager = self.daemon_instance.identity_manager registered_identities = identity_manager.list_identities() - + # Get configured identities from config identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - + # Enhance with config data configured = [] for room_config in room_servers: name = room_config.get("name") identity_key = room_config.get("identity_key", "") settings = room_config.get("settings", {}) - + # Find matching registered identity for additional data matching = next( - (r for r in registered_identities if r["name"] == f"room_server:{name}"), - None + (r for r in registered_identities if r["name"] == f"room_server:{name}"), None ) - - configured.append({ - "name": name, - "type": "room_server", - "identity_key": identity_key[:16] + "..." if len(identity_key) > 16 else identity_key, - "identity_key_length": len(identity_key), - "settings": settings, - "hash": matching["hash"] if matching else None, - "address": matching["address"] if matching else None, - "registered": matching is not None - }) - - return self._success({ - "registered": registered_identities, - "configured": configured, - "total_registered": len(registered_identities), - "total_configured": len(configured) - }) - + + configured.append( + { + "name": name, + "type": "room_server", + "identity_key": ( + identity_key[:16] + "..." if len(identity_key) > 16 else identity_key + ), + "identity_key_length": len(identity_key), + "settings": settings, + "hash": matching["hash"] if matching else None, + "address": matching["address"] if matching else None, + "registered": matching is not None, + } + ) + + return self._success( + { + "registered": registered_identities, + "configured": configured, + "total_registered": len(registered_identities), + "total_configured": len(configured), + } + ) + except Exception as e: logger.error(f"Error listing identities: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def identity(self, name=None): @@ -1895,55 +1981,52 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: if not name: return self._error("Missing name parameter") - + identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - + # Find the identity in config - identity_config = next( - (r for r in room_servers if r.get("name") == name), - None - ) - + identity_config = next((r for r in room_servers if r.get("name") == name), None) + if not identity_config: return self._error(f"Identity '{name}' not found") - + # Get runtime info if available - if self.daemon_instance and hasattr(self.daemon_instance, 'identity_manager'): + if self.daemon_instance and hasattr(self.daemon_instance, "identity_manager"): identity_manager = self.daemon_instance.identity_manager runtime_info = identity_manager.get_identity_by_name(name) - + if runtime_info: identity_obj, config, identity_type = runtime_info identity_config["runtime"] = { "hash": f"0x{identity_obj.get_public_key()[0]:02X}", "address": identity_obj.get_address_bytes().hex(), "type": identity_type, - "registered": True + "registered": True, } else: identity_config["runtime"] = {"registered": False} - + return self._success(identity_config) - + except Exception as e: logger.error(f"Error getting identity: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def create_identity(self): """ POST /api/create_identity - Create a new identity - + Body: { "name": "MyRoomServer", "identity_key": "hex_key_string", # Optional - will be auto-generated if not provided @@ -1960,28 +2043,28 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() data = cherrypy.request.json or {} - + name = data.get("name") identity_key = data.get("identity_key") identity_type = data.get("type", "room_server") settings = data.get("settings", {}) - + if not name: return self._error("Missing required field: name") - + # Validate passwords are different if both provided admin_pw = settings.get("admin_password") guest_pw = settings.get("guest_password") if admin_pw and guest_pw and admin_pw == guest_pw: return self._error("admin_password and guest_password must be different") - + # Auto-generate identity key if not provided key_was_generated = False if not identity_key: @@ -1994,46 +2077,50 @@ class APIEndpoints: except Exception as gen_error: logger.error(f"Failed to auto-generate identity key: {gen_error}") return self._error(f"Failed to auto-generate identity key: {gen_error}") - + # Validate identity type if identity_type not in ["room_server"]: - return self._error(f"Invalid identity type: {identity_type}. Only 'room_server' is supported.") - + return self._error( + f"Invalid identity type: {identity_type}. Only 'room_server' is supported." + ) + # Check if identity already exists identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - + if any(r.get("name") == name for r in room_servers): return self._error(f"Identity with name '{name}' already exists") - + # Create new identity config new_identity = { "name": name, "identity_key": identity_key, "type": identity_type, - "settings": settings + "settings": settings, } - + # Add to config room_servers.append(new_identity) - + if "identities" not in self.config: self.config["identities"] = {} self.config["identities"]["room_servers"] = room_servers - + # Save to file saved, err = self.config_manager.save_to_file() if not saved: return self._error(err or "Failed to save configuration to file") - logger.info(f"Created new identity: {name} (type: {identity_type}){' with auto-generated key' if key_was_generated else ''}") - + logger.info( + f"Created new identity: {name} (type: {identity_type}){' with auto-generated key' if key_was_generated else ''}" + ) + # Hot reload - register identity immediately registration_success = False if self.daemon_instance: try: from pymc_core import LocalIdentity - + # Create LocalIdentity from the key (convert hex string to bytes) if isinstance(identity_key, bytes): identity_key_bytes = identity_key @@ -2042,51 +2129,60 @@ class APIEndpoints: identity_key_bytes = bytes.fromhex(identity_key) except ValueError as e: logger.error(f"Identity key for {name} is not valid hex string: {e}") - identity_key_bytes = identity_key.encode('latin-1') if len(identity_key) == 32 else identity_key.encode('utf-8') + identity_key_bytes = ( + identity_key.encode("latin-1") + if len(identity_key) == 32 + else identity_key.encode("utf-8") + ) else: logger.error(f"Unknown identity_key type: {type(identity_key)}") identity_key_bytes = bytes(identity_key) - + room_identity = LocalIdentity(seed=identity_key_bytes) - + # Use the consolidated registration method - if hasattr(self.daemon_instance, '_register_identity_everywhere'): + if hasattr(self.daemon_instance, "_register_identity_everywhere"): registration_success = self.daemon_instance._register_identity_everywhere( name=name, identity=room_identity, config=new_identity, - identity_type=identity_type + identity_type=identity_type, ) if registration_success: - logger.info(f"Hot reload: Registered identity '{name}' with all systems") + logger.info( + f"Hot reload: Registered identity '{name}' with all systems" + ) else: logger.warning(f"Hot reload: Failed to register identity '{name}'") - + except Exception as reg_error: - logger.error(f"Failed to hot reload identity {name}: {reg_error}", exc_info=True) - - message = f"Identity '{name}' created successfully and activated immediately!" if registration_success else f"Identity '{name}' created successfully. Restart required to activate." + logger.error( + f"Failed to hot reload identity {name}: {reg_error}", exc_info=True + ) + + message = ( + f"Identity '{name}' created successfully and activated immediately!" + if registration_success + else f"Identity '{name}' created successfully. Restart required to activate." + ) if key_was_generated: message += " Identity key was auto-generated." - - return self._success( - new_identity, - message=message - ) - + + return self._success(new_identity, message=message) + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error creating identity: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def update_identity(self): """ PUT /api/update_identity - Update an existing identity - + Body: { "name": "MyRoomServer", # Required - used to find identity "new_name": "RenamedRoom", # Optional - rename identity @@ -2102,44 +2198,47 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: if cherrypy.request.method != "PUT": cherrypy.response.status = 405 - cherrypy.response.headers['Allow'] = 'PUT' + cherrypy.response.headers["Allow"] = "PUT" raise cherrypy.HTTPError(405, "Method not allowed. This endpoint requires PUT.") - + data = cherrypy.request.json or {} - + name = data.get("name") if not name: return self._error("Missing required field: name") - + identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - + # Find the identity identity_index = next( - (i for i, r in enumerate(room_servers) if r.get("name") == name), - None + (i for i, r in enumerate(room_servers) if r.get("name") == name), None ) - + if identity_index is None: return self._error(f"Identity '{name}' not found") - + # Update fields identity = room_servers[identity_index] - + if "new_name" in data: new_name = data["new_name"] # Check if new name conflicts - if any(r.get("name") == new_name for i, r in enumerate(room_servers) if i != identity_index): + if any( + r.get("name") == new_name + for i, r in enumerate(room_servers) + if i != identity_index + ): return self._error(f"Identity with name '{new_name}' already exists") identity["name"] = new_name - + # Only update identity_key if a valid full key is provided # Silently reject truncated keys (containing "...") or invalid hex strings if "identity_key" in data and data["identity_key"]: @@ -2154,19 +2253,19 @@ class APIEndpoints: except ValueError: # Invalid hex, silently ignore pass - + if "settings" in data: # Merge settings if "settings" not in identity: identity["settings"] = {} identity["settings"].update(data["settings"]) - + # Validate passwords are different if both are now set admin_pw = identity["settings"].get("admin_password") guest_pw = identity["settings"].get("guest_password") if admin_pw and guest_pw and admin_pw == guest_pw: return self._error("admin_password and guest_password must be different") - + # Save to config room_servers[identity_index] = identity self.config["identities"]["room_servers"] = room_servers @@ -2176,19 +2275,19 @@ class APIEndpoints: return self._error(err or "Failed to save configuration to file") logger.info(f"Updated identity: {name}") - + # Hot reload - re-register identity if key changed or name changed registration_success = False # Only reload if identity_key was actually provided and not empty, or if name changed - needs_reload = (data.get("identity_key") or "new_name" in data) - + needs_reload = data.get("identity_key") or "new_name" in data + if needs_reload and self.daemon_instance: try: from pymc_core import LocalIdentity - + final_name = identity["name"] # Could be new_name identity_key = identity["identity_key"] - + # Create LocalIdentity from the key (convert hex string to bytes) if isinstance(identity_key, bytes): identity_key_bytes = identity_key @@ -2196,46 +2295,61 @@ class APIEndpoints: try: identity_key_bytes = bytes.fromhex(identity_key) except ValueError as e: - logger.error(f"Identity key for {final_name} is not valid hex string: {e}") - identity_key_bytes = identity_key.encode('latin-1') if len(identity_key) == 32 else identity_key.encode('utf-8') + logger.error( + f"Identity key for {final_name} is not valid hex string: {e}" + ) + identity_key_bytes = ( + identity_key.encode("latin-1") + if len(identity_key) == 32 + else identity_key.encode("utf-8") + ) else: logger.error(f"Unknown identity_key type: {type(identity_key)}") identity_key_bytes = bytes(identity_key) - + room_identity = LocalIdentity(seed=identity_key_bytes) - + # Use the consolidated registration method - if hasattr(self.daemon_instance, '_register_identity_everywhere'): + if hasattr(self.daemon_instance, "_register_identity_everywhere"): registration_success = self.daemon_instance._register_identity_everywhere( name=final_name, identity=room_identity, config=identity, - identity_type="room_server" + identity_type="room_server", ) if registration_success: - logger.info(f"Hot reload: Re-registered identity '{final_name}' with all systems") + logger.info( + f"Hot reload: Re-registered identity '{final_name}' with all systems" + ) else: - logger.warning(f"Hot reload: Failed to re-register identity '{final_name}'") - + logger.warning( + f"Hot reload: Failed to re-register identity '{final_name}'" + ) + except Exception as reg_error: - logger.error(f"Failed to hot reload identity {name}: {reg_error}", exc_info=True) - + logger.error( + f"Failed to hot reload identity {name}: {reg_error}", exc_info=True + ) + if needs_reload: - message = f"Identity '{name}' updated successfully and changes applied immediately!" if registration_success else f"Identity '{name}' updated successfully. Restart required to apply changes." + message = ( + f"Identity '{name}' updated successfully and changes applied immediately!" + if registration_success + else f"Identity '{name}' updated successfully. Restart required to apply changes." + ) else: - message = f"Identity '{name}' updated successfully (settings only, no reload needed)." - - return self._success( - identity, - message=message - ) - + message = ( + f"Identity '{name}' updated successfully (settings only, no reload needed)." + ) + + return self._success(identity, message=message) + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error updating identity: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def delete_identity(self, name=None): @@ -2244,29 +2358,29 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: if cherrypy.request.method != "DELETE": cherrypy.response.status = 405 - cherrypy.response.headers['Allow'] = 'DELETE' + cherrypy.response.headers["Allow"] = "DELETE" raise cherrypy.HTTPError(405, "Method not allowed. This endpoint requires DELETE.") - + if not name: return self._error("Missing name parameter") - + identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - + # Find and remove the identity initial_count = len(room_servers) room_servers = [r for r in room_servers if r.get("name") != name] - + if len(room_servers) == initial_count: return self._error(f"Identity '{name}' not found") - + # Update config self.config["identities"]["room_servers"] = room_servers @@ -2275,138 +2389,150 @@ class APIEndpoints: return self._error(err or "Failed to save configuration to file") logger.info(f"Deleted identity: {name}") - + unregister_success = False if self.daemon_instance: try: - if hasattr(self.daemon_instance, 'identity_manager'): + if hasattr(self.daemon_instance, "identity_manager"): identity_manager = self.daemon_instance.identity_manager - + # Remove from named_identities dict if name in identity_manager.named_identities: del identity_manager.named_identities[name] logger.info(f"Removed identity {name} from named_identities") unregister_success = True - + # Note: We don't remove from identities dict (keyed by hash) # because we'd need to look up the hash first, and there could # be multiple identities with the same hash # Full cleanup happens on restart - + except Exception as unreg_error: - logger.error(f"Failed to unregister identity {name}: {unreg_error}", exc_info=True) - - message = f"Identity '{name}' deleted successfully and deactivated immediately!" if unregister_success else f"Identity '{name}' deleted successfully. Restart required to fully remove." - - return self._success( - {"name": name}, - message=message + logger.error( + f"Failed to unregister identity {name}: {unreg_error}", exc_info=True + ) + + message = ( + f"Identity '{name}' deleted successfully and deactivated immediately!" + if unregister_success + else f"Identity '{name}' deleted successfully. Restart required to fully remove." ) - + + return self._success({"name": name}, message=message) + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error deleting identity: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def send_room_server_advert(self): """ POST /api/send_room_server_advert - Send advert for a room server - + Body: { "name": "MyRoomServer" } """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() - + if not self.daemon_instance: return self._error("Daemon not available") - + data = cherrypy.request.json or {} name = data.get("name") - + if not name: return self._error("Missing required field: name") - + # Get the identity from identity manager - if not hasattr(self.daemon_instance, 'identity_manager'): + if not hasattr(self.daemon_instance, "identity_manager"): return self._error("Identity manager not available") - + identity_manager = self.daemon_instance.identity_manager identity_info = identity_manager.get_identity_by_name(name) - + if not identity_info: return self._error(f"Room server '{name}' not found or not registered") - + identity, config, identity_type = identity_info - + if identity_type != "room_server": return self._error(f"Identity '{name}' is not a room server") - + # Get settings from config settings = config.get("settings", {}) node_name = settings.get("node_name", name) latitude = settings.get("latitude", 0.0) longitude = settings.get("longitude", 0.0) disable_fwd = settings.get("disable_fwd", False) - + # Send the advert asynchronously if self.event_loop is None: return self._error("Event loop not available") - + import asyncio + future = asyncio.run_coroutine_threadsafe( self._send_room_server_advert_async( identity=identity, node_name=node_name, latitude=latitude, longitude=longitude, - disable_fwd=disable_fwd + disable_fwd=disable_fwd, ), - self.event_loop + self.event_loop, ) - + result = future.result(timeout=10) - + if result: - return self._success({ - "name": name, - "node_name": node_name, - "latitude": latitude, - "longitude": longitude - }, message=f"Advert sent for room server '{node_name}'") + return self._success( + { + "name": name, + "node_name": node_name, + "latitude": latitude, + "longitude": longitude, + }, + message=f"Advert sent for room server '{node_name}'", + ) else: return self._error(f"Failed to send advert for room server '{name}'") - + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error sending room server advert: {e}", exc_info=True) return self._error(e) - - async def _send_room_server_advert_async(self, identity, node_name, latitude, longitude, disable_fwd): + + async def _send_room_server_advert_async( + self, identity, node_name, latitude, longitude, disable_fwd + ): """Send advert for a room server identity""" try: from pymc_core.protocol import PacketBuilder - from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_ROOM_SERVER - + from pymc_core.protocol.constants import ( + ADVERT_FLAG_HAS_NAME, + ADVERT_FLAG_IS_ROOM_SERVER, + ) + if not self.daemon_instance or not self.daemon_instance.dispatcher: logger.error("Cannot send advert: dispatcher not initialized") return False - + # Build flags - just use HAS_NAME for room servers flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME - + packet = PacketBuilder.create_advert( local_identity=identity, name=node_name, @@ -2417,30 +2543,32 @@ class APIEndpoints: flags=flags, route_type="flood", ) - + # Send via dispatcher await self.daemon_instance.dispatcher.send_packet(packet, wait_for_ack=False) - + # Mark as seen to prevent re-forwarding if self.daemon_instance.repeater_handler: self.daemon_instance.repeater_handler.mark_seen(packet) logger.debug(f"Marked room server advert '{node_name}' as seen in duplicate cache") - - logger.info(f"Sent flood advert for room server '{node_name}' at ({latitude:.6f}, {longitude:.6f})") + + logger.info( + f"Sent flood advert for room server '{node_name}' at ({latitude:.6f}, {longitude:.6f})" + ) return True - + except Exception as e: logger.error(f"Failed to send room server advert: {e}", exc_info=True) return False # ========== ACL (Access Control List) Endpoints ========== - + @cherrypy.expose @cherrypy.tools.json_out() def acl_info(self): """ GET /api/acl_info - Get ACL configuration and statistics - + Returns ACL settings for all registered identities including: - Identity name, type, and hash - Max clients allowed @@ -2450,75 +2578,83 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - if not self.daemon_instance or not hasattr(self.daemon_instance, 'login_helper'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "login_helper"): return self._error("Login helper not available") - + login_helper = self.daemon_instance.login_helper identity_manager = self.daemon_instance.identity_manager - + acl_dict = login_helper.get_acl_dict() - + acl_info_list = [] - + # Add repeater identity if self.daemon_instance.local_identity: repeater_hash = self.daemon_instance.local_identity.get_public_key()[0] repeater_acl = acl_dict.get(repeater_hash) - + if repeater_acl: - acl_info_list.append({ - "name": "repeater", - "type": "repeater", - "hash": f"0x{repeater_hash:02X}", - "max_clients": repeater_acl.max_clients, - "authenticated_clients": repeater_acl.get_num_clients(), - "has_admin_password": bool(repeater_acl.admin_password), - "has_guest_password": bool(repeater_acl.guest_password), - "allow_read_only": repeater_acl.allow_read_only - }) - + acl_info_list.append( + { + "name": "repeater", + "type": "repeater", + "hash": f"0x{repeater_hash:02X}", + "max_clients": repeater_acl.max_clients, + "authenticated_clients": repeater_acl.get_num_clients(), + "has_admin_password": bool(repeater_acl.admin_password), + "has_guest_password": bool(repeater_acl.guest_password), + "allow_read_only": repeater_acl.allow_read_only, + } + ) + # Add room server identities for name, identity, config in identity_manager.get_identities_by_type("room_server"): hash_byte = identity.get_public_key()[0] acl = acl_dict.get(hash_byte) - + if acl: - acl_info_list.append({ - "name": name, - "type": "room_server", - "hash": f"0x{hash_byte:02X}", - "max_clients": acl.max_clients, - "authenticated_clients": acl.get_num_clients(), - "has_admin_password": bool(acl.admin_password), - "has_guest_password": bool(acl.guest_password), - "allow_read_only": acl.allow_read_only - }) - - return self._success({ - "acls": acl_info_list, - "total_identities": len(acl_info_list), - "total_authenticated_clients": sum(a["authenticated_clients"] for a in acl_info_list) - }) - + acl_info_list.append( + { + "name": name, + "type": "room_server", + "hash": f"0x{hash_byte:02X}", + "max_clients": acl.max_clients, + "authenticated_clients": acl.get_num_clients(), + "has_admin_password": bool(acl.admin_password), + "has_guest_password": bool(acl.guest_password), + "allow_read_only": acl.allow_read_only, + } + ) + + return self._success( + { + "acls": acl_info_list, + "total_identities": len(acl_info_list), + "total_authenticated_clients": sum( + a["authenticated_clients"] for a in acl_info_list + ), + } + ) + except Exception as e: logger.error(f"Error getting ACL info: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def acl_clients(self, identity_hash=None, identity_name=None): """ GET /api/acl_clients - Get authenticated clients - + Query parameters: - identity_hash: Filter by identity hash (e.g., "0x42") - identity_name: Filter by identity name (e.g., "repeater" or room server name) - + Returns list of authenticated clients with: - Public key (truncated) - Full address @@ -2529,45 +2665,49 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - if not self.daemon_instance or not hasattr(self.daemon_instance, 'login_helper'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "login_helper"): return self._error("Login helper not available") - + login_helper = self.daemon_instance.login_helper identity_manager = self.daemon_instance.identity_manager acl_dict = login_helper.get_acl_dict() - + # Build a mapping of hash to identity info identity_map = {} - + # Add repeater if self.daemon_instance.local_identity: repeater_hash = self.daemon_instance.local_identity.get_public_key()[0] identity_map[repeater_hash] = { "name": "repeater", "type": "repeater", - "hash": f"0x{repeater_hash:02X}" + "hash": f"0x{repeater_hash:02X}", } - + # Add room servers for name, identity, config in identity_manager.get_identities_by_type("room_server"): hash_byte = identity.get_public_key()[0] identity_map[hash_byte] = { "name": name, "type": "room_server", - "hash": f"0x{hash_byte:02X}" + "hash": f"0x{hash_byte:02X}", } - + # Filter by identity if requested target_hash = None if identity_hash: # Convert "0x42" to int try: - target_hash = int(identity_hash, 16) if identity_hash.startswith("0x") else int(identity_hash) + target_hash = ( + int(identity_hash, 16) + if identity_hash.startswith("0x") + else int(identity_hash) + ) except ValueError: return self._error(f"Invalid identity_hash format: {identity_hash}") elif identity_name: @@ -2578,71 +2718,76 @@ class APIEndpoints: break if target_hash is None: return self._error(f"Identity '{identity_name}' not found") - + # Collect clients clients_list = [] - + logger.info(f"ACL dict has {len(acl_dict)} identities") - + for hash_byte, acl in acl_dict.items(): # Skip if filtering by specific identity if target_hash is not None and hash_byte != target_hash: continue - - identity_info = identity_map.get(hash_byte, { - "name": "unknown", - "type": "unknown", - "hash": f"0x{hash_byte:02X}" - }) - + + identity_info = identity_map.get( + hash_byte, {"name": "unknown", "type": "unknown", "hash": f"0x{hash_byte:02X}"} + ) + all_clients = acl.get_all_clients() - logger.info(f"Identity {identity_info['name']} (0x{hash_byte:02X}) has {len(all_clients)} clients") - + logger.info( + f"Identity {identity_info['name']} (0x{hash_byte:02X}) has {len(all_clients)} clients" + ) + for client in all_clients: try: pub_key = client.id.get_public_key() - + # Compute address from public key (first byte of SHA256) address_bytes = CryptoUtils.sha256(pub_key)[:1] - - clients_list.append({ - "public_key": pub_key[:8].hex() + "..." + pub_key[-4:].hex(), - "public_key_full": pub_key.hex(), - "address": address_bytes.hex(), - "permissions": "admin" if client.is_admin() else "guest", - "last_activity": client.last_activity, - "last_login_success": client.last_login_success, - "last_timestamp": client.last_timestamp, - "identity_name": identity_info["name"], - "identity_type": identity_info["type"], - "identity_hash": identity_info["hash"] - }) + + clients_list.append( + { + "public_key": pub_key[:8].hex() + "..." + pub_key[-4:].hex(), + "public_key_full": pub_key.hex(), + "address": address_bytes.hex(), + "permissions": "admin" if client.is_admin() else "guest", + "last_activity": client.last_activity, + "last_login_success": client.last_login_success, + "last_timestamp": client.last_timestamp, + "identity_name": identity_info["name"], + "identity_type": identity_info["type"], + "identity_hash": identity_info["hash"], + } + ) except Exception as client_error: logger.error(f"Error processing client: {client_error}", exc_info=True) continue - + logger.info(f"Returning {len(clients_list)} total clients") - - return self._success({ - "clients": clients_list, - "count": len(clients_list), - "filter": { - "identity_hash": identity_hash, - "identity_name": identity_name - } if (identity_hash or identity_name) else None - }) - + + return self._success( + { + "clients": clients_list, + "count": len(clients_list), + "filter": ( + {"identity_hash": identity_hash, "identity_name": identity_name} + if (identity_hash or identity_name) + else None + ), + } + ) + except Exception as e: logger.error(f"Error getting ACL clients: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def acl_remove_client(self): """ POST /api/acl_remove_client - Remove an authenticated client from ACL - + Body: { "public_key": "full_hex_string", "identity_hash": "0x42" # Optional - if not provided, removes from all ACLs @@ -2650,74 +2795,78 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() - - if not self.daemon_instance or not hasattr(self.daemon_instance, 'login_helper'): + + if not self.daemon_instance or not hasattr(self.daemon_instance, "login_helper"): return self._error("Login helper not available") - + data = cherrypy.request.json or {} public_key_hex = data.get("public_key") identity_hash_str = data.get("identity_hash") - + if not public_key_hex: return self._error("Missing required field: public_key") - + # Convert hex to bytes try: public_key = bytes.fromhex(public_key_hex) except ValueError: return self._error("Invalid public_key format (must be hex string)") - + login_helper = self.daemon_instance.login_helper acl_dict = login_helper.get_acl_dict() - + # Determine which ACLs to remove from target_hashes = [] if identity_hash_str: try: - target_hash = int(identity_hash_str, 16) if identity_hash_str.startswith("0x") else int(identity_hash_str) + target_hash = ( + int(identity_hash_str, 16) + if identity_hash_str.startswith("0x") + else int(identity_hash_str) + ) target_hashes = [target_hash] except ValueError: return self._error(f"Invalid identity_hash format: {identity_hash_str}") else: # Remove from all ACLs target_hashes = list(acl_dict.keys()) - + removed_count = 0 removed_from = [] - + for hash_byte in target_hashes: acl = acl_dict.get(hash_byte) if acl and acl.remove_client(public_key): removed_count += 1 removed_from.append(f"0x{hash_byte:02X}") - + if removed_count > 0: logger.info(f"Removed client {public_key[:6].hex()}... from {removed_count} ACL(s)") - return self._success({ - "removed_count": removed_count, - "removed_from": removed_from - }, message=f"Client removed from {removed_count} ACL(s)") + return self._success( + {"removed_count": removed_count, "removed_from": removed_from}, + message=f"Client removed from {removed_count} ACL(s)", + ) else: return self._error("Client not found in any ACL") - + except cherrypy.HTTPError: raise except Exception as e: logger.error(f"Error removing client from ACL: {e}") return self._error(e) - + @cherrypy.expose @cherrypy.tools.json_out() def acl_stats(self): """ GET /api/acl_stats - Get overall ACL statistics - + Returns: - Total identities with ACLs - Total authenticated clients across all identities @@ -2726,27 +2875,27 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - if not self.daemon_instance or not hasattr(self.daemon_instance, 'login_helper'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "login_helper"): return self._error("Login helper not available") - + login_helper = self.daemon_instance.login_helper identity_manager = self.daemon_instance.identity_manager acl_dict = login_helper.get_acl_dict() - + total_clients = 0 admin_count = 0 guest_count = 0 - + identity_stats = { "repeater": {"count": 0, "clients": 0}, - "room_server": {"count": 0, "clients": 0} + "room_server": {"count": 0, "clients": 0}, } - + # Count repeater if self.daemon_instance.local_identity: repeater_hash = self.daemon_instance.local_identity.get_public_key()[0] @@ -2756,17 +2905,17 @@ class APIEndpoints: clients = repeater_acl.get_all_clients() identity_stats["repeater"]["clients"] = len(clients) total_clients += len(clients) - + for client in clients: if client.is_admin(): admin_count += 1 else: guest_count += 1 - + # Count room servers room_servers = identity_manager.get_identities_by_type("room_server") identity_stats["room_server"]["count"] = len(room_servers) - + for name, identity, config in room_servers: hash_byte = identity.get_public_key()[0] acl = acl_dict.get(hash_byte) @@ -2774,21 +2923,23 @@ class APIEndpoints: clients = acl.get_all_clients() identity_stats["room_server"]["clients"] += len(clients) total_clients += len(clients) - + for client in clients: if client.is_admin(): admin_count += 1 else: guest_count += 1 - - return self._success({ - "total_identities": len(acl_dict), - "total_clients": total_clients, - "admin_clients": admin_count, - "guest_clients": guest_count, - "by_identity_type": identity_stats - }) - + + return self._success( + { + "total_identities": len(acl_dict), + "total_clients": total_clients, + "admin_clients": admin_count, + "guest_clients": guest_count, + "by_identity_type": identity_stats, + } + ) + except Exception as e: logger.error(f"Error getting ACL stats: {e}") return self._error(e) @@ -2799,15 +2950,15 @@ class APIEndpoints: def _get_room_server_by_name_or_hash(self, room_name=None, room_hash=None): """Helper to get room server instance and metadata by name or hash.""" - if not self.daemon_instance or not hasattr(self.daemon_instance, 'text_helper'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "text_helper"): raise Exception("Text helper not available") - + text_helper = self.daemon_instance.text_helper - if not text_helper or not hasattr(text_helper, 'room_servers'): + if not text_helper or not hasattr(text_helper, "room_servers"): raise Exception("Room servers not initialized") - + identity_manager = text_helper.identity_manager - + # Find by name first if room_name: identities = identity_manager.get_identities_by_type("room_server") @@ -2817,24 +2968,24 @@ class APIEndpoints: room_server = text_helper.room_servers.get(hash_byte) if room_server: return { - 'room_server': room_server, - 'name': name, - 'hash': hash_byte, - 'identity': identity, - 'config': config + "room_server": room_server, + "name": name, + "hash": hash_byte, + "identity": identity, + "config": config, } raise Exception(f"Room '{room_name}' not found") - + # Find by hash if room_hash: if isinstance(room_hash, str): - if room_hash.startswith('0x'): + if room_hash.startswith("0x"): hash_byte = int(room_hash, 16) else: hash_byte = int(room_hash) else: hash_byte = room_hash - + room_server = text_helper.room_servers.get(hash_byte) if room_server: # Find name @@ -2842,37 +2993,39 @@ class APIEndpoints: for name, identity, config in identities: if identity.get_public_key()[0] == hash_byte: return { - 'room_server': room_server, - 'name': name, - 'hash': hash_byte, - 'identity': identity, - 'config': config + "room_server": room_server, + "name": name, + "hash": hash_byte, + "identity": identity, + "config": config, } # Found server but no name match return { - 'room_server': room_server, - 'name': f"Room_0x{hash_byte:02X}", - 'hash': hash_byte, - 'identity': None, - 'config': {} + "room_server": room_server, + "name": f"Room_0x{hash_byte:02X}", + "hash": hash_byte, + "identity": None, + "config": {}, } raise Exception(f"Room with hash {room_hash} not found") - + raise Exception("Must provide room_name or room_hash") @cherrypy.expose @cherrypy.tools.json_out() - def room_messages(self, room_name=None, room_hash=None, limit=50, offset=0, since_timestamp=None): + def room_messages( + self, room_name=None, room_hash=None, limit=50, offset=0, since_timestamp=None + ): """ Get messages from a room server. - + Parameters: room_name: Name of the room room_hash: Hash of room identity (alternative to name) limit: Max messages to return (default 50) offset: Skip first N messages (default 0) since_timestamp: Only return messages after this timestamp - + Returns: { "success": true, @@ -2900,69 +3053,69 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] - + room_server = room_info["room_server"] + # Get messages from database db = room_server.db room_hash_str = f"0x{room_info['hash']:02X}" - + # Get total count total_count = db.get_room_message_count(room_hash_str) - + # Get messages if since_timestamp: messages = db.get_messages_since( room_hash=room_hash_str, since_timestamp=float(since_timestamp), - limit=int(limit) + limit=int(limit), ) else: messages = db.get_room_messages( - room_hash=room_hash_str, - limit=int(limit), - offset=int(offset) + room_hash=room_hash_str, limit=int(limit), offset=int(offset) ) - + # Format messages with author prefix and lookup sender names storage = self._get_storage() formatted_messages = [] for msg in messages: - author_pubkey = msg['author_pubkey'] + author_pubkey = msg["author_pubkey"] formatted_msg = { - 'id': msg['id'], - 'author_pubkey': author_pubkey, - 'author_prefix': author_pubkey[:8] if author_pubkey else '', - 'post_timestamp': msg['post_timestamp'], - 'sender_timestamp': msg['sender_timestamp'], - 'message_text': msg['message_text'], - 'txt_type': msg['txt_type'], - 'created_at': msg.get('created_at', msg['post_timestamp']) + "id": msg["id"], + "author_pubkey": author_pubkey, + "author_prefix": author_pubkey[:8] if author_pubkey else "", + "post_timestamp": msg["post_timestamp"], + "sender_timestamp": msg["sender_timestamp"], + "message_text": msg["message_text"], + "txt_type": msg["txt_type"], + "created_at": msg.get("created_at", msg["post_timestamp"]), } - + # Lookup sender name from adverts table if author_pubkey: author_name = storage.get_node_name_by_pubkey(author_pubkey) if author_name: - formatted_msg['author_name'] = author_name - + formatted_msg["author_name"] = author_name + formatted_messages.append(formatted_msg) - - return self._success({ - 'room_name': room_info['name'], - 'room_hash': room_hash_str, - 'messages': formatted_messages, - 'count': len(formatted_messages), - 'total': total_count, - 'limit': int(limit), - 'offset': int(offset) - }) - + + return self._success( + { + "room_name": room_info["name"], + "room_hash": room_hash_str, + "messages": formatted_messages, + "count": len(formatted_messages), + "total": total_count, + "limit": int(limit), + "offset": int(offset), + } + ) + except Exception as e: logger.error(f"Error getting room messages: {e}", exc_info=True) return self._error(e) @@ -2973,7 +3126,7 @@ class APIEndpoints: def room_post_message(self): """ Post a message to a room server. - + POST Body: { "room_name": "General", // or "room_hash": "0x42" @@ -2981,43 +3134,43 @@ class APIEndpoints: "author_pubkey": "abc123...", // hex string, or "server" for system messages "txt_type": 0 // optional, default 0 } - + Special Values for author_pubkey: - "server" or "system": Uses SERVER_AUTHOR_PUBKEY (all zeros), message goes to ALL clients - Any other hex string: Normal behavior, message NOT sent to that client - + Returns: {"success": true, "data": {"message_id": 123}} """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: self._require_post() - + data = cherrypy.request.json - room_name = data.get('room_name') - room_hash = data.get('room_hash') - message = data.get('message') - author_pubkey = data.get('author_pubkey') - txt_type = data.get('txt_type', 0) - + room_name = data.get("room_name") + room_hash = data.get("room_hash") + message = data.get("message") + author_pubkey = data.get("author_pubkey") + txt_type = data.get("txt_type", 0) + if not message: return self._error("message is required") if not author_pubkey: return self._error("author_pubkey is required") - + # Convert author_pubkey to bytes try: # Special case: "server" or "system" = use room server's public key # This allows clients to identify which room server sent the message - if isinstance(author_pubkey, str) and author_pubkey.lower() in ('server', 'system'): + if isinstance(author_pubkey, str) and author_pubkey.lower() in ("server", "system"): # Get room server first to access its identity room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] + room_server = room_info["room_server"] # Use the room server's actual public key author_bytes = room_server.local_identity.get_public_key() author_pubkey = author_bytes.hex() @@ -3030,14 +3183,18 @@ class APIEndpoints: is_server_message = False except Exception as e: return self._error(f"Invalid author_pubkey: {e}") - + # Get room server (if not already retrieved above) - if not isinstance(author_pubkey, str) or author_pubkey.lower() not in ('server', 'system'): + if not isinstance(author_pubkey, str) or author_pubkey.lower() not in ( + "server", + "system", + ): room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] - + room_server = room_info["room_server"] + # Add post to room (will be distributed asynchronously) import asyncio + if self.event_loop: sender_timestamp = int(time.time()) # SECURITY: Server messages (using room server's key) go to ALL clients @@ -3048,32 +3205,38 @@ class APIEndpoints: message_text=message, sender_timestamp=sender_timestamp, txt_type=txt_type, - allow_server_author=is_server_message # Allow server key from API + allow_server_author=is_server_message, # Allow server key from API ), - self.event_loop + self.event_loop, ) success = future.result(timeout=5) - + if success: # Get the message ID (last inserted) db = room_server.db room_hash_str = f"0x{room_info['hash']:02X}" messages = db.get_room_messages(room_hash_str, limit=1, offset=0) - message_id = messages[0]['id'] if messages else None - - return self._success({ - 'message_id': message_id, - 'room_name': room_info['name'], - 'room_hash': room_hash_str, - 'queued_for_distribution': True, - 'is_server_message': is_server_message, - 'author_filter_note': 'Server messages go to ALL clients' if is_server_message else 'Message will NOT be sent to author' - }) + message_id = messages[0]["id"] if messages else None + + return self._success( + { + "message_id": message_id, + "room_name": room_info["name"], + "room_hash": room_hash_str, + "queued_for_distribution": True, + "is_server_message": is_server_message, + "author_filter_note": ( + "Server messages go to ALL clients" + if is_server_message + else "Message will NOT be sent to author" + ), + } + ) else: return self._error("Failed to add message (rate limit or validation error)") else: return self._error("Event loop not available") - + except cherrypy.HTTPError: raise except Exception as e: @@ -3085,13 +3248,13 @@ class APIEndpoints: def room_stats(self, room_name=None, room_hash=None): """ Get statistics for one or all room servers. - + Parameters: room_name: Name of specific room (optional) room_hash: Hash of specific room (optional) - + If no parameters, returns stats for all rooms. - + Returns: { "success": true, @@ -3119,16 +3282,16 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: - if not self.daemon_instance or not hasattr(self.daemon_instance, 'text_helper'): + if not self.daemon_instance or not hasattr(self.daemon_instance, "text_helper"): return self._error("Text helper not available") - + text_helper = self.daemon_instance.text_helper - + # Get all rooms if no specific room requested if not room_name and not room_hash: all_rooms = [] @@ -3140,96 +3303,101 @@ class APIEndpoints: if identity.get_public_key()[0] == hash_byte: room_name_found = name break - + db = room_server.db room_hash_str = f"0x{hash_byte:02X}" - + # Get basic stats total_messages = db.get_room_message_count(room_hash_str) all_clients_sync = db.get_all_room_clients(room_hash_str) - active_clients = sum(1 for c in all_clients_sync if c.get('last_activity', 0) > 0) - - all_rooms.append({ - 'room_name': room_name_found, - 'room_hash': room_hash_str, - 'total_messages': total_messages, - 'total_clients': len(all_clients_sync), - 'active_clients': active_clients, - 'max_posts': room_server.max_posts, - 'sync_running': room_server._running - }) - - return self._success({ - 'rooms': all_rooms, - 'total_rooms': len(all_rooms) - }) - + active_clients = sum( + 1 for c in all_clients_sync if c.get("last_activity", 0) > 0 + ) + + all_rooms.append( + { + "room_name": room_name_found, + "room_hash": room_hash_str, + "total_messages": total_messages, + "total_clients": len(all_clients_sync), + "active_clients": active_clients, + "max_posts": room_server.max_posts, + "sync_running": room_server._running, + } + ) + + return self._success({"rooms": all_rooms, "total_rooms": len(all_rooms)}) + # Get specific room stats room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] + room_server = room_info["room_server"] db = room_server.db room_hash_str = f"0x{room_info['hash']:02X}" - + # Get message count total_messages = db.get_room_message_count(room_hash_str) - + # Get client sync states all_clients_sync = db.get_all_room_clients(room_hash_str) - + # Get ACL for this room acl = None - if room_info['hash'] in text_helper.acl_dict: - acl = text_helper.acl_dict[room_info['hash']] - + if room_info["hash"] in text_helper.acl_dict: + acl = text_helper.acl_dict[room_info["hash"]] + # Format client info clients_info = [] active_count = 0 for client_sync in all_clients_sync: - pubkey_hex = client_sync['client_pubkey'] + pubkey_hex = client_sync["client_pubkey"] pubkey_bytes = bytes.fromhex(pubkey_hex) - + # Check if still in ACL in_acl = False if acl: acl_clients = acl.get_all_clients() in_acl = any(c.id.get_public_key() == pubkey_bytes for c in acl_clients) - + unsynced_count = db.get_unsynced_count( room_hash=room_hash_str, client_pubkey=pubkey_hex, - sync_since=client_sync.get('sync_since', 0) + sync_since=client_sync.get("sync_since", 0), ) - - is_active = client_sync.get('last_activity', 0) > 0 + + is_active = client_sync.get("last_activity", 0) > 0 if is_active: active_count += 1 - - clients_info.append({ - 'pubkey': pubkey_hex, - 'pubkey_prefix': pubkey_hex[:8], - 'sync_since': client_sync.get('sync_since', 0), - 'unsynced_count': unsynced_count, - 'pending_ack': client_sync.get('pending_ack_crc', 0) != 0, - 'pending_ack_crc': client_sync.get('pending_ack_crc', 0), - 'push_failures': client_sync.get('push_failures', 0), - 'last_activity': client_sync.get('last_activity', 0), - 'in_acl': in_acl, - 'is_active': is_active - }) - - return self._success({ - 'room_name': room_info['name'], - 'room_hash': room_hash_str, - 'total_messages': total_messages, - 'total_clients': len(all_clients_sync), - 'active_clients': active_count, - 'max_posts': room_server.max_posts, - 'sync_running': room_server._running, - 'next_push_time': room_server.next_push_time, - 'last_cleanup_time': room_server.last_cleanup_time, - 'clients': clients_info - }) - + + clients_info.append( + { + "pubkey": pubkey_hex, + "pubkey_prefix": pubkey_hex[:8], + "sync_since": client_sync.get("sync_since", 0), + "unsynced_count": unsynced_count, + "pending_ack": client_sync.get("pending_ack_crc", 0) != 0, + "pending_ack_crc": client_sync.get("pending_ack_crc", 0), + "push_failures": client_sync.get("push_failures", 0), + "last_activity": client_sync.get("last_activity", 0), + "in_acl": in_acl, + "is_active": is_active, + } + ) + + return self._success( + { + "room_name": room_info["name"], + "room_hash": room_hash_str, + "total_messages": total_messages, + "total_clients": len(all_clients_sync), + "active_clients": active_count, + "max_posts": room_server.max_posts, + "sync_running": room_server._running, + "next_push_time": room_server.next_push_time, + "last_cleanup_time": room_server.last_cleanup_time, + "clients": clients_info, + } + ) + except Exception as e: logger.error(f"Error getting room stats: {e}", exc_info=True) return self._error(e) @@ -3239,11 +3407,11 @@ class APIEndpoints: def room_clients(self, room_name=None, room_hash=None): """ Get list of clients synced to a room. - + Parameters: room_name: Name of the room room_hash: Hash of room identity - + Returns: { "success": true, @@ -3256,22 +3424,24 @@ class APIEndpoints: """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: # Reuse room_stats logic but return only clients stats = self.room_stats(room_name=room_name, room_hash=room_hash) - if stats.get('success') and 'clients' in stats.get('data', {}): - data = stats['data'] - return self._success({ - 'room_name': data['room_name'], - 'room_hash': data['room_hash'], - 'clients': data['clients'], - 'total': len(data['clients']), - 'active': data['active_clients'] - }) + if stats.get("success") and "clients" in stats.get("data", {}): + data = stats["data"] + return self._success( + { + "room_name": data["room_name"], + "room_hash": data["room_hash"], + "clients": data["clients"], + "total": len(data["clients"]), + "active": data["active_clients"], + } + ) else: return stats except Exception as e: @@ -3283,46 +3453,44 @@ class APIEndpoints: def room_message(self, room_name=None, room_hash=None, message_id=None): """ Delete a specific message from a room. - + Parameters: room_name: Name of the room room_hash: Hash of room identity message_id: ID of message to delete - + Returns: {"success": true} """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: if cherrypy.request.method != "DELETE": cherrypy.response.status = 405 return self._error("Method not allowed. Use DELETE.") - + if not message_id: return self._error("message_id is required") - + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] + room_server = room_info["room_server"] db = room_server.db room_hash_str = f"0x{room_info['hash']:02X}" - + # Delete message deleted = db.delete_room_message(room_hash_str, int(message_id)) - + if deleted: - return self._success({ - 'deleted': True, - 'message_id': int(message_id), - 'room_name': room_info['name'] - }) + return self._success( + {"deleted": True, "message_id": int(message_id), "room_name": room_info["name"]} + ) else: return self._error("Message not found or already deleted") - + except Exception as e: logger.error(f"Error deleting room message: {e}") return self._error(e) @@ -3332,42 +3500,44 @@ class APIEndpoints: def room_messages_clear(self, room_name=None, room_hash=None): """ Clear all messages from a room. - + Parameters: room_name: Name of the room room_hash: Hash of room identity - + Returns: {"success": true, "data": {"deleted_count": 123}} """ # Enable CORS for this endpoint only if configured self._set_cors_headers() - + if cherrypy.request.method == "OPTIONS": return "" - + try: if cherrypy.request.method != "DELETE": cherrypy.response.status = 405 return self._error("Method not allowed. Use DELETE.") - + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) - room_server = room_info['room_server'] + room_server = room_info["room_server"] db = room_server.db room_hash_str = f"0x{room_info['hash']:02X}" - + # Get count before deleting count_before = db.get_room_message_count(room_hash_str) - + # Clear all messages deleted = db.clear_room_messages(room_hash_str) - - return self._success({ - 'deleted_count': deleted or count_before, - 'room_name': room_info['name'], - 'room_hash': room_hash_str - }) - + + return self._success( + { + "deleted_count": deleted or count_before, + "room_name": room_info["name"], + "room_hash": room_hash_str, + } + ) + except Exception as e: logger.error(f"Error clearing room messages: {e}") return self._error(e) @@ -3380,18 +3550,19 @@ class APIEndpoints: def openapi(self): """Serve OpenAPI specification in YAML format.""" import os - spec_path = os.path.join(os.path.dirname(__file__), 'openapi.yaml') + + spec_path = os.path.join(os.path.dirname(__file__), "openapi.yaml") try: - with open(spec_path, 'r') as f: + with open(spec_path, "r") as f: spec_content = f.read() - cherrypy.response.headers['Content-Type'] = 'application/x-yaml' - return spec_content.encode('utf-8') + cherrypy.response.headers["Content-Type"] = "application/x-yaml" + return spec_content.encode("utf-8") except FileNotFoundError: cherrypy.response.status = 404 return b"OpenAPI spec not found" except Exception as e: cherrypy.response.status = 500 - return f"Error loading OpenAPI spec: {e}".encode('utf-8') + return f"Error loading OpenAPI spec: {e}".encode("utf-8") @cherrypy.expose def docs(self): @@ -3433,5 +3604,5 @@ class APIEndpoints: """ - cherrypy.response.headers['Content-Type'] = 'text/html' - return html.encode('utf-8') \ No newline at end of file + cherrypy.response.headers["Content-Type"] = "text/html" + return html.encode("utf-8") diff --git a/repeater/web/auth/__init__.py b/repeater/web/auth/__init__.py index a38c19f..2e0695e 100644 --- a/repeater/web/auth/__init__.py +++ b/repeater/web/auth/__init__.py @@ -1,9 +1,5 @@ -from .jwt_handler import JWTHandler from .api_tokens import APITokenManager +from .jwt_handler import JWTHandler from .middleware import require_auth -__all__ = [ - 'JWTHandler', - 'APITokenManager', - 'require_auth' -] +__all__ = ["JWTHandler", "APITokenManager", "require_auth"] diff --git a/repeater/web/auth/api_tokens.py b/repeater/web/auth/api_tokens.py index 55dcff7..5105e70 100644 --- a/repeater/web/auth/api_tokens.py +++ b/repeater/web/auth/api_tokens.py @@ -1,8 +1,8 @@ -import secrets -import hmac import hashlib -from typing import Optional, List, Dict +import hmac import logging +import secrets +from typing import Dict, List, Optional logger = logging.getLogger(__name__) @@ -11,18 +11,14 @@ class APITokenManager: def __init__(self, sqlite_handler, secret_key: str): self.db = sqlite_handler - self.secret_key = secret_key.encode('utf-8') - + self.secret_key = secret_key.encode("utf-8") + def generate_api_token(self) -> str: return secrets.token_hex(32) - + def hash_token(self, token: str) -> str: - return hmac.new( - self.secret_key, - token.encode('utf-8'), - hashlib.sha256 - ).hexdigest() - + return hmac.new(self.secret_key, token.encode("utf-8"), hashlib.sha256).hexdigest() + def create_token(self, name: str) -> tuple[int, str]: plaintext_token = self.generate_api_token() token_hash = self.hash_token(plaintext_token) @@ -43,7 +39,6 @@ class APITokenManager: logger.info(f"Revoked API token ID {token_id}") return deleted - + def list_tokens(self) -> List[Dict]: return self.db.list_api_tokens() - diff --git a/repeater/web/auth/cherrypy_tool.py b/repeater/web/auth/cherrypy_tool.py index c6894df..f107dc5 100644 --- a/repeater/web/auth/cherrypy_tool.py +++ b/repeater/web/auth/cherrypy_tool.py @@ -1,4 +1,5 @@ import logging + import cherrypy logger = logging.getLogger("HTTPServer") @@ -40,10 +41,10 @@ def check_auth(): cherrypy.request.user = { "username": payload.get("sub"), "client_id": payload.get("client_id"), - "auth_type": "jwt" + "auth_type": "jwt", } return - + # Check for JWT token in query parameter (for EventSource/SSE) # EventSource doesn't support custom headers, so we use query param query_token = cherrypy.request.params.get("token") @@ -54,7 +55,7 @@ def check_auth(): cherrypy.request.user = { "username": payload.get("sub"), "client_id": payload.get("client_id"), - "auth_type": "jwt_query" + "auth_type": "jwt_query", } # Remove token from params to avoid exposing it in logs del cherrypy.request.params["token"] @@ -69,15 +70,15 @@ def check_auth(): cherrypy.request.user = { "token_id": token_info["id"], "token_name": token_info["name"], - "auth_type": "api_token" + "auth_type": "api_token", } return - + # No valid authentication found logger.warning(f"Unauthorized access attempt to {cherrypy.request.path_info}") raise cherrypy.HTTPError(401, "Unauthorized - Valid JWT or API token required") # Register the tool -cherrypy.tools.require_auth = cherrypy.Tool('before_handler', check_auth) +cherrypy.tools.require_auth = cherrypy.Tool("before_handler", check_auth) logger.info("CherryPy require_auth tool registered") diff --git a/repeater/web/auth/jwt_handler.py b/repeater/web/auth/jwt_handler.py index a1dd22c..bc9d257 100644 --- a/repeater/web/auth/jwt_handler.py +++ b/repeater/web/auth/jwt_handler.py @@ -1,10 +1,12 @@ -import jwt +import logging import time from typing import Dict, Optional -import logging + +import jwt logger = logging.getLogger(__name__) + class JWTHandler: def __init__(self, secret: str, expiry_minutes: int = 15): self.secret = secret @@ -14,21 +16,16 @@ class JWTHandler: now = int(time.time()) expiry = now + (self.expiry_minutes * 60) - - payload = { - 'sub': username, - 'exp': expiry, - 'iat': now, - 'client_id': client_id - } - - token = jwt.encode(payload, self.secret, algorithm='HS256') + + payload = {"sub": username, "exp": expiry, "iat": now, "client_id": client_id} + + token = jwt.encode(payload, self.secret, algorithm="HS256") logger.info(f"Created JWT for user '{username}' with client_id '{client_id[:8]}...'") return token - + def verify_jwt(self, token: str) -> Optional[Dict]: try: - payload = jwt.decode(token, self.secret, algorithms=['HS256']) + payload = jwt.decode(token, self.secret, algorithms=["HS256"]) return payload except jwt.ExpiredSignatureError: logger.warning("JWT token expired") diff --git a/repeater/web/auth/middleware.py b/repeater/web/auth/middleware.py index c5cdb50..54ecc9b 100644 --- a/repeater/web/auth/middleware.py +++ b/repeater/web/auth/middleware.py @@ -1,6 +1,7 @@ -import cherrypy -from functools import wraps import logging +from functools import wraps + +import cherrypy logger = logging.getLogger(__name__) @@ -10,56 +11,56 @@ def require_auth(func): @wraps(func) def wrapper(*args, **kwargs): # Skip authentication for OPTIONS requests (CORS preflight) - if cherrypy.request.method == 'OPTIONS': + if cherrypy.request.method == "OPTIONS": return func(*args, **kwargs) - + # Get auth handlers from global cherrypy config (not app config) - jwt_handler = cherrypy.config.get('jwt_handler') - token_manager = cherrypy.config.get('token_manager') - + jwt_handler = cherrypy.config.get("jwt_handler") + token_manager = cherrypy.config.get("token_manager") + if not jwt_handler or not token_manager: logger.error("Auth handlers not configured") raise cherrypy.HTTPError(500, "Authentication not configured") - + # Try JWT authentication first - auth_header = cherrypy.request.headers.get('Authorization', '') - if auth_header.startswith('Bearer '): + auth_header = cherrypy.request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): token = auth_header[7:] # Remove 'Bearer ' prefix payload = jwt_handler.verify_jwt(token) - + if payload: # JWT is valid cherrypy.request.user = { - 'username': payload['sub'], - 'client_id': payload['client_id'], - 'auth_type': 'jwt' + "username": payload["sub"], + "client_id": payload["client_id"], + "auth_type": "jwt", } return func(*args, **kwargs) else: logger.warning("Invalid or expired JWT token") - + # Try API token authentication - api_key = cherrypy.request.headers.get('X-API-Key', '') + api_key = cherrypy.request.headers.get("X-API-Key", "") if api_key: token_info = token_manager.verify_token(api_key) - + if token_info: # API token is valid cherrypy.request.user = { - 'username': 'api_token', - 'token_name': token_info['name'], - 'token_id': token_info['id'], - 'auth_type': 'api_token' + "username": "api_token", + "token_name": token_info["name"], + "token_id": token_info["id"], + "auth_type": "api_token", } return func(*args, **kwargs) else: logger.warning("Invalid API token") - + # No valid authentication found logger.warning(f"Unauthorized access attempt to {cherrypy.request.path_info}") - + cherrypy.response.status = 401 - cherrypy.response.headers['Content-Type'] = 'application/json' - return {'success': False, 'error': 'Unauthorized - Valid JWT or API token required'} - - return wrapper \ No newline at end of file + cherrypy.response.headers["Content-Type"] = "application/json" + return {"success": False, "error": "Unauthorized - Valid JWT or API token required"} + + return wrapper diff --git a/repeater/web/auth_endpoints.py b/repeater/web/auth_endpoints.py index acf8f51..4f9e1ed 100644 --- a/repeater/web/auth_endpoints.py +++ b/repeater/web/auth_endpoints.py @@ -1,8 +1,11 @@ """ Authentication endpoints for login and token management """ -import cherrypy + import logging + +import cherrypy + from .auth.middleware import require_auth logger = logging.getLogger(__name__) @@ -24,123 +27,101 @@ class TokensAPIEndpoint: @require_auth def index(self): # Handle CORS preflight - if cherrypy.request.method == 'OPTIONS': + if cherrypy.request.method == "OPTIONS": return {} - + # Get token manager from cherrypy config - token_manager = cherrypy.config.get('token_manager') + token_manager = cherrypy.config.get("token_manager") if not token_manager: cherrypy.response.status = 500 - return {'success': False, 'error': 'Token manager not available'} - - if cherrypy.request.method == 'GET': + return {"success": False, "error": "Token manager not available"} + + if cherrypy.request.method == "GET": try: tokens = token_manager.list_tokens() - return { - 'success': True, - 'tokens': tokens - } + return {"success": True, "tokens": tokens} except Exception as e: logger.error(f"Token list error: {e}") cherrypy.response.status = 500 - return { - 'success': False, - 'error': 'Failed to list tokens' - } - - elif cherrypy.request.method == 'POST': + return {"success": False, "error": "Failed to list tokens"} + + elif cherrypy.request.method == "POST": try: import json - body = cherrypy.request.body.read().decode('utf-8') + + body = cherrypy.request.body.read().decode("utf-8") data = json.loads(body) if body else {} - name = data.get('name', '').strip() - + name = data.get("name", "").strip() + if not name: cherrypy.response.status = 400 - return { - 'success': False, - 'error': 'Token name is required' - } - + return {"success": False, "error": "Token name is required"} + # Create the token token_id, plaintext_token = token_manager.create_token(name) - - logger.info(f"Generated API token '{name}' (ID: {token_id}) by user {cherrypy.request.user['username']}") - + + logger.info( + f"Generated API token '{name}' (ID: {token_id}) by user {cherrypy.request.user['username']}" + ) + return { - 'success': True, - 'token': plaintext_token, - 'token_id': token_id, - 'name': name, - 'warning': 'Save this token securely - it will not be shown again' + "success": True, + "token": plaintext_token, + "token_id": token_id, + "name": name, + "warning": "Save this token securely - it will not be shown again", } - + except Exception as e: logger.error(f"Token generation error: {e}") cherrypy.response.status = 500 - return { - 'success': False, - 'error': 'Failed to generate token' - } + return {"success": False, "error": "Failed to generate token"} else: raise cherrypy.HTTPError(405, "Method not allowed") - + @cherrypy.expose @cherrypy.tools.json_out() @require_auth def default(self, token_id=None): # Handle CORS preflight - if cherrypy.request.method == 'OPTIONS': + if cherrypy.request.method == "OPTIONS": return {} - + # Get token manager from cherrypy config - token_manager = cherrypy.config.get('token_manager') + token_manager = cherrypy.config.get("token_manager") if not token_manager: cherrypy.response.status = 500 - return {'success': False, 'error': 'Token manager not available'} - - if cherrypy.request.method == 'DELETE': + return {"success": False, "error": "Token manager not available"} + + if cherrypy.request.method == "DELETE": try: if not token_id: cherrypy.response.status = 400 - return { - 'success': False, - 'error': 'Token ID is required' - } - + return {"success": False, "error": "Token ID is required"} + # Convert to int try: token_id_int = int(token_id) except ValueError: cherrypy.response.status = 400 - return { - 'success': False, - 'error': 'Invalid token ID' - } - + return {"success": False, "error": "Invalid token ID"} + # Revoke the token success = token_manager.revoke_token(token_id_int) - + if success: - logger.info(f"Revoked API token ID {token_id_int} by user {cherrypy.request.user['username']}") - return { - 'success': True, - 'message': 'Token revoked successfully' - } + logger.info( + f"Revoked API token ID {token_id_int} by user {cherrypy.request.user['username']}" + ) + return {"success": True, "message": "Token revoked successfully"} else: cherrypy.response.status = 404 - return { - 'success': False, - 'error': 'Token not found' - } - + return {"success": False, "error": "Token not found"} + except Exception as e: logger.error(f"Token revocation error: {e}") cherrypy.response.status = 500 - return { - 'success': False, - 'error': 'Failed to revoke token' - } + return {"success": False, "error": "Failed to revoke token"} else: raise cherrypy.HTTPError(405, "Method not allowed") @@ -156,309 +137,314 @@ class AuthEndpoints: @cherrypy.expose def login(self, **kwargs): - cherrypy.response.headers['Content-Type'] = 'application/json' - + cherrypy.response.headers["Content-Type"] = "application/json" + # Handle CORS preflight - if cherrypy.request.method == 'OPTIONS': - cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' - return b'' - - if cherrypy.request.method != 'POST': + if cherrypy.request.method == "OPTIONS": + cherrypy.response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" + cherrypy.response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-API-Key" + ) + return b"" + + if cherrypy.request.method != "POST": raise cherrypy.HTTPError(405, "Method not allowed") - + try: # Parse JSON body manually since we can't use json_in decorator with OPTIONS import json - body = cherrypy.request.body.read().decode('utf-8') + + body = cherrypy.request.body.read().decode("utf-8") data = json.loads(body) if body else {} - - username = data.get('username', '').strip() - password = data.get('password', '') - client_id = data.get('client_id', '').strip() - + + username = data.get("username", "").strip() + password = data.get("password", "") + client_id = data.get("client_id", "").strip() + if not username or not password or not client_id: - return json.dumps({ - 'success': False, - 'error': 'Missing required fields: username, password, client_id' - }).encode('utf-8') - + return json.dumps( + { + "success": False, + "error": "Missing required fields: username, password, client_id", + } + ).encode("utf-8") + # Validate credentials against config # Check if username is 'admin' and password matches config - repeater_config = self.config.get('repeater', {}) - security_config = repeater_config.get('security', {}) - config_password = security_config.get('admin_password', '') - + repeater_config = self.config.get("repeater", {}) + security_config = repeater_config.get("security", {}) + config_password = security_config.get("admin_password", "") + # Don't allow login with empty or unconfigured password if not config_password: logger.warning(f"Login attempt rejected - password not configured") - return json.dumps({ - 'success': False, - 'error': 'System not configured. Please complete setup wizard.' - }).encode('utf-8') - - if username == 'admin' and password == config_password: + return json.dumps( + { + "success": False, + "error": "System not configured. Please complete setup wizard.", + } + ).encode("utf-8") + + if username == "admin" and password == config_password: # Create JWT token token = self.jwt_handler.create_jwt(username, client_id) - - logger.info(f"Successful login for user '{username}' from client '{client_id[:8]}...'") - - return json.dumps({ - 'success': True, - 'token': token, - 'expires_in': self.jwt_handler.expiry_minutes * 60, - 'username': username - }).encode('utf-8') + + logger.info( + f"Successful login for user '{username}' from client '{client_id[:8]}...'" + ) + + return json.dumps( + { + "success": True, + "token": token, + "expires_in": self.jwt_handler.expiry_minutes * 60, + "username": username, + } + ).encode("utf-8") else: logger.warning(f"Failed login attempt for user '{username}'") - + # Don't reveal which part was wrong - return json.dumps({ - 'success': False, - 'error': 'Invalid username or password' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "Invalid username or password"} + ).encode("utf-8") + except Exception as e: logger.error(f"Login error: {e}") - return json.dumps({ - 'success': False, - 'error': 'Internal server error' - }).encode('utf-8') - + return json.dumps({"success": False, "error": "Internal server error"}).encode("utf-8") + @cherrypy.expose @cherrypy.tools.json_out() @require_auth def verify(self): - if cherrypy.request.method != 'GET': + if cherrypy.request.method != "GET": raise cherrypy.HTTPError(405, "Method not allowed") - - return { - 'success': True, - 'authenticated': True, - 'user': cherrypy.request.user - } - + + return {"success": True, "authenticated": True, "user": cherrypy.request.user} + @cherrypy.expose def refresh(self, **kwargs): - cherrypy.response.headers['Content-Type'] = 'application/json' - + cherrypy.response.headers["Content-Type"] = "application/json" + # Handle CORS preflight - if cherrypy.request.method == 'OPTIONS': - cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' - return b'' - - if cherrypy.request.method != 'POST': + if cherrypy.request.method == "OPTIONS": + cherrypy.response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" + cherrypy.response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-API-Key" + ) + return b"" + + if cherrypy.request.method != "POST": raise cherrypy.HTTPError(405, "Method not allowed") - + try: import json - + # Manual authentication check (can't use @require_auth since we need to handle OPTIONS) - auth_header = cherrypy.request.headers.get('Authorization', '') - api_key = cherrypy.request.headers.get('X-API-Key', '') - - jwt_handler = cherrypy.config.get('jwt_handler') - token_manager = cherrypy.config.get('token_manager') - + auth_header = cherrypy.request.headers.get("Authorization", "") + api_key = cherrypy.request.headers.get("X-API-Key", "") + + jwt_handler = cherrypy.config.get("jwt_handler") + token_manager = cherrypy.config.get("token_manager") + user_info = None - + # Check JWT first - if auth_header.startswith('Bearer '): + if auth_header.startswith("Bearer "): token = auth_header[7:] payload = jwt_handler.verify_jwt(token) if payload: user_info = { - 'username': payload['sub'], - 'client_id': payload.get('client_id'), - 'auth_method': 'jwt' + "username": payload["sub"], + "client_id": payload.get("client_id"), + "auth_method": "jwt", } - + # Check API token if not user_info and api_key: token_data = token_manager.verify_token(api_key) if token_data: user_info = { - 'username': 'admin', - 'token_id': token_data['id'], - 'auth_method': 'api_token' + "username": "admin", + "token_id": token_data["id"], + "auth_method": "api_token", } - + if not user_info: - return json.dumps({ - 'success': False, - 'error': 'Unauthorized - Valid JWT or API token required' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "Unauthorized - Valid JWT or API token required"} + ).encode("utf-8") + # Parse request body - body = cherrypy.request.body.read().decode('utf-8') + body = cherrypy.request.body.read().decode("utf-8") data = json.loads(body) if body else {} - - client_id = data.get('client_id', user_info.get('client_id', '')).strip() - + + client_id = data.get("client_id", user_info.get("client_id", "")).strip() + if not client_id: - return json.dumps({ - 'success': False, - 'error': 'Client ID is required' - }).encode('utf-8') - + return json.dumps({"success": False, "error": "Client ID is required"}).encode( + "utf-8" + ) + # Create new JWT token (refreshes expiry time) - new_token = self.jwt_handler.create_jwt(user_info['username'], client_id) - - logger.info(f"Token refreshed for user '{user_info['username']}' from client '{client_id[:8]}...'") - - return json.dumps({ - 'success': True, - 'token': new_token, - 'expires_in': self.jwt_handler.expiry_minutes * 60, - 'username': user_info['username'] - }).encode('utf-8') - + new_token = self.jwt_handler.create_jwt(user_info["username"], client_id) + + logger.info( + f"Token refreshed for user '{user_info['username']}' from client '{client_id[:8]}...'" + ) + + return json.dumps( + { + "success": True, + "token": new_token, + "expires_in": self.jwt_handler.expiry_minutes * 60, + "username": user_info["username"], + } + ).encode("utf-8") + except Exception as e: logger.error(f"Token refresh error: {e}") - return json.dumps({ - 'success': False, - 'error': 'Failed to refresh token' - }).encode('utf-8') - + return json.dumps({"success": False, "error": "Failed to refresh token"}).encode( + "utf-8" + ) + @cherrypy.expose def change_password(self): import json - - cherrypy.response.headers['Content-Type'] = 'application/json' - + + cherrypy.response.headers["Content-Type"] = "application/json" + # Handle CORS preflight - if cherrypy.request.method == 'OPTIONS': - cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' - cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' - return b'' - - if cherrypy.request.method != 'POST': + if cherrypy.request.method == "OPTIONS": + cherrypy.response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" + cherrypy.response.headers["Access-Control-Allow-Headers"] = ( + "Content-Type, Authorization, X-API-Key" + ) + return b"" + + if cherrypy.request.method != "POST": raise cherrypy.HTTPError(405, "Method not allowed") - + # Require authentication for POST # Get auth handlers from global cherrypy config - jwt_handler = cherrypy.config.get('jwt_handler') - token_manager = cherrypy.config.get('token_manager') - + jwt_handler = cherrypy.config.get("jwt_handler") + token_manager = cherrypy.config.get("token_manager") + if not jwt_handler or not token_manager: logger.error("Auth handlers not configured") raise cherrypy.HTTPError(500, "Authentication not configured") - + # Try JWT authentication first - auth_header = cherrypy.request.headers.get('Authorization', '') + auth_header = cherrypy.request.headers.get("Authorization", "") user = None - - if auth_header.startswith('Bearer '): + + if auth_header.startswith("Bearer "): token = auth_header[7:] # Remove 'Bearer ' prefix payload = jwt_handler.verify_jwt(token) - + if payload: user = { - 'username': payload['sub'], - 'client_id': payload['client_id'], - 'auth_type': 'jwt' + "username": payload["sub"], + "client_id": payload["client_id"], + "auth_type": "jwt", } - + # Try API token authentication if JWT failed if not user: - api_key = cherrypy.request.headers.get('X-API-Key', '') + api_key = cherrypy.request.headers.get("X-API-Key", "") if api_key: token_info = token_manager.verify_token(api_key) - + if token_info: user = { - 'username': 'api_token', - 'token_name': token_info['name'], - 'token_id': token_info['id'], - 'auth_type': 'api_token' + "username": "api_token", + "token_name": token_info["name"], + "token_id": token_info["id"], + "auth_type": "api_token", } - + if not user: cherrypy.response.status = 401 - return json.dumps({ - 'success': False, - 'error': 'Unauthorized - Valid JWT or API token required' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "Unauthorized - Valid JWT or API token required"} + ).encode("utf-8") + try: # Parse JSON body manually - body = cherrypy.request.body.read().decode('utf-8') + body = cherrypy.request.body.read().decode("utf-8") data = json.loads(body) if body else {} - - current_password = data.get('current_password', '') - new_password = data.get('new_password', '') - + + current_password = data.get("current_password", "") + new_password = data.get("new_password", "") + if not current_password or not new_password: cherrypy.response.status = 400 - return json.dumps({ - 'success': False, - 'error': 'Both current_password and new_password are required' - }).encode('utf-8') - + return json.dumps( + { + "success": False, + "error": "Both current_password and new_password are required", + } + ).encode("utf-8") + # Validate new password strength if len(new_password) < 8: cherrypy.response.status = 400 - return json.dumps({ - 'success': False, - 'error': 'New password must be at least 8 characters long' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "New password must be at least 8 characters long"} + ).encode("utf-8") + # Verify current password - repeater_config = self.config.get('repeater', {}) - security_config = repeater_config.get('security', {}) - config_password = security_config.get('admin_password', '') - + repeater_config = self.config.get("repeater", {}) + security_config = repeater_config.get("security", {}) + config_password = security_config.get("admin_password", "") + if not config_password: cherrypy.response.status = 500 - return json.dumps({ - 'success': False, - 'error': 'System configuration error' - }).encode('utf-8') - + return json.dumps({"success": False, "error": "System configuration error"}).encode( + "utf-8" + ) + if current_password != config_password: cherrypy.response.status = 401 - return json.dumps({ - 'success': False, - 'error': 'Current password is incorrect' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "Current password is incorrect"} + ).encode("utf-8") + # Update password in config - if 'repeater' not in self.config: - self.config['repeater'] = {} - if 'security' not in self.config['repeater']: - self.config['repeater']['security'] = {} - - self.config['repeater']['security']['admin_password'] = new_password - + if "repeater" not in self.config: + self.config["repeater"] = {} + if "security" not in self.config["repeater"]: + self.config["repeater"]["security"] = {} + + self.config["repeater"]["security"]["admin_password"] = new_password + # Save to config file using ConfigManager if self.config_manager: saved, _ = self.config_manager.save_to_file() if saved: logger.info(f"Admin password changed successfully by user {user['username']}") - return json.dumps({ - 'success': True, - 'message': 'Password changed successfully. Please log in again with your new password.' - }).encode('utf-8') + return json.dumps( + { + "success": True, + "message": "Password changed successfully. Please log in again with your new password.", + } + ).encode("utf-8") else: cherrypy.response.status = 500 - return json.dumps({ - 'success': False, - 'error': 'Failed to save password to config file' - }).encode('utf-8') + return json.dumps( + {"success": False, "error": "Failed to save password to config file"} + ).encode("utf-8") else: cherrypy.response.status = 500 - return json.dumps({ - 'success': False, - 'error': 'Config manager not available' - }).encode('utf-8') - + return json.dumps( + {"success": False, "error": "Config manager not available"} + ).encode("utf-8") + except Exception as e: logger.error(f"Password change error: {e}") cherrypy.response.status = 500 - return json.dumps({ - 'success': False, - 'error': 'Failed to change password' - }).encode('utf-8') \ No newline at end of file + return json.dumps({"success": False, "error": "Failed to change password"}).encode( + "utf-8" + ) diff --git a/repeater/web/cad_calibration_engine.py b/repeater/web/cad_calibration_engine.py index c124cfe..f43dbe0 100644 --- a/repeater/web/cad_calibration_engine.py +++ b/repeater/web/cad_calibration_engine.py @@ -3,13 +3,13 @@ import logging import random import threading import time -from typing import Dict, Any, Optional +from typing import Any, Dict, Optional logger = logging.getLogger("HTTPServer") class CADCalibrationEngine: - + def __init__(self, daemon_instance=None, event_loop=None): self.daemon_instance = daemon_instance self.event_loop = event_loop @@ -19,26 +19,28 @@ class CADCalibrationEngine: self.progress = {"current": 0, "total": 0} self.clients = set() # SSE clients self.calibration_thread = None - + def get_test_ranges(self, spreading_factor: int): """Get CAD test ranges""" # Higher values = less sensitive, lower values = more sensitive # Test from LESS sensitive to MORE sensitive to find the sweet spot sf_ranges = { - 7: (range(22, 30, 1), range(12, 20, 1)), - 8: (range(22, 30, 1), range(12, 20, 1)), - 9: (range(24, 32, 1), range(14, 22, 1)), - 10: (range(26, 34, 1), range(16, 24, 1)), - 11: (range(28, 36, 1), range(18, 26, 1)), - 12: (range(30, 38, 1), range(20, 28, 1)), + 7: (range(22, 30, 1), range(12, 20, 1)), + 8: (range(22, 30, 1), range(12, 20, 1)), + 9: (range(24, 32, 1), range(14, 22, 1)), + 10: (range(26, 34, 1), range(16, 24, 1)), + 11: (range(28, 36, 1), range(18, 26, 1)), + 12: (range(30, 38, 1), range(20, 28, 1)), } return sf_ranges.get(spreading_factor, sf_ranges[8]) - - async def test_cad_config(self, radio, det_peak: int, det_min: int, samples: int = 20) -> Dict[str, Any]: - + + async def test_cad_config( + self, radio, det_peak: int, det_min: int, samples: int = 20 + ) -> Dict[str, Any]: + detections = 0 baseline_detections = 0 - + # First, get baseline with very insensitive settings (should detect nothing) baseline_samples = 5 for _ in range(baseline_samples): @@ -50,10 +52,10 @@ class CADCalibrationEngine: except Exception: pass await asyncio.sleep(0.1) # 100ms between baseline samples - + # Wait before actual test await asyncio.sleep(0.5) - + # Now test the actual configuration for i in range(samples): try: @@ -62,226 +64,247 @@ class CADCalibrationEngine: detections += 1 except Exception: pass - + # Variable delay to avoid sampling artifacts delay = 0.05 + (i % 3) * 0.05 # 50ms, 100ms, 150ms rotation await asyncio.sleep(delay) - + # Calculate adjusted detection rate baseline_rate = (baseline_detections / baseline_samples) * 100 detection_rate = (detections / samples) * 100 - + # Subtract baseline noise adjusted_rate = max(0, detection_rate - baseline_rate) - + return { - 'det_peak': det_peak, - 'det_min': det_min, - 'samples': samples, - 'detections': detections, - 'detection_rate': detection_rate, - 'baseline_rate': baseline_rate, - 'adjusted_rate': adjusted_rate, # This is the useful metric - 'sensitivity_score': self._calculate_sensitivity_score(det_peak, det_min, adjusted_rate) + "det_peak": det_peak, + "det_min": det_min, + "samples": samples, + "detections": detections, + "detection_rate": detection_rate, + "baseline_rate": baseline_rate, + "adjusted_rate": adjusted_rate, # This is the useful metric + "sensitivity_score": self._calculate_sensitivity_score( + det_peak, det_min, adjusted_rate + ), } - - def _calculate_sensitivity_score(self, det_peak: int, det_min: int, adjusted_rate: float) -> float: - + + def _calculate_sensitivity_score( + self, det_peak: int, det_min: int, adjusted_rate: float + ) -> float: + # Ideal detection rate is around 10-30% for good sensitivity without false positives ideal_rate = 20.0 rate_penalty = abs(adjusted_rate - ideal_rate) / ideal_rate - + # Prefer moderate sensitivity settings (not too extreme) sensitivity_penalty = (abs(det_peak - 25) + abs(det_min - 15)) / 20.0 - + # Lower penalty = higher score score = max(0, 100 - (rate_penalty * 50) - (sensitivity_penalty * 20)) return score - + def broadcast_to_clients(self, data): # Store the message for clients to pick up self.last_message = data # Also store in a queue for clients to consume - if not hasattr(self, 'message_queue'): + if not hasattr(self, "message_queue"): self.message_queue = [] self.message_queue.append(data) - + def calibration_worker(self, samples: int, delay_ms: int): - + try: # Get radio from daemon instance if not self.daemon_instance: - self.broadcast_to_clients({"type": "error", "message": "No daemon instance available"}) + self.broadcast_to_clients( + {"type": "error", "message": "No daemon instance available"} + ) return - - radio = getattr(self.daemon_instance, 'radio', None) + + radio = getattr(self.daemon_instance, "radio", None) if not radio: - self.broadcast_to_clients({"type": "error", "message": "Radio instance not available"}) + self.broadcast_to_clients( + {"type": "error", "message": "Radio instance not available"} + ) return - if not hasattr(radio, 'perform_cad'): - self.broadcast_to_clients({"type": "error", "message": "Radio does not support CAD"}) + if not hasattr(radio, "perform_cad"): + self.broadcast_to_clients( + {"type": "error", "message": "Radio does not support CAD"} + ) return - + # Get spreading factor from daemon instance - config = getattr(self.daemon_instance, 'config', {}) + config = getattr(self.daemon_instance, "config", {}) radio_config = config.get("radio", {}) sf = radio_config.get("spreading_factor", 8) - + # Get test ranges peak_range, min_range = self.get_test_ranges(sf) - + total_tests = len(peak_range) * len(min_range) self.progress = {"current": 0, "total": total_tests} - - self.broadcast_to_clients({ - "type": "status", - "message": f"Starting calibration: SF{sf}, {total_tests} tests", - "test_ranges": { - "peak_min": min(peak_range), - "peak_max": max(peak_range), - "min_min": min(min_range), - "min_max": max(min_range), - "spreading_factor": sf, - "total_tests": total_tests + + self.broadcast_to_clients( + { + "type": "status", + "message": f"Starting calibration: SF{sf}, {total_tests} tests", + "test_ranges": { + "peak_min": min(peak_range), + "peak_max": max(peak_range), + "min_min": min(min_range), + "min_max": max(min_range), + "spreading_factor": sf, + "total_tests": total_tests, + }, } - }) - + ) + current = 0 - + peak_list = list(peak_range) min_list = list(min_range) - + # Create all test combinations test_combinations = [] for det_peak in peak_list: for det_min in min_list: test_combinations.append((det_peak, det_min)) - + # Sort by distance from center for center-out pattern peak_center = (max(peak_list) + min(peak_list)) / 2 min_center = (max(min_list) + min(min_list)) / 2 - + def distance_from_center(combo): peak, min_val = combo return ((peak - peak_center) ** 2 + (min_val - min_center) ** 2) ** 0.5 - + # Sort by distance from center test_combinations.sort(key=distance_from_center) - + # Randomize within bands for better coverage band_size = max(1, len(test_combinations) // 8) # Create 8 bands randomized_combinations = [] - + for i in range(0, len(test_combinations), band_size): - band = test_combinations[i:i + band_size] + band = test_combinations[i : i + band_size] random.shuffle(band) # Randomize within each band randomized_combinations.extend(band) - + # Run calibration in event loop with center-out randomized pattern if self.event_loop: for det_peak, det_min in randomized_combinations: if not self.running: break - + current += 1 self.progress["current"] = current - + # Update progress - self.broadcast_to_clients({ - "type": "progress", - "current": current, - "total": total_tests, - "peak": det_peak, - "min": det_min - }) - + self.broadcast_to_clients( + { + "type": "progress", + "current": current, + "total": total_tests, + "peak": det_peak, + "min": det_min, + } + ) + # Run the test future = asyncio.run_coroutine_threadsafe( - self.test_cad_config(radio, det_peak, det_min, samples), - self.event_loop + self.test_cad_config(radio, det_peak, det_min, samples), self.event_loop ) - + try: result = future.result(timeout=30) # 30 second timeout per test - + # Store result key = f"{det_peak}-{det_min}" self.results[key] = result - + # Send result to clients - self.broadcast_to_clients({ - "type": "result", - **result - }) + self.broadcast_to_clients({"type": "result", **result}) except Exception as e: logger.error(f"CAD test failed for peak={det_peak}, min={det_min}: {e}") - + # Delay between tests if self.running and delay_ms > 0: time.sleep(delay_ms / 1000.0) - + if self.running: # Find best result based on sensitivity score (not just detection rate) best_result = None recommended_result = None if self.results: # Find result with highest sensitivity score (best balance) - best_result = max(self.results.values(), key=lambda x: x.get('sensitivity_score', 0)) - + best_result = max( + self.results.values(), key=lambda x: x.get("sensitivity_score", 0) + ) + # Also find result with ideal adjusted detection rate (10-30%) - ideal_results = [r for r in self.results.values() if 10 <= r.get('adjusted_rate', 0) <= 30] + ideal_results = [ + r for r in self.results.values() if 10 <= r.get("adjusted_rate", 0) <= 30 + ] if ideal_results: # Among ideal results, pick the one with best sensitivity score - recommended_result = max(ideal_results, key=lambda x: x.get('sensitivity_score', 0)) + recommended_result = max( + ideal_results, key=lambda x: x.get("sensitivity_score", 0) + ) else: recommended_result = best_result - - self.broadcast_to_clients({ - "type": "completed", - "message": "Calibration completed", - "results": { - "best": best_result, - "recommended": recommended_result, - "total_tests": len(self.results) - } if best_result else None - }) + + self.broadcast_to_clients( + { + "type": "completed", + "message": "Calibration completed", + "results": ( + { + "best": best_result, + "recommended": recommended_result, + "total_tests": len(self.results), + } + if best_result + else None + ), + } + ) else: self.broadcast_to_clients({"type": "status", "message": "Calibration stopped"}) - + except Exception as e: logger.error(f"Calibration worker error: {e}") self.broadcast_to_clients({"type": "error", "message": str(e)}) finally: self.running = False - + def start_calibration(self, samples: int = 8, delay_ms: int = 100): - + if self.running: return False - + self.running = True self.results.clear() self.progress = {"current": 0, "total": 0} self.clear_message_queue() # Clear any old messages - + # Start calibration in separate thread self.calibration_thread = threading.Thread( - target=self.calibration_worker, - args=(samples, delay_ms) + target=self.calibration_worker, args=(samples, delay_ms) ) self.calibration_thread.daemon = True self.calibration_thread.start() - + return True - + def stop_calibration(self): - + self.running = False if self.calibration_thread: self.calibration_thread.join(timeout=2) - + def clear_message_queue(self): - - if hasattr(self, 'message_queue'): - self.message_queue.clear() \ No newline at end of file + + if hasattr(self, "message_queue"): + self.message_queue.clear() diff --git a/repeater/web/companion_endpoints.py b/repeater/web/companion_endpoints.py index 81ea9b3..24f60da 100644 --- a/repeater/web/companion_endpoints.py +++ b/repeater/web/companion_endpoints.py @@ -10,11 +10,12 @@ import asyncio import json import logging import queue -import time import threading +import time from typing import Optional import cherrypy + from .auth.middleware import require_auth logger = logging.getLogger("CompanionAPI") @@ -62,7 +63,9 @@ class CompanionAPIEndpoints: if name is not None: identity_manager = getattr(self.daemon_instance, "identity_manager", None) if identity_manager: - for reg_name, identity, _cfg in identity_manager.get_identities_by_type("companion"): + for reg_name, identity, _cfg in identity_manager.get_identities_by_type( + "companion" + ): if reg_name == name: hash_byte = identity.get_public_key()[0] bridge = bridges.get(hash_byte) @@ -74,7 +77,8 @@ class CompanionAPIEndpoints: if companion_hash is not None: bridge = bridges.get(companion_hash) if not bridge: - raise cherrypy.HTTPError(404, f"Companion 0x{companion_hash:02X} not found") + msg = f"Companion 0x{companion_hash:02X} not found" # noqa: E231 + raise cherrypy.HTTPError(404, msg) return bridge # --- default: first bridge --- @@ -154,9 +158,11 @@ class CompanionAPIEndpoints: def _make_cb(event_name): """Create a callback that serialises event data for SSE clients.""" + def _cb(*args, **kwargs): payload = self._serialise_event(event_name, args, kwargs) self._broadcast_sse(payload) + return _cb callback_names = [ @@ -218,15 +224,17 @@ class CompanionAPIEndpoints: items = [] for h, b in bridges.items(): - items.append({ - "companion_name": name_by_hash.get(h, ""), - "companion_hash": f"0x{h:02X}", - "node_name": b.prefs.node_name, - "public_key": b.get_public_key().hex(), - "is_running": b.is_running, - "contacts_count": b.contacts.get_count(), - "channels_count": b.channels.get_count(), - }) + items.append( + { + "companion_name": name_by_hash.get(h, ""), + "companion_hash": f"0x{h:02X}", # noqa: E231 + "node_name": b.prefs.node_name, + "public_key": b.get_public_key().hex(), + "is_running": b.is_running, + "contacts_count": b.contacts.get_count(), + "channels_count": b.channels.get_count(), + } + ) return self._success(items) # ----- Identity ----- @@ -238,18 +246,20 @@ class CompanionAPIEndpoints: """GET /api/companion/self_info — node identity and preferences.""" bridge = self._get_bridge(**self._resolve_bridge_params(kwargs)) prefs = bridge.get_self_info() - return self._success({ - "public_key": bridge.get_public_key().hex(), - "node_name": prefs.node_name, - "adv_type": prefs.adv_type, - "tx_power_dbm": prefs.tx_power_dbm, - "frequency_hz": prefs.frequency_hz, - "bandwidth_hz": prefs.bandwidth_hz, - "spreading_factor": prefs.spreading_factor, - "coding_rate": prefs.coding_rate, - "latitude": prefs.latitude, - "longitude": prefs.longitude, - }) + return self._success( + { + "public_key": bridge.get_public_key().hex(), + "node_name": prefs.node_name, + "adv_type": prefs.adv_type, + "tx_power_dbm": prefs.tx_power_dbm, + "frequency_hz": prefs.frequency_hz, + "bandwidth_hz": prefs.bandwidth_hz, + "spreading_factor": prefs.spreading_factor, + "coding_rate": prefs.coding_rate, + "latitude": prefs.latitude, + "longitude": prefs.longitude, + } + ) # ----- Contacts ----- @@ -263,17 +273,21 @@ class CompanionAPIEndpoints: contacts = bridge.get_contacts(since=since) items = [] for c in contacts: - items.append({ - "public_key": c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key, - "name": c.name, - "adv_type": c.adv_type, - "flags": c.flags, - "out_path_len": c.out_path_len, - "last_advert_timestamp": c.last_advert_timestamp, - "lastmod": c.lastmod, - "gps_lat": c.gps_lat, - "gps_lon": c.gps_lon, - }) + items.append( + { + "public_key": ( + c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key + ), + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + } + ) return self._success(items) @cherrypy.expose @@ -289,18 +303,22 @@ class CompanionAPIEndpoints: c = bridge.get_contact_by_key(pub_key) if not c: raise cherrypy.HTTPError(404, "Contact not found") - return self._success({ - "public_key": c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key, - "name": c.name, - "adv_type": c.adv_type, - "flags": c.flags, - "out_path_len": c.out_path_len, - "out_path": c.out_path.hex() if isinstance(c.out_path, bytes) else "", - "last_advert_timestamp": c.last_advert_timestamp, - "lastmod": c.lastmod, - "gps_lat": c.gps_lat, - "gps_lon": c.gps_lon, - }) + return self._success( + { + "public_key": ( + c.public_key.hex() if isinstance(c.public_key, bytes) else c.public_key + ), + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "out_path": c.out_path.hex() if isinstance(c.out_path, bytes) else "", + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + } + ) # ----- Channels ----- @@ -315,11 +333,13 @@ class CompanionAPIEndpoints: for idx in range(bridge.channels.max_channels): ch = bridge.channels.get(idx) if ch: - items.append({ - "index": idx, - "name": ch.name, - # Don't expose the PSK secret over REST - }) + items.append( + { + "index": idx, + "name": ch.name, + # Don't expose the PSK secret over REST + } + ) return self._success(items) except cherrypy.HTTPError: raise @@ -354,14 +374,14 @@ class CompanionAPIEndpoints: if not text: raise cherrypy.HTTPError(400, "text required") txt_type = int(body.get("txt_type", 0)) - result = self._run_async( - bridge.send_text_message(pub_key, text, txt_type=txt_type) + result = self._run_async(bridge.send_text_message(pub_key, text, txt_type=txt_type)) + return self._success( + { + "sent": result.success, + "is_flood": result.is_flood, + "expected_ack": result.expected_ack, + } ) - return self._success({ - "sent": result.success, - "is_flood": result.is_flood, - "expected_ack": result.expected_ack, - }) @cherrypy.expose @cherrypy.tools.json_out() @@ -390,9 +410,7 @@ class CompanionAPIEndpoints: bridge = self._get_bridge(**self._resolve_bridge_params(body)) pub_key = self._pub_key_from_hex(body.get("pub_key", "")) password = body.get("password", "") - result = self._run_async( - bridge.send_login(pub_key, password), timeout=15.0 - ) + result = self._run_async(bridge.send_login(pub_key, password), timeout=15.0) return self._success(_to_json_safe(result)) # ----- Status / Telemetry Requests ----- @@ -417,7 +435,11 @@ class CompanionAPIEndpoints: @cherrypy.tools.json_out() @require_auth def request_telemetry(self, **kwargs): - """POST /api/companion/request_telemetry {pub_key, want_base?, want_location?, want_environment?, timeout?, companion_name?}""" + """POST /api/companion/request_telemetry. + + Body: pub_key, want_base?, want_location?, want_environment?, + timeout?, companion_name? + """ self._require_post() try: body = self._get_json_body() @@ -531,7 +553,8 @@ class CompanionAPIEndpoints: def generate(): try: - yield f"data: {json.dumps({'event': 'connected', 'timestamp': int(time.time())})}\n\n" + payload = {"event": "connected", "timestamp": int(time.time())} + yield f"data: {json.dumps(payload)}\n\n" while True: try: @@ -539,7 +562,8 @@ class CompanionAPIEndpoints: yield f"data: {json.dumps(item)}\n\n" except queue.Empty: # Keep-alive comment - yield f"data: {json.dumps({'event': 'keepalive', 'timestamp': int(time.time())})}\n\n" + payload = {"event": "keepalive", "timestamp": int(time.time())} + yield f"data: {json.dumps(payload)}\n\n" except GeneratorExit: pass except Exception as exc: @@ -558,6 +582,7 @@ class CompanionAPIEndpoints: # Utility: make arbitrary objects JSON-serialisable for SSE events # ====================================================================== + def _to_json_safe(obj): """Convert common companion objects to JSON-safe dicts/values.""" if obj is None or isinstance(obj, (bool, int, float, str)): diff --git a/repeater/web/html/assets/plotly.min-DO11Gp-n.js b/repeater/web/html/assets/plotly.min-DO11Gp-n.js index c8ae593..dcefca3 100644 --- a/repeater/web/html/assets/plotly.min-DO11Gp-n.js +++ b/repeater/web/html/assets/plotly.min-DO11Gp-n.js @@ -233,7 +233,7 @@ float cookTorranceSpecular( float G1 = (2.0 * NdotH * VdotN) / VdotH; float G2 = (2.0 * NdotH * LdotN) / LdotH; float G = min(1.0, min(G1, G2)); - + //Distribution term float D = beckmannDistribution(NdotH, roughness); @@ -245,7 +245,7 @@ float cookTorranceSpecular( } bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -393,7 +393,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -511,7 +511,7 @@ float cookTorranceSpecular( float G1 = (2.0 * NdotH * VdotN) / VdotH; float G2 = (2.0 * NdotH * LdotN) / LdotH; float G = min(1.0, min(G1, G2)); - + //Distribution term float D = beckmannDistribution(NdotH, roughness); @@ -525,7 +525,7 @@ float cookTorranceSpecular( //#pragma glslify: beckmann = require(glsl-specular-beckmann) // used in gl-surface3d bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -604,7 +604,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -639,7 +639,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -713,7 +713,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -746,7 +746,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -861,7 +861,7 @@ float beckmannSpecular( } bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -963,7 +963,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1011,7 +1011,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1068,7 +1068,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1126,7 +1126,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1186,7 +1186,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1222,7 +1222,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1526,7 +1526,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1673,7 +1673,7 @@ float cookTorranceSpecular( float G1 = (2.0 * NdotH * VdotN) / VdotH; float G2 = (2.0 * NdotH * LdotN) / LdotH; float G = min(1.0, min(G1, G2)); - + //Distribution term float D = beckmannDistribution(NdotH, roughness); @@ -1685,7 +1685,7 @@ float cookTorranceSpecular( } bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1796,7 +1796,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1871,7 +1871,7 @@ void main() { #define GLSLIFY 1 bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } @@ -1957,7 +1957,7 @@ vec4 packFloat(float v) { } bool outOfRange(float a, float b, float p) { - return ((p > max(a, b)) || + return ((p > max(a, b)) || (p < min(a, b))); } diff --git a/repeater/web/http_server.py b/repeater/web/http_server.py index 2740e7f..84e21c4 100644 --- a/repeater/web/http_server.py +++ b/repeater/web/http_server.py @@ -14,15 +14,21 @@ from pymc_core.protocol.utils import PAYLOAD_TYPES, ROUTE_TYPES from repeater import __version__ from repeater.data_acquisition import SQLiteHandler + from .api_endpoints import APIEndpoints -from .auth_endpoints import AuthEndpoints -from .auth.jwt_handler import JWTHandler -from .auth.api_tokens import APITokenManager from .auth import cherrypy_tool # Import to register the tool +from .auth.api_tokens import APITokenManager +from .auth.jwt_handler import JWTHandler +from .auth_endpoints import AuthEndpoints # WebSocket support try: - from repeater.data_acquisition.websocket_handler import PacketWebSocket, init_websocket, broadcast_packet + from repeater.data_acquisition.websocket_handler import ( + PacketWebSocket, + broadcast_packet, + init_websocket, + ) + WEBSOCKET_AVAILABLE = True except ImportError: WEBSOCKET_AVAILABLE = False @@ -61,40 +67,41 @@ _log_buffer = LogBuffer(max_lines=100) class DocEndpoint: """Simple wrapper to serve API docs at /doc""" - + def __init__(self, api_endpoints): self.api_endpoints = api_endpoints - + @cherrypy.expose def index(self, **kwargs): """Serve Swagger UI at /doc""" return self.api_endpoints.docs() - + @cherrypy.expose def docs(self): """Serve Swagger UI at /doc/docs""" return self.api_endpoints.docs() - + @cherrypy.expose def openapi_json(self): """Serve OpenAPI spec in JSON format at /doc/openapi.json""" - import os - import yaml import json - - spec_path = os.path.join(os.path.dirname(__file__), 'openapi.yaml') + import os + + import yaml + + spec_path = os.path.join(os.path.dirname(__file__), "openapi.yaml") try: - with open(spec_path, 'r') as f: + with open(spec_path, "r") as f: spec_content = yaml.safe_load(f) - - cherrypy.response.headers['Content-Type'] = 'application/json' - return json.dumps(spec_content).encode('utf-8') + + cherrypy.response.headers["Content-Type"] = "application/json" + return json.dumps(spec_content).encode("utf-8") except FileNotFoundError: cherrypy.response.status = 404 - return json.dumps({"error": "OpenAPI spec not found"}).encode('utf-8') + return json.dumps({"error": "OpenAPI spec not found"}).encode("utf-8") except Exception as e: cherrypy.response.status = 500 - return json.dumps({"error": f"Error loading OpenAPI spec: {e}"}).encode('utf-8') + return json.dumps({"error": f"Error loading OpenAPI spec: {e}"}).encode("utf-8") class StatsApp: @@ -116,7 +123,7 @@ class StatsApp: self.pub_key = pub_key self.dashboard_template = None self.config = config or {} - + # Path to the compiled Vue.js application # Use web_path from config if provided, otherwise use default default_html_dir = os.path.join(os.path.dirname(__file__), "html") @@ -124,8 +131,10 @@ class StatsApp: self.html_dir = web_path if web_path is not None else default_html_dir # Create nested API object for routing - self.api = APIEndpoints(stats_getter, send_advert_func, self.config, event_loop, daemon_instance, config_path) - + self.api = APIEndpoints( + stats_getter, send_advert_func, self.config, event_loop, daemon_instance, config_path + ) + # Create doc endpoint for API documentation self.doc = DocEndpoint(self.api) @@ -134,7 +143,7 @@ class StatsApp: """Serve the Vue.js application index.html.""" index_path = os.path.join(self.html_dir, "index.html") try: - with open(index_path, 'r', encoding='utf-8') as f: + with open(index_path, "r", encoding="utf-8") as f: return f.read() except FileNotFoundError: raise cherrypy.HTTPError(404, "Application not found. Please build the frontend first.") @@ -148,19 +157,18 @@ class StatsApp: # Handle OPTIONS requests for any path if cherrypy.request.method == "OPTIONS": return "" - + # Let API routes pass through - if args and args[0] == 'api': + if args and args[0] == "api": raise cherrypy.NotFound() - + # Handle WebSocket routes - if args and len(args) >= 2 and args[0] == 'ws' and args[1] == 'packets': + if args and len(args) >= 2 and args[0] == "ws" and args[1] == "packets": # WebSocket tool will intercept this return "" - + # For all other routes, serve the Vue.js app (client-side routing) return self.index() - class HTTPStatsServer: @@ -183,20 +191,29 @@ class HTTPStatsServer: self.port = port self.config = config or {} self.config_path = config_path - + # Initialize authentication handlers self._init_auth_handlers() - + self.app = StatsApp( - stats_getter, node_name, pub_key, send_advert_func, config, event_loop, daemon_instance, config_path + stats_getter, + node_name, + pub_key, + send_advert_func, + config, + event_loop, + daemon_instance, + config_path, ) - + # Create auth endpoints (APIEndpoints has the config_manager) - self.auth_app = AuthEndpoints(self.config, self.jwt_handler, self.token_manager, self.app.api.config_manager) - + self.auth_app = AuthEndpoints( + self.config, self.jwt_handler, self.token_manager, self.app.api.config_manager + ) + # Create documentation endpoints as separate app self.doc_app = DocEndpoint(self.app.api) - + # Set up CORS at the server level if enabled self._cors_enabled = self.config.get("web", {}).get("cors_enabled", False) logger.info(f"CORS enabled: {self._cors_enabled}") @@ -207,43 +224,46 @@ class HTTPStatsServer: repeater_config = self.config.get("repeater", {}) security_config = repeater_config.get("security", {}) jwt_secret = security_config.get("jwt_secret", "") - + if not jwt_secret: # Auto-generate JWT secret jwt_secret = secrets.token_hex(32) - logger.warning("No JWT secret found in config, auto-generated one. Please save this to config.yaml:") - + logger.warning( + "No JWT secret found in config, auto-generated one. Please save this to config.yaml:" + ) + # Try to save to config if config_path is available if self.config_path: try: import yaml - with open(self.config_path, 'r') as f: + + with open(self.config_path, "r") as f: config_data = yaml.safe_load(f) or {} - - if 'repeater' not in config_data: - config_data['repeater'] = {} - if 'security' not in config_data['repeater']: - config_data['repeater']['security'] = {} - config_data['repeater']['security']['jwt_secret'] = jwt_secret - - with open(self.config_path, 'w') as f: + + if "repeater" not in config_data: + config_data["repeater"] = {} + if "security" not in config_data["repeater"]: + config_data["repeater"]["security"] = {} + config_data["repeater"]["security"]["jwt_secret"] = jwt_secret + + with open(self.config_path, "w") as f: yaml.dump(config_data, f, default_flow_style=False) - + logger.info(f"Saved auto-generated JWT secret to {self.config_path}") except Exception as e: logger.error(f"Failed to save JWT secret to config: {e}") - + # Initialize JWT handler with configurable expiry (default 1 hour) jwt_expiry_minutes = security_config.get("jwt_expiry_minutes", 60) self.jwt_handler = JWTHandler(jwt_secret, expiry_minutes=jwt_expiry_minutes) logger.info(f"JWT handler initialized (token expiry: {jwt_expiry_minutes} minutes)") - + # Initialize API token manager storage_dir = self.config.get("storage", {}).get("storage_dir", ".") - + # Ensure storage directory exists os.makedirs(storage_dir, exist_ok=True) - + # Initialize SQLiteHandler and APITokenManager self.sqlite_handler = SQLiteHandler(Path(storage_dir)) self.token_manager = APITokenManager(self.sqlite_handler, jwt_secret) @@ -254,29 +274,25 @@ class HTTPStatsServer: # Configure CORS to allow Authorization header # cherrypy-cors will handle preflight requests automatically cherrypy_cors.install() - + logger.info("CORS support enabled with Authorization header") - + def _json_error_handler(self, status, message, traceback, version): """Return JSON error responses instead of HTML for API endpoints""" cherrypy.response.headers["Content-Type"] = "application/json" - return json.dumps({ - "success": False, - "error": message - }) + return json.dumps({"success": False, "error": message}) def start(self): try: - + if self._cors_enabled: self._setup_server_cors() - default_html_dir = os.path.join(os.path.dirname(__file__), "html") web_path = self.config.get("web", {}).get("web_path") html_dir = web_path if web_path is not None else default_html_dir - + assets_dir = os.path.join(html_dir, "assets") next_dir = os.path.join(html_dir, "_next") @@ -288,11 +304,11 @@ class HTTPStatsServer: # "tools.gzip.mime_types": ["application/json", "text/html", "text/plain"], # Ensure proper content types for static files "tools.staticfile.content_types": { - 'js': 'application/javascript', - 'css': 'text/css', - 'html': 'text/html; charset=utf-8', - 'svg': 'image/svg+xml', - 'txt': 'text/plain' + "js": "application/javascript", + "css": "text/css", + "html": "text/html; charset=utf-8", + "svg": "image/svg+xml", + "txt": "text/plain", }, }, # Require authentication for all /api endpoints @@ -330,7 +346,7 @@ class HTTPStatsServer: "tools.staticfile.filename": os.path.join(html_dir, "favicon.ico"), }, } - + # Add WebSocket configuration to main config if available if WEBSOCKET_AVAILABLE: try: @@ -340,33 +356,34 @@ class HTTPStatsServer: "tools.websocket.handler_cls": PacketWebSocket, "tools.trailing_slash.on": False, "tools.require_auth.on": False, - "tools.gzip.on": False, + "tools.gzip.on": False, } logger.info("WebSocket endpoint configured at /ws/packets") except Exception as e: logger.error(f"Failed to initialize WebSocket: {e}") import traceback + logger.error(traceback.format_exc()) - + # Add CORS configuration if enabled if self._cors_enabled: cors_config = { "cors.expose.on": True, "tools.response_headers.on": True, "tools.response_headers.headers": [ - ('Access-Control-Allow-Origin', '*'), - ('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'), - ('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'), - ('Access-Control-Allow-Credentials', 'true'), + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"), + ("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key"), + ("Access-Control-Allow-Credentials", "true"), ], # Disable automatic trailing slash redirects to prevent CORS issues "tools.trailing_slash.on": False, } - + # Apply CORS to paths config["/"].update(cors_config) config["/api"].update(cors_config) - + # Add Vue.js assets support only if assets directory exists if os.path.isdir(assets_dir): config["/assets"] = { @@ -374,12 +391,12 @@ class HTTPStatsServer: "tools.staticdir.dir": assets_dir, # Set proper content types for assets "tools.staticdir.content_types": { - 'js': 'application/javascript', - 'css': 'text/css', - 'map': 'application/json' + "js": "application/javascript", + "css": "text/css", + "map": "application/json", }, } - + # Add Next.js support only if _next directory exists if os.path.isdir(next_dir): config["/_next"] = { @@ -387,9 +404,9 @@ class HTTPStatsServer: "tools.staticdir.dir": next_dir, # Set proper content types for Next.js assets "tools.staticdir.content_types": { - 'js': 'application/javascript', - 'css': 'text/css', - 'map': 'application/json' + "js": "application/javascript", + "css": "text/css", + "map": "application/json", }, } @@ -421,13 +438,13 @@ class HTTPStatsServer: # Mount main app cherrypy.tree.mount(self.app, "/", config) - + # Mount auth endpoints auth_config = { "/": { "tools.response_headers.on": True, "tools.response_headers.headers": [ - ('Content-Type', 'application/json'), + ("Content-Type", "application/json"), ], # Disable automatic trailing slash redirects "tools.trailing_slash.on": False, @@ -436,42 +453,48 @@ class HTTPStatsServer: if self._cors_enabled: auth_config["/"]["cors.expose.on"] = True # Add CORS headers for OPTIONS requests - auth_config["/"]["tools.response_headers.headers"].extend([ - ('Access-Control-Allow-Origin', '*'), - ('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'), - ('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'), - ('Access-Control-Allow-Credentials', 'true'), - ]) - + auth_config["/"]["tools.response_headers.headers"].extend( + [ + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"), + ("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key"), + ("Access-Control-Allow-Credentials", "true"), + ] + ) + cherrypy.tree.mount(self.auth_app, "/auth", auth_config) - + # Mount documentation endpoints as separate app (no auth required for docs) doc_config = { "/": { "tools.require_auth.on": False, # Docs are publicly accessible "tools.response_headers.on": True, "tools.response_headers.headers": [ - ('Content-Type', 'text/html; charset=utf-8'), + ("Content-Type", "text/html; charset=utf-8"), ], "tools.trailing_slash.on": False, } } if self._cors_enabled: doc_config["/"]["cors.expose.on"] = True - doc_config["/"]["tools.response_headers.headers"].extend([ - ('Access-Control-Allow-Origin', '*'), - ('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'), - ('Access-Control-Allow-Headers', 'Authorization, Content-Type, X-API-Key'), - ]) - + doc_config["/"]["tools.response_headers.headers"].extend( + [ + ("Access-Control-Allow-Origin", "*"), + ("Access-Control-Allow-Methods", "GET, POST, OPTIONS"), + ("Access-Control-Allow-Headers", "Authorization, Content-Type, X-API-Key"), + ] + ) + cherrypy.tree.mount(self.doc_app, "/doc", doc_config) - + # Store auth handlers in cherrypy config for middleware access - cherrypy.config.update({ - "jwt_handler": self.jwt_handler, - "token_manager": self.token_manager, - "security_config": self.config.get("security", {}), - }) + cherrypy.config.update( + { + "jwt_handler": self.jwt_handler, + "token_manager": self.token_manager, + "security_config": self.config.get("security", {}), + } + ) # Completely disable access logging cherrypy.log.access_log.propagate = False diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index 2f32422..9cfdc0e 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -3,7 +3,7 @@ info: title: pyMC Repeater API description: | REST API for pyMC Repeater - LoRa mesh network repeater with room server functionality. - + ## Features - System statistics and monitoring - Packet history and analysis @@ -726,7 +726,7 @@ paths: summary: Get RRD time-series data description: | Retrieve Round-Robin Database metrics for graphing. - + **Note:** This endpoint extracts parameters from the request internally. Parameters are handled automatically by the backend. responses: @@ -821,7 +821,7 @@ paths: summary: Get system metrics graph data description: | Returns time-series data for system metrics like packet counts, RSSI, SNR, etc. - + Available metrics: - rx_count: Received packets - tx_count: Transmitted packets @@ -1557,7 +1557,7 @@ paths: summary: Get ACL information for all identities description: | Get ACL configuration and statistics for all registered identities. - + Returns information including: - Identity name, type, and hash - Max clients allowed @@ -1741,7 +1741,7 @@ paths: summary: Get room messages description: | Retrieve messages from a room with pagination. - + **Max Messages Per Room**: 32 (hard limit) - Older messages auto-deleted every 10 minutes - Cannot be increased beyond 32 @@ -1847,15 +1847,15 @@ paths: summary: Post message to room description: | Add a new message to a room server. Message will be distributed to all synced clients. - + **Special author values:** - `"server"` or `"system"` - System message, goes to ALL clients (API only) - Any hex string - Normal message, NOT sent to that client - + **Security:** - Radio messages cannot use server key (blocked) - API messages can use server key (for announcements) - + **Rate Limits:** - 10 messages/minute per author_pubkey - 160 bytes max message length @@ -1992,7 +1992,7 @@ paths: summary: Get room statistics description: | Get detailed statistics for one or all room servers. - + **Room Limits:** - 32 messages maximum per room (hard limit) - Messages auto-expire every 10 minutes @@ -2101,7 +2101,7 @@ paths: summary: Get room clients description: | List all clients synced to a room with their status. - + **Client Filtering:** - Clients only receive messages where author_pubkey ≠ client_pubkey - unsynced_count shows pending messages for each client @@ -2335,7 +2335,7 @@ components: type: boolean description: Client is currently active (synced within timeout period) example: true - + Identity: type: object required: [name, type, hash, public_key] @@ -2385,7 +2385,7 @@ components: default: 32 description: Maximum messages to keep (room_server only, hard limit 32) example: 32 - + ACLClient: type: object required: [public_key, public_key_full, address, permissions] diff --git a/scripts/build-prod.sh b/scripts/build-prod.sh index 896b9ca..0a67fd3 100755 --- a/scripts/build-prod.sh +++ b/scripts/build-prod.sh @@ -152,7 +152,7 @@ if [ -n "$DEB_FILE" ]; then # Run lintian to check package quality log_step "Running lintian checks..." lintian "$DEB_FILE" || log_warn "Lintian found some issues (non-fatal)" - + log_info "" log_info "════════════════════════════════════════════════════════════" log_info "Production build complete!" diff --git a/scripts/setup-build-env.sh b/scripts/setup-build-env.sh index 168f8e7..5c41552 100755 --- a/scripts/setup-build-env.sh +++ b/scripts/setup-build-env.sh @@ -23,7 +23,7 @@ log_error() { } # Check if running as root or with sudo -if [ "$EUID" -ne 0 ]; then +if [ "$EUID" -ne 0 ]; then log_error "This script must be run with sudo or as root" exit 1 fi diff --git a/setup-radio-config.sh b/setup-radio-config.sh index f85d8bc..2f5f3a8 100644 --- a/setup-radio-config.sh +++ b/setup-radio-config.sh @@ -124,13 +124,13 @@ API_RESPONSE=$(curl -s --max-time 5 https://api.meshcore.nz/api/v1/config 2>/dev if [ -z "$API_RESPONSE" ]; then echo "Warning: Failed to fetch configuration from API (timeout or error)" echo "Using local radio presets file..." - + LOCAL_PRESETS="$SCRIPT_DIR/radio-presets.json" if [ ! -f "$LOCAL_PRESETS" ]; then echo "Error: Local radio presets file not found at $LOCAL_PRESETS" exit 1 fi - + API_RESPONSE=$(cat "$LOCAL_PRESETS") if [ -z "$API_RESPONSE" ]; then echo "Error: Failed to read local radio presets file" @@ -291,7 +291,7 @@ else [ -n "$irq_pin" ] && sed "${SED_OPTS[@]}" "s/^ irq_pin:.*/ irq_pin: $irq_pin/" "$CONFIG_FILE" [ -n "$txen_pin" ] && sed "${SED_OPTS[@]}" "s/^ txen_pin:.*/ txen_pin: $txen_pin/" "$CONFIG_FILE" [ -n "$rxen_pin" ] && sed "${SED_OPTS[@]}" "s/^ rxen_pin:.*/ rxen_pin: $rxen_pin/" "$CONFIG_FILE" - + # Handle LED pins - add if missing, update if present if [ -n "$txled_pin" ]; then if grep -q "^ txled_pin:" "$CONFIG_FILE"; then @@ -301,7 +301,7 @@ else sed "${SED_OPTS[@]}" "/^ rxen_pin:.*/a\\ txled_pin: $txled_pin" "$CONFIG_FILE" fi fi - + if [ -n "$rxled_pin" ]; then if grep -q "^ rxled_pin:" "$CONFIG_FILE"; then sed "${SED_OPTS[@]}" "s/^ rxled_pin:.*/ rxled_pin: $rxled_pin/" "$CONFIG_FILE" @@ -310,7 +310,7 @@ else sed "${SED_OPTS[@]}" "/^ txled_pin:.*/a\\ rxled_pin: $rxled_pin" "$CONFIG_FILE" fi fi - + [ -n "$tx_power" ] && sed "${SED_OPTS[@]}" "s/^ tx_power:.*/ tx_power: $tx_power/" "$CONFIG_FILE" [ -n "$preamble_length" ] && sed "${SED_OPTS[@]}" "s/^ preamble_length:.*/ preamble_length: $preamble_length/" "$CONFIG_FILE" From 65164fffb7a4bf7874cf954a589308c5027428d6 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 17 Feb 2026 21:32:11 -0800 Subject: [PATCH 13/29] Improve retransmission logic and duty cycle handling in RepeaterHandler - Improved local transmission handling by deferring local TX when duty cycle limits are exceeded, instead of dropping packets. - Added LBT metadata extraction and logging for better monitoring of transmission attempts and delays. - Refactored `schedule_retransmit` to support retrying local transmissions on failure, enhancing reliability. - Introduced a lock in PacketRouter to serialize local TX operations, preventing race conditions during packet processing. --- repeater/engine.py | 123 ++++++++++++++++++++++++++++---------- repeater/main.py | 29 +++++---- repeater/packet_router.py | 13 +++- 3 files changed, 118 insertions(+), 47 deletions(-) diff --git a/repeater/engine.py b/repeater/engine.py index 08cade9..8d891cd 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -164,28 +164,69 @@ class RepeaterHandler(BaseHandler): can_tx, wait_time = self.airtime_mgr.can_transmit(airtime_ms) + # LBT metadata (set after any TX path that awaits send) + tx_metadata = None + lbt_attempts = 0 + lbt_backoff_delays_ms = None + lbt_channel_busy = False + if not can_tx: - logger.warning( - f"Duty-cycle limit exceeded. Airtime={airtime_ms:.1f}ms, " - f"wait={wait_time:.1f}s before retry" - ) - self.dropped_count += 1 - drop_reason = "Duty cycle limit" + if local_transmission: + # Defer local TX until duty cycle allows instead of dropping + deferred_delay = delay + wait_time + logger.info( + f"Duty-cycle limit: deferring local TX by {wait_time:.1f}s " + f"(airtime={airtime_ms:.1f}ms)" + ) + self.forwarded_count += 1 + transmitted = True + tx_task = await self.schedule_retransmit( + fwd_pkt, deferred_delay, airtime_ms, local_transmission=True + ) + try: + await tx_task + except Exception as e: + self.forwarded_count -= 1 + transmitted = False + drop_reason = "TX failed (deferred)" + logger.warning(f"Deferred local TX failed: {e}") + raise + tx_metadata = getattr(fwd_pkt, "_tx_metadata", None) + if tx_metadata: + lbt_attempts = tx_metadata.get("lbt_attempts", 0) + lbt_backoff_delays_ms = tx_metadata.get( + "lbt_backoff_delays_ms", [] + ) + lbt_channel_busy = tx_metadata.get("lbt_channel_busy", False) + if lbt_attempts > 0: + total_lbt_delay = sum(lbt_backoff_delays_ms) + logger.info( + f"LBT: {lbt_attempts} attempts, " + f"{total_lbt_delay:.0f}ms delay, " + f"backoffs={lbt_backoff_delays_ms}" + ) + else: + logger.warning( + f"Duty-cycle limit exceeded. Airtime={airtime_ms:.1f}ms, " + f"wait={wait_time:.1f}s before retry" + ) + self.dropped_count += 1 + drop_reason = "Duty cycle limit" else: self.forwarded_count += 1 transmitted = True - # Schedule retransmit with delay (returns task) - tx_task = await self.schedule_retransmit(fwd_pkt, delay, airtime_ms) - - # Wait for transmission to complete to get LBT metadata - await tx_task - - # Extract LBT metadata after transmission + tx_task = await self.schedule_retransmit( + fwd_pkt, delay, airtime_ms, local_transmission=local_transmission + ) + try: + await tx_task + except Exception as e: + self.forwarded_count -= 1 + transmitted = False + drop_reason = "TX failed" + logger.warning(f"Local TX failed: {e}") + raise tx_metadata = getattr(fwd_pkt, "_tx_metadata", None) - lbt_attempts = 0 - lbt_backoff_delays_ms = None - lbt_channel_busy = False - if tx_metadata: lbt_attempts = tx_metadata.get("lbt_attempts", 0) lbt_backoff_delays_ms = tx_metadata.get("lbt_backoff_delays_ms", []) @@ -672,23 +713,43 @@ class RepeaterHandler(BaseHandler): packet.drop_reason = f"Unknown route type: {route_type}" return None - async def schedule_retransmit(self, fwd_pkt: Packet, delay: float, airtime_ms: float = 0.0): - """Schedule a packet retransmission with delay and return the task.""" + async def schedule_retransmit( + self, + fwd_pkt: Packet, + delay: float, + airtime_ms: float = 0.0, + local_transmission: bool = False, + ): + """Schedule a packet retransmission with delay and return the task. + + If local_transmission is True and the first send fails, retry once after + a short delay (handles transient radio/LBT failures). + """ async def delayed_send(): await asyncio.sleep(delay) - try: - await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False) - - # Record airtime after successful TX - if airtime_ms > 0: - self.airtime_mgr.record_tx(airtime_ms) - packet_size = fwd_pkt.get_raw_length() - logger.info( - f"Retransmitted packet ({packet_size} bytes, {airtime_ms:.1f}ms airtime)" - ) - except Exception as e: - logger.error(f"Retransmit failed: {e}") + last_error = None + for attempt in range(2 if local_transmission else 1): + try: + await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False) + if airtime_ms > 0: + self.airtime_mgr.record_tx(airtime_ms) + packet_size = fwd_pkt.get_raw_length() + logger.info( + f"Retransmitted packet ({packet_size} bytes, " + f"{airtime_ms:.1f}ms airtime)" + ) + return + except Exception as e: + last_error = e + logger.error(f"Retransmit failed: {e}") + if local_transmission and attempt == 0: + logger.info("Retrying local TX in 1s...") + await asyncio.sleep(1.0) + else: + raise + if last_error is not None: + raise last_error return asyncio.create_task(delayed_send()) diff --git a/repeater/main.py b/repeater/main.py index 00538ae..800cb63 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -429,21 +429,24 @@ class RepeaterDaemon: records.append(d) bridge.contacts.load_from_dicts(records) - # Load channels from SQLite + # Load channels from SQLite (normalize secret to 32 bytes to match + # CompanionBase.set_channel and GroupTextHandler/PacketBuilder) channel_rows = sqlite_handler.companion_load_channels(companion_hash_str) for row in channel_rows: - ch = Channel( - name=row.get("name", ""), - secret=( - row.get("secret", b"") - if isinstance(row.get("secret"), bytes) - else ( - bytes.fromhex(row.get("secret", "")) - if row.get("secret") - else b"" - ) - ), - ) + s = row.get("secret", b"") + if isinstance(s, bytes): + raw = s + elif isinstance(s, (bytearray, memoryview)): + raw = bytes(s) + elif s: + raw = bytes.fromhex(s if isinstance(s, str) else str(s)) + else: + raw = b"" + if len(raw) < 32: + raw = raw + b"\x00" * (32 - len(raw)) + elif len(raw) > 32: + raw = raw[:32] + ch = Channel(name=row.get("name", ""), secret=raw) bridge.channels.set(row.get("channel_idx", 0), ch) # Preload queued messages from SQLite into bridge diff --git a/repeater/packet_router.py b/repeater/packet_router.py index 8a8e878..48ac218 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -23,7 +23,9 @@ class PacketRouter: self.queue = asyncio.Queue() self.running = False self.router_task = None - + # Serialize injects so one local TX completes before the next is processed + self._inject_lock = asyncio.Lock() + async def start(self): self.running = True self.router_task = asyncio.create_task(self._process_queue()) @@ -51,8 +53,13 @@ class PacketRouter: "timestamp": getattr(packet, "timestamp", 0), } - # Use local_transmission=True to bypass forwarding logic - await self.daemon.repeater_handler(packet, metadata, local_transmission=True) + # Serialize injects so one local TX completes before the next runs + # (avoids duty-cycle or dispatcher races where a later packet goes out first) + async with self._inject_lock: + # Use local_transmission=True to bypass forwarding logic + await self.daemon.repeater_handler( + packet, metadata, local_transmission=True + ) # Enqueue so router can deliver to companion(s): TXT_MSG -> dest bridge, ACK -> all bridges (sender sees ACK) await self.enqueue(packet) From e14fd3feeac242a63c6c4cef2a39ff905d82fa22 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 21 Feb 2026 08:19:00 -0800 Subject: [PATCH 14/29] Refactor noise floor retrieval in RepeaterHandler to use asyncio executor - Updated the noise floor retrieval method to run in an executor, preventing blocking of the event loop during the KISS modem's command execution. - This change enhances responsiveness by allowing the process to handle other tasks while waiting for the noise floor measurement. --- repeater/engine.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/repeater/engine.py b/repeater/engine.py index 8d891cd..47cf667 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -874,7 +874,10 @@ class RepeaterHandler(BaseHandler): return try: - noise_floor = self.get_noise_floor() + # Run in executor so KISS modem's blocking _send_command (up to 5s timeout) + # does not block the event loop and hang the process / delay Ctrl+C. + loop = asyncio.get_running_loop() + noise_floor = await loop.run_in_executor(None, self.get_noise_floor) if noise_floor is not None: self.storage.record_noise_floor(noise_floor) logger.debug(f"Recorded noise floor: {noise_floor} dBm") From f1a944222fdb6b8a9a1688263f35a1168a81246b Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 21 Feb 2026 21:19:22 -0800 Subject: [PATCH 15/29] Update login, path and req response types. - Simplified KISS modem setup instructions in README.md by removing unnecessary details. - Refactored ConfigManager in config_manager.py to improve code clarity and efficiency, including changes to the save_to_file method and live_update_daemon method. - Updated logging and error handling for better debugging and maintenance. - Adjusted method signatures for consistency and clarity across the ConfigManager class. - Modified device_version in frame_server.py to use FIRMWARE_VER_CODE from pyMC_core for better version management. - Enhanced login.py and protocol_request.py with additional payload type handling and logging improvements. - Cleaned up auth_endpoints.py for better readability and consistency in response formatting. --- README.md | 7 +- repeater/companion/frame_server.py | 2 +- repeater/config_manager.py | 145 +++-- repeater/handler_helpers/login.py | 10 +- repeater/handler_helpers/protocol_request.py | 1 + repeater/web/auth_endpoints.py | 531 ++++++++++--------- 6 files changed, 352 insertions(+), 344 deletions(-) diff --git a/README.md b/README.md index 8f2a5a4..3ea3f44 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ The repeater daemon runs continuously as a background process, forwarding LoRa p The repeater supports two radio backends: - **SX1262 (SPI)** — Direct connection to LoRa modules (HATs, etc.) as listed below. -- **KISS modem** — Serial TNC using the KISS protocol. Requires a pyMC_core build with KISS support (e.g. [agessaman/pyMC_core (dev)](https://github.com/agessaman/pyMC_core/tree/dev)). Set `radio_type: kiss` in config and configure `kiss.port` and `kiss.baud_rate`. The setup script (`./setup-radio-config.sh`) offers a "KISS modem" option when configuring the repeater. +- **KISS modem** — Serial TNC using the KISS protocol. Set `radio_type: kiss` in config and configure `kiss.port` and `kiss.baud_rate`. The following SX1262 hardware is currently supported out-of-the-box: @@ -164,11 +164,6 @@ http://:8000 pip install -e . ``` -On **macOS** (or when using only the KISS modem), the base install is enough. On **Raspberry Pi** with SX1262 hardware, install with the optional hardware extra so SPI/spidev is available: -```bash -pip install -e .[hardware] -``` - ## Configuration The configuration file is created and configured during installation at: diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index ba051f1..a019702 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -43,7 +43,7 @@ class CompanionFrameServer(_BaseFrameServer): port=port, bind_address=bind_address, device_model="pyMC-Repeater-Companion", - device_version="1.0.0", + device_version=None, # use FIRMWARE_VER_CODE from pyMC_core build_date="13 Feb 2026", local_hash=local_hash, stats_getter=stats_getter, diff --git a/repeater/config_manager.py b/repeater/config_manager.py index a30003c..b4c13f8 100644 --- a/repeater/config_manager.py +++ b/repeater/config_manager.py @@ -1,21 +1,18 @@ -from __future__ import annotations - import logging import os -from typing import Any, Dict, List, Optional - import yaml +from typing import Optional, Dict, Any, List logger = logging.getLogger("ConfigManager") class ConfigManager: """Manages configuration persistence and live updates to the daemon.""" - + def __init__(self, config_path: str, config: dict, daemon_instance=None): """ Initialize ConfigManager. - + Args: config_path: Path to the YAML config file config: Reference to the config dictionary @@ -24,105 +21,100 @@ class ConfigManager: self.config_path = config_path self.config = config self.daemon = daemon_instance - - def save_to_file(self) -> tuple[bool, str]: + + def save_to_file(self) -> bool: """ Save current config to YAML file. - + Returns: - (True, "") if successful, (False, error_message) otherwise + True if successful, False otherwise """ try: - dirpath = os.path.dirname(self.config_path) - if dirpath: - os.makedirs(dirpath, exist_ok=True) - with open(self.config_path, "w") as f: + os.makedirs(os.path.dirname(self.config_path), exist_ok=True) + with open(self.config_path, 'w') as f: # Use safe_dump with explicit width to prevent line wrapping # Setting width to a very large number prevents truncation of long strings like identity keys yaml.safe_dump( - self.config, - f, - default_flow_style=False, - indent=2, + self.config, + f, + default_flow_style=False, + indent=2, width=1000000, # Very large width to prevent any line wrapping sort_keys=False, - allow_unicode=True, + allow_unicode=True ) logger.info(f"Configuration saved to {self.config_path}") - return True, "" + return True except Exception as e: - msg = f"Failed to save config to {self.config_path}: {e}" - logger.error(msg, exc_info=True) - return False, str(e) - + logger.error(f"Failed to save config to {self.config_path}: {e}", exc_info=True) + return False + def live_update_daemon(self, sections: Optional[List[str]] = None) -> bool: """ Apply configuration changes to the running daemon's in-memory config. - + Args: sections: List of config sections to update (e.g., ['repeater', 'delays']). If None, updates all common sections. - + Returns: True if live update was successful, False otherwise """ - if not self.daemon or not hasattr(self.daemon, "config"): + if not self.daemon or not hasattr(self.daemon, 'config'): logger.warning("Daemon not available for live update") return False - + try: daemon_config = self.daemon.config - + # Default sections to update if not specified if sections is None: - sections = ["repeater", "delays", "radio", "acl", "identities"] - + sections = ['repeater', 'delays', 'radio', 'acl', 'identities'] + # Update each section for section in sections: if section in self.config: if section not in daemon_config: daemon_config[section] = {} - + # Deep copy the section to avoid reference issues if isinstance(self.config[section], dict): daemon_config[section].update(self.config[section]) else: daemon_config[section] = self.config[section] - + logger.debug(f"Live updated daemon config section: {section}") - + logger.info(f"Live updated daemon config sections: {', '.join(sections)}") - + # Also reload runtime config in RepeaterHandler if delays or repeater sections changed - if self.daemon and hasattr(self.daemon, "repeater_handler"): - if any(s in ["delays", "repeater"] for s in sections): - if hasattr(self.daemon.repeater_handler, "reload_runtime_config"): + if self.daemon and hasattr(self.daemon, 'repeater_handler'): + if any(s in ['delays', 'repeater'] for s in sections): + if hasattr(self.daemon.repeater_handler, 'reload_runtime_config'): self.daemon.repeater_handler.reload_runtime_config() logger.info("Reloaded RepeaterHandler runtime config") - + return True - + except Exception as e: logger.error(f"Failed to live update daemon config: {e}", exc_info=True) return False - - def update_and_save( - self, - updates: Dict[str, Any], - live_update: bool = True, - live_update_sections: Optional[List[str]] = None, - ) -> Dict[str, Any]: + + def update_and_save(self, + updates: Dict[str, Any], + live_update: bool = True, + live_update_sections: Optional[List[str]] = None) -> Dict[str, Any]: """ Apply updates to config, save to file, and optionally live update daemon. - + This is the main method that should be used by both mesh_cli and api_endpoints. - + Args: updates: Dictionary of config updates in nested format. Example: {"repeater": {"node_name": "NewName"}, "delays": {"tx_delay_factor": 1.5}} live_update: Whether to apply changes to running daemon immediately live_update_sections: Specific sections to live update. If None, auto-detects from updates. - + Returns: Dict with keys: - success: bool - Whether operation succeeded @@ -130,59 +122,62 @@ class ConfigManager: - live_updated: bool - Whether daemon was live updated - error: str (optional) - Error message if failed """ - result = {"success": False, "saved": False, "live_updated": False} - + result = { + "success": False, + "saved": False, + "live_updated": False + } + try: # Apply updates to config for section, values in updates.items(): if section not in self.config: self.config[section] = {} - + if isinstance(values, dict): self.config[section].update(values) else: self.config[section] = values - + # Save to file - saved, err = self.save_to_file() - result["saved"] = saved - + result["saved"] = self.save_to_file() + if not result["saved"]: - result["error"] = err or "Failed to save config to file" + result["error"] = "Failed to save config to file" return result - + # Live update daemon if requested if live_update: # Auto-detect sections if not specified if live_update_sections is None: live_update_sections = list(updates.keys()) - + result["live_updated"] = self.live_update_daemon(live_update_sections) - + result["success"] = result["saved"] return result - + except Exception as e: logger.error(f"Error in update_and_save: {e}", exc_info=True) result["error"] = str(e) return result - + def update_nested(self, path: str, value: Any, live_update: bool = True) -> Dict[str, Any]: """ Update a nested config value using dot notation. - + Convenience method for simple updates like "repeater.node_name" = "NewName" - + Args: path: Dot-separated path to config value (e.g., "repeater.node_name") value: Value to set live_update: Whether to apply changes to running daemon - + Returns: Result dict from update_and_save """ - parts = path.split(".") - + parts = path.split('.') + if len(parts) == 1: # Top-level key updates = {parts[0]: value} @@ -201,26 +196,26 @@ class ConfigManager: current[part] = {} current = current[part] current[parts[-1]] = value - + # Determine which section to live update section = parts[0] - + return self.update_and_save( updates=updates, live_update=live_update, - live_update_sections=[section] if live_update else None, + live_update_sections=[section] if live_update else None ) - + def get_status(self) -> Dict[str, Any]: """ Get status information about the ConfigManager. - + Returns: Dict with config file path, existence, daemon availability """ return { "config_path": self.config_path, "config_exists": os.path.exists(self.config_path), - "daemon_available": self.daemon is not None and hasattr(self.daemon, "config"), - "config_sections": list(self.config.keys()) if self.config else [], + "daemon_available": self.daemon is not None and hasattr(self.daemon, 'config'), + "config_sections": list(self.config.keys()) if self.config else [] } diff --git a/repeater/handler_helpers/login.py b/repeater/handler_helpers/login.py index 5912866..3e9924b 100644 --- a/repeater/handler_helpers/login.py +++ b/repeater/handler_helpers/login.py @@ -8,6 +8,7 @@ import asyncio import logging from pymc_core.node.handlers.login_server import LoginServerHandler +from pymc_core.protocol.constants import PAYLOAD_TYPE_ANON_REQ logger = logging.getLogger("LoginHelper") @@ -125,9 +126,12 @@ class LoginHelper: packet.mark_do_not_retransmit() return True else: - logger.debug( - f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward" - ) + # ANON_REQ to other nodes (e.g. owner-info to firmware) is normal; skip log to avoid spam + ptype = getattr(packet, "get_payload_type", lambda: None)() + if ptype != PAYLOAD_TYPE_ANON_REQ: + logger.debug( + f"No login handler registered for hash 0x{dest_hash:02X}, allowing forward" + ) return False except Exception as e: diff --git a/repeater/handler_helpers/protocol_request.py b/repeater/handler_helpers/protocol_request.py index 5490a65..ac42f69 100644 --- a/repeater/handler_helpers/protocol_request.py +++ b/repeater/handler_helpers/protocol_request.py @@ -12,6 +12,7 @@ import time from pymc_core.node.handlers.protocol_request import ( REQ_TYPE_GET_ACCESS_LIST, REQ_TYPE_GET_NEIGHBOURS, + REQ_TYPE_GET_OWNER_INFO, REQ_TYPE_GET_STATUS, REQ_TYPE_GET_TELEMETRY_DATA, SERVER_RESPONSE_DELAY_MS, diff --git a/repeater/web/auth_endpoints.py b/repeater/web/auth_endpoints.py index 4f9e1ed..2ceb572 100644 --- a/repeater/web/auth_endpoints.py +++ b/repeater/web/auth_endpoints.py @@ -1,11 +1,8 @@ """ Authentication endpoints for login and token management """ - -import logging - import cherrypy - +import logging from .auth.middleware import require_auth logger = logging.getLogger(__name__) @@ -27,101 +24,123 @@ class TokensAPIEndpoint: @require_auth def index(self): # Handle CORS preflight - if cherrypy.request.method == "OPTIONS": + if cherrypy.request.method == 'OPTIONS': return {} - + # Get token manager from cherrypy config - token_manager = cherrypy.config.get("token_manager") + token_manager = cherrypy.config.get('token_manager') if not token_manager: cherrypy.response.status = 500 - return {"success": False, "error": "Token manager not available"} - - if cherrypy.request.method == "GET": + return {'success': False, 'error': 'Token manager not available'} + + if cherrypy.request.method == 'GET': try: tokens = token_manager.list_tokens() - return {"success": True, "tokens": tokens} + return { + 'success': True, + 'tokens': tokens + } except Exception as e: logger.error(f"Token list error: {e}") cherrypy.response.status = 500 - return {"success": False, "error": "Failed to list tokens"} - - elif cherrypy.request.method == "POST": + return { + 'success': False, + 'error': 'Failed to list tokens' + } + + elif cherrypy.request.method == 'POST': try: import json - - body = cherrypy.request.body.read().decode("utf-8") + body = cherrypy.request.body.read().decode('utf-8') data = json.loads(body) if body else {} - name = data.get("name", "").strip() - + name = data.get('name', '').strip() + if not name: cherrypy.response.status = 400 - return {"success": False, "error": "Token name is required"} - + return { + 'success': False, + 'error': 'Token name is required' + } + # Create the token token_id, plaintext_token = token_manager.create_token(name) - - logger.info( - f"Generated API token '{name}' (ID: {token_id}) by user {cherrypy.request.user['username']}" - ) - + + logger.info(f"Generated API token '{name}' (ID: {token_id}) by user {cherrypy.request.user['username']}") + return { - "success": True, - "token": plaintext_token, - "token_id": token_id, - "name": name, - "warning": "Save this token securely - it will not be shown again", + 'success': True, + 'token': plaintext_token, + 'token_id': token_id, + 'name': name, + 'warning': 'Save this token securely - it will not be shown again' } - + except Exception as e: logger.error(f"Token generation error: {e}") cherrypy.response.status = 500 - return {"success": False, "error": "Failed to generate token"} + return { + 'success': False, + 'error': 'Failed to generate token' + } else: raise cherrypy.HTTPError(405, "Method not allowed") - + @cherrypy.expose @cherrypy.tools.json_out() @require_auth def default(self, token_id=None): # Handle CORS preflight - if cherrypy.request.method == "OPTIONS": + if cherrypy.request.method == 'OPTIONS': return {} - + # Get token manager from cherrypy config - token_manager = cherrypy.config.get("token_manager") + token_manager = cherrypy.config.get('token_manager') if not token_manager: cherrypy.response.status = 500 - return {"success": False, "error": "Token manager not available"} - - if cherrypy.request.method == "DELETE": + return {'success': False, 'error': 'Token manager not available'} + + if cherrypy.request.method == 'DELETE': try: if not token_id: cherrypy.response.status = 400 - return {"success": False, "error": "Token ID is required"} - + return { + 'success': False, + 'error': 'Token ID is required' + } + # Convert to int try: token_id_int = int(token_id) except ValueError: cherrypy.response.status = 400 - return {"success": False, "error": "Invalid token ID"} - + return { + 'success': False, + 'error': 'Invalid token ID' + } + # Revoke the token success = token_manager.revoke_token(token_id_int) - + if success: - logger.info( - f"Revoked API token ID {token_id_int} by user {cherrypy.request.user['username']}" - ) - return {"success": True, "message": "Token revoked successfully"} + logger.info(f"Revoked API token ID {token_id_int} by user {cherrypy.request.user['username']}") + return { + 'success': True, + 'message': 'Token revoked successfully' + } else: cherrypy.response.status = 404 - return {"success": False, "error": "Token not found"} - + return { + 'success': False, + 'error': 'Token not found' + } + except Exception as e: logger.error(f"Token revocation error: {e}") cherrypy.response.status = 500 - return {"success": False, "error": "Failed to revoke token"} + return { + 'success': False, + 'error': 'Failed to revoke token' + } else: raise cherrypy.HTTPError(405, "Method not allowed") @@ -137,314 +156,308 @@ class AuthEndpoints: @cherrypy.expose def login(self, **kwargs): - cherrypy.response.headers["Content-Type"] = "application/json" - + cherrypy.response.headers['Content-Type'] = 'application/json' + # Handle CORS preflight - if cherrypy.request.method == "OPTIONS": - cherrypy.response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" - cherrypy.response.headers["Access-Control-Allow-Headers"] = ( - "Content-Type, Authorization, X-API-Key" - ) - return b"" - - if cherrypy.request.method != "POST": + if cherrypy.request.method == 'OPTIONS': + cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' + cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' + return b'' + + if cherrypy.request.method != 'POST': raise cherrypy.HTTPError(405, "Method not allowed") - + try: # Parse JSON body manually since we can't use json_in decorator with OPTIONS import json - - body = cherrypy.request.body.read().decode("utf-8") + body = cherrypy.request.body.read().decode('utf-8') data = json.loads(body) if body else {} - - username = data.get("username", "").strip() - password = data.get("password", "") - client_id = data.get("client_id", "").strip() - + + username = data.get('username', '').strip() + password = data.get('password', '') + client_id = data.get('client_id', '').strip() + if not username or not password or not client_id: - return json.dumps( - { - "success": False, - "error": "Missing required fields: username, password, client_id", - } - ).encode("utf-8") - + return json.dumps({ + 'success': False, + 'error': 'Missing required fields: username, password, client_id' + }).encode('utf-8') + # Validate credentials against config # Check if username is 'admin' and password matches config - repeater_config = self.config.get("repeater", {}) - security_config = repeater_config.get("security", {}) - config_password = security_config.get("admin_password", "") - + repeater_config = self.config.get('repeater', {}) + security_config = repeater_config.get('security', {}) + config_password = security_config.get('admin_password', '') + # Don't allow login with empty or unconfigured password if not config_password: logger.warning(f"Login attempt rejected - password not configured") - return json.dumps( - { - "success": False, - "error": "System not configured. Please complete setup wizard.", - } - ).encode("utf-8") - - if username == "admin" and password == config_password: + return json.dumps({ + 'success': False, + 'error': 'System not configured. Please complete setup wizard.' + }).encode('utf-8') + + if username == 'admin' and password == config_password: # Create JWT token token = self.jwt_handler.create_jwt(username, client_id) - - logger.info( - f"Successful login for user '{username}' from client '{client_id[:8]}...'" - ) - - return json.dumps( - { - "success": True, - "token": token, - "expires_in": self.jwt_handler.expiry_minutes * 60, - "username": username, - } - ).encode("utf-8") + + logger.info(f"Successful login for user '{username}' from client '{client_id[:8]}...'") + + return json.dumps({ + 'success': True, + 'token': token, + 'expires_in': self.jwt_handler.expiry_minutes * 60, + 'username': username + }).encode('utf-8') else: logger.warning(f"Failed login attempt for user '{username}'") - + # Don't reveal which part was wrong - return json.dumps( - {"success": False, "error": "Invalid username or password"} - ).encode("utf-8") - + return json.dumps({ + 'success': False, + 'error': 'Invalid username or password' + }).encode('utf-8') + except Exception as e: logger.error(f"Login error: {e}") - return json.dumps({"success": False, "error": "Internal server error"}).encode("utf-8") - + return json.dumps({ + 'success': False, + 'error': 'Internal server error' + }).encode('utf-8') + @cherrypy.expose @cherrypy.tools.json_out() @require_auth def verify(self): - if cherrypy.request.method != "GET": + if cherrypy.request.method != 'GET': raise cherrypy.HTTPError(405, "Method not allowed") - - return {"success": True, "authenticated": True, "user": cherrypy.request.user} - + + return { + 'success': True, + 'authenticated': True, + 'user': cherrypy.request.user + } + @cherrypy.expose def refresh(self, **kwargs): - cherrypy.response.headers["Content-Type"] = "application/json" - + cherrypy.response.headers['Content-Type'] = 'application/json' + # Handle CORS preflight - if cherrypy.request.method == "OPTIONS": - cherrypy.response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" - cherrypy.response.headers["Access-Control-Allow-Headers"] = ( - "Content-Type, Authorization, X-API-Key" - ) - return b"" - - if cherrypy.request.method != "POST": + if cherrypy.request.method == 'OPTIONS': + cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' + cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' + return b'' + + if cherrypy.request.method != 'POST': raise cherrypy.HTTPError(405, "Method not allowed") - + try: import json - + # Manual authentication check (can't use @require_auth since we need to handle OPTIONS) - auth_header = cherrypy.request.headers.get("Authorization", "") - api_key = cherrypy.request.headers.get("X-API-Key", "") - - jwt_handler = cherrypy.config.get("jwt_handler") - token_manager = cherrypy.config.get("token_manager") - + auth_header = cherrypy.request.headers.get('Authorization', '') + api_key = cherrypy.request.headers.get('X-API-Key', '') + + jwt_handler = cherrypy.config.get('jwt_handler') + token_manager = cherrypy.config.get('token_manager') + user_info = None - + # Check JWT first - if auth_header.startswith("Bearer "): + if auth_header.startswith('Bearer '): token = auth_header[7:] payload = jwt_handler.verify_jwt(token) if payload: user_info = { - "username": payload["sub"], - "client_id": payload.get("client_id"), - "auth_method": "jwt", + 'username': payload['sub'], + 'client_id': payload.get('client_id'), + 'auth_method': 'jwt' } - + # Check API token if not user_info and api_key: token_data = token_manager.verify_token(api_key) if token_data: user_info = { - "username": "admin", - "token_id": token_data["id"], - "auth_method": "api_token", + 'username': 'admin', + 'token_id': token_data['id'], + 'auth_method': 'api_token' } - + if not user_info: - return json.dumps( - {"success": False, "error": "Unauthorized - Valid JWT or API token required"} - ).encode("utf-8") - + return json.dumps({ + 'success': False, + 'error': 'Unauthorized - Valid JWT or API token required' + }).encode('utf-8') + # Parse request body - body = cherrypy.request.body.read().decode("utf-8") + body = cherrypy.request.body.read().decode('utf-8') data = json.loads(body) if body else {} - - client_id = data.get("client_id", user_info.get("client_id", "")).strip() - + + client_id = data.get('client_id', user_info.get('client_id', '')).strip() + if not client_id: - return json.dumps({"success": False, "error": "Client ID is required"}).encode( - "utf-8" - ) - + return json.dumps({ + 'success': False, + 'error': 'Client ID is required' + }).encode('utf-8') + # Create new JWT token (refreshes expiry time) - new_token = self.jwt_handler.create_jwt(user_info["username"], client_id) - - logger.info( - f"Token refreshed for user '{user_info['username']}' from client '{client_id[:8]}...'" - ) - - return json.dumps( - { - "success": True, - "token": new_token, - "expires_in": self.jwt_handler.expiry_minutes * 60, - "username": user_info["username"], - } - ).encode("utf-8") - + new_token = self.jwt_handler.create_jwt(user_info['username'], client_id) + + logger.info(f"Token refreshed for user '{user_info['username']}' from client '{client_id[:8]}...'") + + return json.dumps({ + 'success': True, + 'token': new_token, + 'expires_in': self.jwt_handler.expiry_minutes * 60, + 'username': user_info['username'] + }).encode('utf-8') + except Exception as e: logger.error(f"Token refresh error: {e}") - return json.dumps({"success": False, "error": "Failed to refresh token"}).encode( - "utf-8" - ) - + return json.dumps({ + 'success': False, + 'error': 'Failed to refresh token' + }).encode('utf-8') + @cherrypy.expose def change_password(self): import json - - cherrypy.response.headers["Content-Type"] = "application/json" - + + cherrypy.response.headers['Content-Type'] = 'application/json' + # Handle CORS preflight - if cherrypy.request.method == "OPTIONS": - cherrypy.response.headers["Access-Control-Allow-Methods"] = "POST, OPTIONS" - cherrypy.response.headers["Access-Control-Allow-Headers"] = ( - "Content-Type, Authorization, X-API-Key" - ) - return b"" - - if cherrypy.request.method != "POST": + if cherrypy.request.method == 'OPTIONS': + cherrypy.response.headers['Access-Control-Allow-Methods'] = 'POST, OPTIONS' + cherrypy.response.headers['Access-Control-Allow-Headers'] = 'Content-Type, Authorization, X-API-Key' + return b'' + + if cherrypy.request.method != 'POST': raise cherrypy.HTTPError(405, "Method not allowed") - + # Require authentication for POST # Get auth handlers from global cherrypy config - jwt_handler = cherrypy.config.get("jwt_handler") - token_manager = cherrypy.config.get("token_manager") - + jwt_handler = cherrypy.config.get('jwt_handler') + token_manager = cherrypy.config.get('token_manager') + if not jwt_handler or not token_manager: logger.error("Auth handlers not configured") raise cherrypy.HTTPError(500, "Authentication not configured") - + # Try JWT authentication first - auth_header = cherrypy.request.headers.get("Authorization", "") + auth_header = cherrypy.request.headers.get('Authorization', '') user = None - - if auth_header.startswith("Bearer "): + + if auth_header.startswith('Bearer '): token = auth_header[7:] # Remove 'Bearer ' prefix payload = jwt_handler.verify_jwt(token) - + if payload: user = { - "username": payload["sub"], - "client_id": payload["client_id"], - "auth_type": "jwt", + 'username': payload['sub'], + 'client_id': payload['client_id'], + 'auth_type': 'jwt' } - + # Try API token authentication if JWT failed if not user: - api_key = cherrypy.request.headers.get("X-API-Key", "") + api_key = cherrypy.request.headers.get('X-API-Key', '') if api_key: token_info = token_manager.verify_token(api_key) - + if token_info: user = { - "username": "api_token", - "token_name": token_info["name"], - "token_id": token_info["id"], - "auth_type": "api_token", + 'username': 'api_token', + 'token_name': token_info['name'], + 'token_id': token_info['id'], + 'auth_type': 'api_token' } - + if not user: cherrypy.response.status = 401 - return json.dumps( - {"success": False, "error": "Unauthorized - Valid JWT or API token required"} - ).encode("utf-8") - + return json.dumps({ + 'success': False, + 'error': 'Unauthorized - Valid JWT or API token required' + }).encode('utf-8') + try: # Parse JSON body manually - body = cherrypy.request.body.read().decode("utf-8") + body = cherrypy.request.body.read().decode('utf-8') data = json.loads(body) if body else {} - - current_password = data.get("current_password", "") - new_password = data.get("new_password", "") - + + current_password = data.get('current_password', '') + new_password = data.get('new_password', '') + if not current_password or not new_password: cherrypy.response.status = 400 - return json.dumps( - { - "success": False, - "error": "Both current_password and new_password are required", - } - ).encode("utf-8") - + return json.dumps({ + 'success': False, + 'error': 'Both current_password and new_password are required' + }).encode('utf-8') + # Validate new password strength if len(new_password) < 8: cherrypy.response.status = 400 - return json.dumps( - {"success": False, "error": "New password must be at least 8 characters long"} - ).encode("utf-8") - + return json.dumps({ + 'success': False, + 'error': 'New password must be at least 8 characters long' + }).encode('utf-8') + # Verify current password - repeater_config = self.config.get("repeater", {}) - security_config = repeater_config.get("security", {}) - config_password = security_config.get("admin_password", "") - + repeater_config = self.config.get('repeater', {}) + security_config = repeater_config.get('security', {}) + config_password = security_config.get('admin_password', '') + if not config_password: cherrypy.response.status = 500 - return json.dumps({"success": False, "error": "System configuration error"}).encode( - "utf-8" - ) - + return json.dumps({ + 'success': False, + 'error': 'System configuration error' + }).encode('utf-8') + if current_password != config_password: cherrypy.response.status = 401 - return json.dumps( - {"success": False, "error": "Current password is incorrect"} - ).encode("utf-8") - + return json.dumps({ + 'success': False, + 'error': 'Current password is incorrect' + }).encode('utf-8') + # Update password in config - if "repeater" not in self.config: - self.config["repeater"] = {} - if "security" not in self.config["repeater"]: - self.config["repeater"]["security"] = {} - - self.config["repeater"]["security"]["admin_password"] = new_password - + if 'repeater' not in self.config: + self.config['repeater'] = {} + if 'security' not in self.config['repeater']: + self.config['repeater']['security'] = {} + + self.config['repeater']['security']['admin_password'] = new_password + # Save to config file using ConfigManager if self.config_manager: - saved, _ = self.config_manager.save_to_file() - if saved: + if self.config_manager.save_to_file(): logger.info(f"Admin password changed successfully by user {user['username']}") - return json.dumps( - { - "success": True, - "message": "Password changed successfully. Please log in again with your new password.", - } - ).encode("utf-8") + return json.dumps({ + 'success': True, + 'message': 'Password changed successfully. Please log in again with your new password.' + }).encode('utf-8') else: cherrypy.response.status = 500 - return json.dumps( - {"success": False, "error": "Failed to save password to config file"} - ).encode("utf-8") + return json.dumps({ + 'success': False, + 'error': 'Failed to save password to config file' + }).encode('utf-8') else: cherrypy.response.status = 500 - return json.dumps( - {"success": False, "error": "Config manager not available"} - ).encode("utf-8") - + return json.dumps({ + 'success': False, + 'error': 'Config manager not available' + }).encode('utf-8') + except Exception as e: logger.error(f"Password change error: {e}") cherrypy.response.status = 500 - return json.dumps({"success": False, "error": "Failed to change password"}).encode( - "utf-8" - ) + return json.dumps({ + 'success': False, + 'error': 'Failed to change password' + }).encode('utf-8') \ No newline at end of file From 27bbaf80ac00d3d662055ce6f48f50b98e3099a9 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 23 Feb 2026 20:04:54 -0800 Subject: [PATCH 16/29] Implement deduplication for companion delivery in PacketRouter - Added logic to ensure that PATH and protocol-response packets are delivered to companions at most once per logical packet, preventing duplicate telemetry delivery. - Introduced a deduplication key generation function and a mechanism to track delivery timestamps. - Updated the enqueue method to utilize the new deduplication logic for companion bridges. - Adjusted timeout for telemetry requests in CompanionAPIEndpoints to improve response handling. --- repeater/packet_router.py | 109 ++++++++++++++++++++++++---- repeater/web/companion_endpoints.py | 5 +- 2 files changed, 100 insertions(+), 14 deletions(-) diff --git a/repeater/packet_router.py b/repeater/packet_router.py index 48ac218..80343d1 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -1,5 +1,6 @@ import asyncio import logging +import time from pymc_core.node.handlers.ack import AckHandler from pymc_core.node.handlers.advert import AdvertHandler @@ -15,6 +16,18 @@ from pymc_core.node.handlers.trace import TraceHandler logger = logging.getLogger("PacketRouter") +# Deliver PATH and protocol-response (PATH) to companion at most once per logical packet +# so the client is not spammed with duplicate telemetry when the mesh delivers multiple copies. +_COMPANION_DEDUPE_TTL_SEC = 60.0 + + +def _companion_dedup_key(packet) -> str | None: + """Return a stable key for companion delivery deduplication, or None if not available.""" + try: + return packet.calculate_packet_hash().hex().upper() + except Exception: + return None + class PacketRouter: @@ -25,6 +38,8 @@ class PacketRouter: self.router_task = None # Serialize injects so one local TX completes before the next is processed self._inject_lock = asyncio.Lock() + # Hash -> expiry time; skip delivering same PATH/protocol-response to companions more than once + self._companion_delivered = {} async def start(self): self.running = True @@ -41,6 +56,19 @@ class PacketRouter: pass logger.info("Packet router stopped") + def _should_deliver_path_to_companions(self, packet) -> bool: + """Return True if this PATH/protocol-response should be delivered to companions (first of duplicates).""" + key = _companion_dedup_key(packet) + if not key: + return True + now = time.time() + # Prune expired + self._companion_delivered = {k: v for k, v in self._companion_delivered.items() if v > now} + if key in self._companion_delivered: + return False + self._companion_delivered[key] = now + _COMPANION_DEDUPE_TTL_SEC + return True + async def enqueue(self, packet): """Add packet to router queue.""" await self.queue.put(packet) @@ -68,6 +96,14 @@ class PacketRouter: logger.debug( f"Injected packet processed by engine as local transmission ({packet_len} bytes)" ) + # Log protocol REQ (e.g. status/telemetry) so we can confirm target node + ptype = getattr(packet, "get_payload_type", lambda: None)() + if ptype == ProtocolRequestHandler.payload_type() and packet.payload and packet_len >= 1: + logger.info( + "Injected protocol REQ: dest=0x%02x, payload=%d bytes", + packet.payload[0], + packet_len, + ) return True except Exception as e: @@ -170,32 +206,79 @@ class PacketRouter: dest_hash = packet.payload[0] if packet.payload else None companion_bridges = getattr(self.daemon, "companion_bridges", {}) if dest_hash is not None and dest_hash in companion_bridges: - await companion_bridges[dest_hash].process_received_packet(packet) + if self._should_deliver_path_to_companions(packet): + await companion_bridges[dest_hash].process_received_packet(packet) processed_by_injection = True elif self.daemon.path_helper: await self.daemon.path_helper.process_path_packet(packet) elif payload_type == LoginResponseHandler.payload_type(): - # PAYLOAD_TYPE_RESPONSE (0x01): login responses from remote repeaters. - # Deliver to all companion bridges so the bridge that initiated the login receives it. + # PAYLOAD_TYPE_RESPONSE (0x01): payload is dest_hash(1)+src_hash(1)+encrypted. + # Deliver to the bridge that is the destination, or to all bridges when the + # response is addressed to this repeater (path-based reply: firmware sends + # to first hop instead of original requester). + dest_hash = packet.payload[0] if packet.payload and len(packet.payload) >= 1 else None companion_bridges = getattr(self.daemon, "companion_bridges", {}) - for bridge in companion_bridges.values(): + local_hash = getattr(self.daemon, "local_hash", None) + if dest_hash is not None and dest_hash in companion_bridges: try: - await bridge.process_received_packet(packet) + await companion_bridges[dest_hash].process_received_packet(packet) + logger.info( + "RESPONSE dest=0x%02x delivered to companion bridge", + dest_hash, + ) except Exception as e: - logger.debug(f"Companion bridge LOGIN_RESPONSE error: {e}") - if companion_bridges: + logger.debug(f"Companion bridge RESPONSE error: {e}") + processed_by_injection = True + elif dest_hash == local_hash and companion_bridges: + # Response addressed to this repeater (e.g. path-based reply to first hop) + for bridge in companion_bridges.values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge RESPONSE error: {e}") + logger.info( + "RESPONSE dest=0x%02x (local) delivered to %d companion bridge(s)", + dest_hash, + len(companion_bridges), + ) + processed_by_injection = True + elif companion_bridges and len(companion_bridges) == 1: + # Single bridge and dest not in bridges: likely ANON_REQ response (dest = ephemeral + # sender hash). Deliver to the only bridge so telemetry/login responses reach the client. + (single_bridge_hash,) = companion_bridges.keys() + try: + await list(companion_bridges.values())[0].process_received_packet(packet) + logger.info( + "RESPONSE dest=0x%02x (anon) delivered to sole companion bridge 0x%02x", + dest_hash or 0, + single_bridge_hash, + ) + except Exception as e: + logger.debug(f"Companion bridge RESPONSE (anon) error: {e}") + processed_by_injection = True + elif companion_bridges: + # Multiple bridges; cannot guess which one. Log and drop. + src_hash = packet.payload[1] if packet.payload and len(packet.payload) >= 2 else None + logger.debug( + "RESPONSE dest=0x%02x src=0x%02x not for us (bridges %s, local=0x%02x)", + dest_hash or 0, + src_hash if src_hash is not None else 0, + [f"0x{h:02x}" for h in companion_bridges], + local_hash if local_hash is not None else 0, + ) processed_by_injection = True elif payload_type == ProtocolResponseHandler.payload_type(): # PAYLOAD_TYPE_PATH (0x08): protocol responses (telemetry, binary, etc.). - # Deliver to all companion bridges (response dest_hash is the client, not the bridge). + # Deliver at most once per logical packet so the client is not spammed with duplicates. companion_bridges = getattr(self.daemon, "companion_bridges", {}) - for bridge in companion_bridges.values(): - try: - await bridge.process_received_packet(packet) - except Exception as e: - logger.debug(f"Companion bridge RESPONSE error: {e}") + if companion_bridges and self._should_deliver_path_to_companions(packet): + for bridge in companion_bridges.values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge RESPONSE error: {e}") if companion_bridges: processed_by_injection = True diff --git a/repeater/web/companion_endpoints.py b/repeater/web/companion_endpoints.py index 24f60da..42dadf4 100644 --- a/repeater/web/companion_endpoints.py +++ b/repeater/web/companion_endpoints.py @@ -439,13 +439,16 @@ class CompanionAPIEndpoints: Body: pub_key, want_base?, want_location?, want_environment?, timeout?, companion_name? + + On success, telemetry_data includes raw_bytes (LPP hex), sensors (parsed), + and frame_bytes (hex): companion-style frame 0x8B + 0 + 6B pubkey prefix + LPP. """ self._require_post() try: body = self._get_json_body() bridge = self._get_bridge(**self._resolve_bridge_params(body)) pub_key = self._pub_key_from_hex(body.get("pub_key", "")) - timeout = float(body.get("timeout", 10.0)) + timeout = float(body.get("timeout", 20.0)) result = self._run_async( bridge.send_telemetry_request( pub_key, From 789a2f27ea231ff535f95f85d91e62e9d3228c26 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 24 Feb 2026 21:51:56 -0800 Subject: [PATCH 17/29] Enhance PacketRouter and CompanionFrameServer for improved packet delivery and contact persistence - Updated PacketRouter to deliver packets to all companion bridges when the destination is not recognized, ensuring better handling of ephemeral destinations. - Refactored CompanionFrameServer to separate contact serialization and persistence logic, allowing for non-blocking database operations. - Introduced a unique index for companion contacts in SQLite to support upsert functionality, enhancing data integrity and performance. - Improved AdvertHelper to run database operations in a separate thread, preventing event loop blocking and maintaining responsiveness. --- repeater/companion/frame_server.py | 76 +++++++++++++-------- repeater/data_acquisition/sqlite_handler.py | 66 ++++++++++++++++++ repeater/handler_helpers/advert.py | 18 +++-- repeater/packet_router.py | 44 ++++++------ 4 files changed, 152 insertions(+), 52 deletions(-) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index a019702..499bbcf 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -83,37 +83,53 @@ class CompanionFrameServer(_BaseFrameServer): path_len=msg_dict.get("path_len", 0), ) - def _save_contacts(self) -> None: - """Persist contacts to SQLite.""" + @staticmethod + def _contact_to_dict(c) -> dict: + """Convert a Contact object to a persistence dict.""" + pk = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key) + return { + "pubkey": pk, + "name": c.name, + "adv_type": c.adv_type, + "flags": c.flags, + "out_path_len": c.out_path_len, + "out_path": ( + c.out_path + if isinstance(c.out_path, bytes) + else (bytes.fromhex(c.out_path) if c.out_path else b"") + ), + "last_advert_timestamp": c.last_advert_timestamp, + "lastmod": c.lastmod, + "gps_lat": c.gps_lat, + "gps_lon": c.gps_lon, + "sync_since": c.sync_since, + } + + async def _persist_contact(self, contact) -> None: + """Upsert a single contact to SQLite (non-blocking).""" + if not self.sqlite_handler: + return + contact_dict = self._contact_to_dict(contact) + await asyncio.to_thread( + self.sqlite_handler.companion_upsert_contact, + self.companion_hash, + contact_dict, + ) + + async def _save_contacts(self) -> None: + """Persist all contacts to SQLite (non-blocking).""" if not self.sqlite_handler: return contacts = self.bridge.get_contacts() - dicts = [] - for c in contacts: - pk = c.public_key if isinstance(c.public_key, bytes) else bytes.fromhex(c.public_key) - dicts.append( - { - "pubkey": pk, - "name": c.name, - "adv_type": c.adv_type, - "flags": c.flags, - "out_path_len": c.out_path_len, - "out_path": ( - c.out_path - if isinstance(c.out_path, bytes) - else (bytes.fromhex(c.out_path) if c.out_path else b"") - ), - "last_advert_timestamp": c.last_advert_timestamp, - "lastmod": c.lastmod, - "gps_lat": c.gps_lat, - "gps_lon": c.gps_lon, - "sync_since": c.sync_since, - } - ) - self.sqlite_handler.companion_save_contacts(self.companion_hash, dicts) + dicts = [self._contact_to_dict(c) for c in contacts] + await asyncio.to_thread( + self.sqlite_handler.companion_save_contacts, + self.companion_hash, + dicts, + ) - def _save_channels(self) -> None: - """Persist channels to SQLite.""" + async def _save_channels(self) -> None: + """Persist channels to SQLite (non-blocking).""" if not self.sqlite_handler: return channels = [] @@ -128,4 +144,8 @@ class CompanionFrameServer(_BaseFrameServer): "secret": ch.secret, } ) - self.sqlite_handler.companion_save_channels(self.companion_hash, channels) + await asyncio.to_thread( + self.sqlite_handler.companion_save_channels, + self.companion_hash, + channels, + ) diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 812dbf6..c006cc1 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -381,6 +381,29 @@ class SQLiteHandler: ) logger.info(f"Migration '{migration_name}' applied successfully") + # Migration 5: Add UNIQUE index on companion_contacts(companion_hash, pubkey) + # Required for ON CONFLICT upsert in companion_upsert_contact. + migration_name = "unique_companion_contacts_pubkey" + existing = conn.execute( + "SELECT migration_name FROM migrations WHERE migration_name = ?", + (migration_name,), + ).fetchone() + + if not existing: + # Replace the non-unique index with a UNIQUE one + conn.execute( + "DROP INDEX IF EXISTS idx_companion_contacts_pubkey" + ) + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_companion_contacts_hash_pubkey " + "ON companion_contacts (companion_hash, pubkey)" + ) + conn.execute( + "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", + (migration_name, time.time()), + ) + logger.info(f"Migration '{migration_name}' applied successfully") + conn.commit() except Exception as e: @@ -1674,6 +1697,49 @@ class SQLiteHandler: logger.error(f"Failed to save companion contacts: {e}") return False + def companion_upsert_contact(self, companion_hash: str, contact: dict) -> bool: + """Insert or update a single contact for a companion in storage.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + now = time.time() + conn.execute( + """ + INSERT INTO companion_contacts + (companion_hash, pubkey, name, adv_type, flags, out_path_len, out_path, + last_advert_timestamp, lastmod, gps_lat, gps_lon, sync_since, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(companion_hash, pubkey) + DO UPDATE SET + name=excluded.name, adv_type=excluded.adv_type, + flags=excluded.flags, out_path_len=excluded.out_path_len, + out_path=excluded.out_path, + last_advert_timestamp=excluded.last_advert_timestamp, + lastmod=excluded.lastmod, gps_lat=excluded.gps_lat, + gps_lon=excluded.gps_lon, sync_since=excluded.sync_since, + updated_at=excluded.updated_at + """, + ( + companion_hash, + contact.get("pubkey", b""), + contact.get("name", ""), + contact.get("adv_type", 0), + contact.get("flags", 0), + contact.get("out_path_len", -1), + contact.get("out_path", b""), + contact.get("last_advert_timestamp", 0), + contact.get("lastmod", 0), + contact.get("gps_lat", 0.0), + contact.get("gps_lon", 0.0), + contact.get("sync_since", 0), + now, + ), + ) + conn.commit() + return True + except Exception as e: + logger.error(f"Failed to upsert companion contact: {e}") + return False + def companion_load_channels(self, companion_hash: str) -> List[Dict]: """Load channels for a companion from storage.""" try: diff --git a/repeater/handler_helpers/advert.py b/repeater/handler_helpers/advert.py index 74354d5..3a6fc91 100644 --- a/repeater/handler_helpers/advert.py +++ b/repeater/handler_helpers/advert.py @@ -4,6 +4,7 @@ Advertisement packet handling helper for pyMC Repeater. This module processes advertisement packets for neighbor tracking and discovery. """ +import asyncio import logging import time @@ -76,11 +77,16 @@ class AdvertHelper: route_type = packet.header & PH_ROUTE_MASK - # Check if this is a new neighbor + # Check if this is a new neighbor (run DB read in thread to avoid blocking event loop) current_time = time.time() if pubkey not in self._known_neighbors: # Only check database if not in cache - current_neighbors = self.storage.get_neighbors() if self.storage else {} + if self.storage: + current_neighbors = await asyncio.to_thread( + self.storage.get_neighbors + ) + else: + current_neighbors = {} is_new_neighbor = pubkey not in current_neighbors if is_new_neighbor: @@ -110,10 +116,14 @@ class AdvertHelper: "zero_hop": zero_hop, } - # Store to database + # Store to database (run in thread so event loop stays responsive; + # blocking here can cause companion TCP clients to disconnect) if self.storage: try: - self.storage.record_advert(advert_record) + await asyncio.to_thread( + self.storage.record_advert, + advert_record, + ) except Exception as e: logger.error(f"Failed to store advert record: {e}") diff --git a/repeater/packet_router.py b/repeater/packet_router.py index 80343d1..843fc44 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -209,6 +209,20 @@ class PacketRouter: if self._should_deliver_path_to_companions(packet): await companion_bridges[dest_hash].process_received_packet(packet) processed_by_injection = True + elif companion_bridges and self._should_deliver_path_to_companions(packet): + # Dest not in bridges: path-return with ephemeral dest (e.g. multi-hop login). + # Deliver to all bridges; each will try to decrypt and ignore if not relevant. + for bridge in companion_bridges.values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge PATH error: {e}") + logger.debug( + "PATH dest=0x%02x (anon) delivered to %d bridge(s) for matching", + dest_hash or 0, + len(companion_bridges), + ) + processed_by_injection = True elif self.daemon.path_helper: await self.daemon.path_helper.process_path_packet(packet) @@ -243,29 +257,19 @@ class PacketRouter: len(companion_bridges), ) processed_by_injection = True - elif companion_bridges and len(companion_bridges) == 1: - # Single bridge and dest not in bridges: likely ANON_REQ response (dest = ephemeral - # sender hash). Deliver to the only bridge so telemetry/login responses reach the client. - (single_bridge_hash,) = companion_bridges.keys() - try: - await list(companion_bridges.values())[0].process_received_packet(packet) - logger.info( - "RESPONSE dest=0x%02x (anon) delivered to sole companion bridge 0x%02x", - dest_hash or 0, - single_bridge_hash, - ) - except Exception as e: - logger.debug(f"Companion bridge RESPONSE (anon) error: {e}") - processed_by_injection = True elif companion_bridges: - # Multiple bridges; cannot guess which one. Log and drop. - src_hash = packet.payload[1] if packet.payload and len(packet.payload) >= 2 else None + # Dest not in bridges and not local: likely ANON_REQ response (dest = ephemeral + # sender hash). Deliver to all bridges; each will try to decrypt and ignore if + # not relevant (firmware-like behavior, works with multiple companion bridges). + for bridge in companion_bridges.values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge RESPONSE error: {e}") logger.debug( - "RESPONSE dest=0x%02x src=0x%02x not for us (bridges %s, local=0x%02x)", + "RESPONSE dest=0x%02x (anon) delivered to %d bridge(s) for matching", dest_hash or 0, - src_hash if src_hash is not None else 0, - [f"0x{h:02x}" for h in companion_bridges], - local_hash if local_hash is not None else 0, + len(companion_bridges), ) processed_by_injection = True From c78133f1759035a663e317bee2429405dba10b38 Mon Sep 17 00:00:00 2001 From: Adam Gessaman Date: Wed, 25 Feb 2026 15:39:18 -0800 Subject: [PATCH 18/29] Refactor CompanionFrameServer for non-blocking message synchronization - Changed the _get_companion_stats method to be asynchronous, improving responsiveness. - Added a new async method _cmd_sync_next_message to handle message synchronization without blocking the event loop, enhancing performance during message processing. - Introduced constants for response codes to streamline message handling in the CompanionFrameServer. --- repeater/companion/frame_server.py | 73 ++++++++++++++++++++++++++++++ repeater/main.py | 2 +- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index 499bbcf..63cea6e 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -10,8 +10,16 @@ from __future__ import annotations import asyncio import logging +import struct from typing import Optional +from pymc_core.companion.constants import ( + RESP_CODE_CHANNEL_MSG_RECV, + RESP_CODE_CHANNEL_MSG_RECV_V3, + RESP_CODE_CONTACT_MSG_RECV, + RESP_CODE_CONTACT_MSG_RECV_V3, + RESP_CODE_NO_MORE_MESSAGES, +) from pymc_core.companion.frame_server import CompanionFrameServer as _BaseFrameServer from pymc_core.companion.models import QueuedMessage @@ -83,6 +91,71 @@ class CompanionFrameServer(_BaseFrameServer): path_len=msg_dict.get("path_len", 0), ) + # ----------------------------------------------------------------- + # Non-blocking command overrides (keep event loop responsive) + # ----------------------------------------------------------------- + + async def _cmd_sync_next_message(self, data: bytes) -> None: + """Sync next message; run persistence read in thread so SQLite does not block.""" + msg = self.bridge.sync_next_message() + if msg is None: + msg = await asyncio.to_thread(self._sync_next_from_persistence) + if msg is None: + self._write_frame(bytes([RESP_CODE_NO_MORE_MESSAGES])) + return + if msg.is_channel: + path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF + txt_type = 0 + text_bytes = (msg.text or "").rstrip("\x00").encode("utf-8", errors="replace") + if self._app_target_ver >= 3: + frame = ( + bytes( + [ + RESP_CODE_CHANNEL_MSG_RECV_V3, + 0, + 0, + 0, + msg.channel_idx, + path_len_byte, + txt_type, + ] + ) + + struct.pack("= 6 else msg.sender_key.ljust(6, b"\x00") + ) + path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF + text_bytes = msg.text.encode("utf-8", errors="replace") + if self._app_target_ver >= 3: + frame = ( + bytes([RESP_CODE_CONTACT_MSG_RECV_V3, 0, 0, 0]) + + prefix + + bytes([path_len_byte, msg.txt_type]) + + struct.pack(" dict: """Convert a Contact object to a persistence dict.""" diff --git a/repeater/main.py b/repeater/main.py index a4a87d7..bd15856 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -651,7 +651,7 @@ class RepeaterDaemon: return stats - def _get_companion_stats(self, stats_type: int) -> dict: + async def _get_companion_stats(self, stats_type: int) -> dict: """Return stats dict for companion CMD_GET_STATS (format expected by frame_server + meshcore_py).""" from repeater.companion.constants import ( STATS_TYPE_CORE, From 6fa85a832fffd3a89ef2864435c4a42331dfa336 Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 27 Feb 2026 21:18:29 -0800 Subject: [PATCH 19/29] Update packet type labels in rrdtool_handler and sqlite_handler for consistency - Enhanced readability by adding descriptive suffixes to packet type labels in both rrdtool_handler.py and sqlite_handler.py. - Aligned packet type definitions with the new naming conventions from pyMC_core, ensuring consistency across the codebase. --- repeater/data_acquisition/rrdtool_handler.py | 4 +- repeater/data_acquisition/sqlite_handler.py | 39 +++++- repeater/handler_helpers/advert.py | 131 ------------------- 3 files changed, 35 insertions(+), 139 deletions(-) diff --git a/repeater/data_acquisition/rrdtool_handler.py b/repeater/data_acquisition/rrdtool_handler.py index e89b4ed..be469e2 100644 --- a/repeater/data_acquisition/rrdtool_handler.py +++ b/repeater/data_acquisition/rrdtool_handler.py @@ -220,8 +220,8 @@ class RRDToolHandler: "type_7": "Anonymous Request (ANON_REQ)", "type_8": "Returned Path (PATH)", "type_9": "Trace (TRACE)", - "type_10": "Multi-part Packet", - "type_11": "Control Packet Data", + "type_10": "Multi-part Packet (MULTIPART)", + "type_11": "Control (CONTROL)", "type_12": "Reserved Type 12", "type_13": "Reserved Type 13", "type_14": "Reserved Type 14", diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index c006cc1..63e0ed4 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -817,10 +817,33 @@ class SQLiteHandler: try: cutoff = time.time() - (hours * 3600) - with sqlite3.connect(self.sqlite_path) as conn: - conn.row_factory = sqlite3.Row - - type_counts = {} + # Align with pyMC_core feat/newRadios PAYLOAD_TYPES (0x0B = CONTROL) + try: + from pymc_core.protocol.utils import PAYLOAD_TYPES as _PT + _human = { + "REQ": "Request", + "RESPONSE": "Response", + "TXT_MSG": "Plain Text Message", + "ACK": "Acknowledgment", + "ADVERT": "Node Advertisement", + "GRP_TXT": "Group Text Message", + "GRP_DATA": "Group Datagram", + "ANON_REQ": "Anonymous Request", + "PATH": "Returned Path", + "TRACE": "Trace", + "MULTIPART": "Multi-part Packet", + "CONTROL": "Control", + "RAW_CUSTOM": "Custom Packet", + } + packet_type_names = {} + for i in range(16): + code = _PT.get(i) + if code: + label = _human.get(code, code.replace("_", " ").title()) + packet_type_names[i] = f"{label} ({code})" + else: + packet_type_names[i] = f"Reserved Type {i}" + except ImportError: packet_type_names = { 0: "Request (REQ)", 1: "Response (RESPONSE)", @@ -832,14 +855,18 @@ class SQLiteHandler: 7: "Anonymous Request (ANON_REQ)", 8: "Returned Path (PATH)", 9: "Trace (TRACE)", - 10: "Multi-part Packet", - 11: "Reserved Type 11", + 10: "Multi-part Packet (MULTIPART)", + 11: "Control (CONTROL)", 12: "Reserved Type 12", 13: "Reserved Type 13", 14: "Reserved Type 14", 15: "Custom Packet (RAW_CUSTOM)", } + with sqlite3.connect(self.sqlite_path) as conn: + conn.row_factory = sqlite3.Row + + type_counts = {} for packet_type in range(16): count = conn.execute( "SELECT COUNT(*) FROM packets WHERE type = ? AND timestamp > ?", diff --git a/repeater/handler_helpers/advert.py b/repeater/handler_helpers/advert.py index 3a6fc91..e69de29 100644 --- a/repeater/handler_helpers/advert.py +++ b/repeater/handler_helpers/advert.py @@ -1,131 +0,0 @@ -""" -Advertisement packet handling helper for pyMC Repeater. - -This module processes advertisement packets for neighbor tracking and discovery. -""" - -import asyncio -import logging -import time - -from pymc_core.node.handlers.advert import AdvertHandler - -logger = logging.getLogger("AdvertHelper") - - -class AdvertHelper: - """Helper class for processing advertisement packets in the repeater.""" - - def __init__(self, local_identity, storage, log_fn=None): - """ - Initialize the advert helper. - - Args: - local_identity: The LocalIdentity instance for this repeater - storage: StorageCollector instance for persisting advert data - log_fn: Optional logging function for AdvertHandler - """ - self.local_identity = local_identity - self.storage = storage - - # Create AdvertHandler internally as a parsing utility - self.advert_handler = AdvertHandler(log_fn=log_fn or logger.info) - - # Cache for tracking known neighbors (avoid repeated database queries) - self._known_neighbors = set() - - async def process_advert_packet(self, packet, rssi: int, snr: float) -> None: - """ - Process an incoming advertisement packet. - - This method uses AdvertHandler to parse the packet, then stores - the neighbor information for tracking and discovery. - - Args: - packet: The advertisement packet to process - rssi: Received signal strength indicator - snr: Signal-to-noise ratio - """ - try: - # Set signal metrics on packet for handler to use - packet._snr = snr - packet._rssi = rssi - - # Use AdvertHandler to parse the packet - it now returns parsed data - advert_data = await self.advert_handler(packet) - - if not advert_data or not advert_data.get("valid"): - logger.warning("Invalid advert packet received, dropping.") - packet.mark_do_not_retransmit() - packet.drop_reason = "Invalid advert packet" - return - - # Extract data from parsed advert - pubkey = advert_data["public_key"] - node_name = advert_data["name"] - contact_type = advert_data["contact_type"] - - # Skip our own adverts - if self.local_identity: - local_pubkey = self.local_identity.get_public_key().hex() - if pubkey == local_pubkey: - logger.debug("Ignoring own advert in neighbor tracking") - return - - # Get route type from packet header - from pymc_core.protocol.constants import PH_ROUTE_MASK - - route_type = packet.header & PH_ROUTE_MASK - - # Check if this is a new neighbor (run DB read in thread to avoid blocking event loop) - current_time = time.time() - if pubkey not in self._known_neighbors: - # Only check database if not in cache - if self.storage: - current_neighbors = await asyncio.to_thread( - self.storage.get_neighbors - ) - else: - current_neighbors = {} - is_new_neighbor = pubkey not in current_neighbors - - if is_new_neighbor: - self._known_neighbors.add(pubkey) - logger.info(f"Discovered new neighbor: {node_name} ({pubkey[:16]}...)") - else: - is_new_neighbor = False - - # Determine zero-hop: direct routes are always zero-hop, - # flood routes are zero-hop if path_len <= 1 (received directly) - path_len = len(packet.path) if packet.path else 0 - zero_hop = path_len == 0 - - # Build advert record - advert_record = { - "timestamp": current_time, - "pubkey": pubkey, - "node_name": node_name, - "is_repeater": "REPEATER" in contact_type.upper(), - "route_type": route_type, - "contact_type": contact_type, - "latitude": advert_data["latitude"], - "longitude": advert_data["longitude"], - "rssi": rssi, - "snr": snr, - "is_new_neighbor": is_new_neighbor, - "zero_hop": zero_hop, - } - - # Store to database (run in thread so event loop stays responsive; - # blocking here can cause companion TCP clients to disconnect) - if self.storage: - try: - await asyncio.to_thread( - self.storage.record_advert, - advert_record, - ) - except Exception as e: - logger.error(f"Failed to store advert record: {e}") - - except Exception as e: - logger.error(f"Error processing advert packet: {e}", exc_info=True) From d5fe1e637cb781532e59d81956b7ddc9bd5120ec Mon Sep 17 00:00:00 2001 From: agessaman Date: Fri, 27 Feb 2026 22:18:56 -0800 Subject: [PATCH 20/29] Restore deleted AdvertHelper. --- repeater/handler_helpers/advert.py | 131 +++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/repeater/handler_helpers/advert.py b/repeater/handler_helpers/advert.py index e69de29..3a6fc91 100644 --- a/repeater/handler_helpers/advert.py +++ b/repeater/handler_helpers/advert.py @@ -0,0 +1,131 @@ +""" +Advertisement packet handling helper for pyMC Repeater. + +This module processes advertisement packets for neighbor tracking and discovery. +""" + +import asyncio +import logging +import time + +from pymc_core.node.handlers.advert import AdvertHandler + +logger = logging.getLogger("AdvertHelper") + + +class AdvertHelper: + """Helper class for processing advertisement packets in the repeater.""" + + def __init__(self, local_identity, storage, log_fn=None): + """ + Initialize the advert helper. + + Args: + local_identity: The LocalIdentity instance for this repeater + storage: StorageCollector instance for persisting advert data + log_fn: Optional logging function for AdvertHandler + """ + self.local_identity = local_identity + self.storage = storage + + # Create AdvertHandler internally as a parsing utility + self.advert_handler = AdvertHandler(log_fn=log_fn or logger.info) + + # Cache for tracking known neighbors (avoid repeated database queries) + self._known_neighbors = set() + + async def process_advert_packet(self, packet, rssi: int, snr: float) -> None: + """ + Process an incoming advertisement packet. + + This method uses AdvertHandler to parse the packet, then stores + the neighbor information for tracking and discovery. + + Args: + packet: The advertisement packet to process + rssi: Received signal strength indicator + snr: Signal-to-noise ratio + """ + try: + # Set signal metrics on packet for handler to use + packet._snr = snr + packet._rssi = rssi + + # Use AdvertHandler to parse the packet - it now returns parsed data + advert_data = await self.advert_handler(packet) + + if not advert_data or not advert_data.get("valid"): + logger.warning("Invalid advert packet received, dropping.") + packet.mark_do_not_retransmit() + packet.drop_reason = "Invalid advert packet" + return + + # Extract data from parsed advert + pubkey = advert_data["public_key"] + node_name = advert_data["name"] + contact_type = advert_data["contact_type"] + + # Skip our own adverts + if self.local_identity: + local_pubkey = self.local_identity.get_public_key().hex() + if pubkey == local_pubkey: + logger.debug("Ignoring own advert in neighbor tracking") + return + + # Get route type from packet header + from pymc_core.protocol.constants import PH_ROUTE_MASK + + route_type = packet.header & PH_ROUTE_MASK + + # Check if this is a new neighbor (run DB read in thread to avoid blocking event loop) + current_time = time.time() + if pubkey not in self._known_neighbors: + # Only check database if not in cache + if self.storage: + current_neighbors = await asyncio.to_thread( + self.storage.get_neighbors + ) + else: + current_neighbors = {} + is_new_neighbor = pubkey not in current_neighbors + + if is_new_neighbor: + self._known_neighbors.add(pubkey) + logger.info(f"Discovered new neighbor: {node_name} ({pubkey[:16]}...)") + else: + is_new_neighbor = False + + # Determine zero-hop: direct routes are always zero-hop, + # flood routes are zero-hop if path_len <= 1 (received directly) + path_len = len(packet.path) if packet.path else 0 + zero_hop = path_len == 0 + + # Build advert record + advert_record = { + "timestamp": current_time, + "pubkey": pubkey, + "node_name": node_name, + "is_repeater": "REPEATER" in contact_type.upper(), + "route_type": route_type, + "contact_type": contact_type, + "latitude": advert_data["latitude"], + "longitude": advert_data["longitude"], + "rssi": rssi, + "snr": snr, + "is_new_neighbor": is_new_neighbor, + "zero_hop": zero_hop, + } + + # Store to database (run in thread so event loop stays responsive; + # blocking here can cause companion TCP clients to disconnect) + if self.storage: + try: + await asyncio.to_thread( + self.storage.record_advert, + advert_record, + ) + except Exception as e: + logger.error(f"Failed to store advert record: {e}") + + except Exception as e: + logger.error(f"Error processing advert packet: {e}", exc_info=True) From d82ebc59b0761849f6c394c542e78b8db3e36330 Mon Sep 17 00:00:00 2001 From: agessaman Date: Sat, 28 Feb 2026 09:06:24 -0800 Subject: [PATCH 21/29] Normalize companion_hash format in SQLite migrations to include 0x prefix - Updated the SQLiteHandler to apply a migration that prefixes companion_hash values with '0x' for consistency with room_hash patterns. - Adjusted the main.py file to reflect the new formatting for companion_hash when generating its string representation. --- repeater/data_acquisition/sqlite_handler.py | 19 +++++++++++++++++++ repeater/main.py | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 63e0ed4..f8b1d24 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -404,6 +404,25 @@ class SQLiteHandler: ) logger.info(f"Migration '{migration_name}' applied successfully") + # Migration 6: Normalize companion_hash to 0x-prefixed hex (match room_hash pattern) + migration_name = "companion_hash_0x_prefix" + existing = conn.execute( + "SELECT migration_name FROM migrations WHERE migration_name = ?", + (migration_name,), + ).fetchone() + + if not existing: + for table in ("companion_contacts", "companion_channels", "companion_messages"): + conn.execute( + f"UPDATE {table} SET companion_hash = '0x' || companion_hash " + "WHERE companion_hash NOT LIKE '0x%'" + ) + conn.execute( + "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", + (migration_name, time.time()), + ) + logger.info(f"Migration '{migration_name}' applied successfully") + conn.commit() except Exception as e: diff --git a/repeater/main.py b/repeater/main.py index bd15856..5db5ce8 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -406,7 +406,7 @@ class RepeaterDaemon: identity = LocalIdentity(seed=identity_key_bytes) pubkey = identity.get_public_key() companion_hash = pubkey[0] - companion_hash_str = f"{companion_hash:02x}" + companion_hash_str = f"0x{companion_hash:02x}" node_name = settings.get("node_name", name) tcp_port = settings.get("tcp_port", 5000) From e54d79d7c2c59c57277d829532d1f8100dd28ae3 Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 2 Mar 2026 07:52:36 -0800 Subject: [PATCH 22/29] Refactor message handling in CompanionFrameServer to use a dedicated frame building method - Removed redundant code for building message frames based on message type. - Introduced a new method, _build_message_frame, to streamline the process of constructing message frames, improving code readability and maintainability. --- repeater/companion/frame_server.py | 62 +----------------------------- 1 file changed, 2 insertions(+), 60 deletions(-) diff --git a/repeater/companion/frame_server.py b/repeater/companion/frame_server.py index 63cea6e..4f2da3a 100644 --- a/repeater/companion/frame_server.py +++ b/repeater/companion/frame_server.py @@ -10,16 +10,9 @@ from __future__ import annotations import asyncio import logging -import struct from typing import Optional -from pymc_core.companion.constants import ( - RESP_CODE_CHANNEL_MSG_RECV, - RESP_CODE_CHANNEL_MSG_RECV_V3, - RESP_CODE_CONTACT_MSG_RECV, - RESP_CODE_CONTACT_MSG_RECV_V3, - RESP_CODE_NO_MORE_MESSAGES, -) +from pymc_core.companion.constants import RESP_CODE_NO_MORE_MESSAGES from pymc_core.companion.frame_server import CompanionFrameServer as _BaseFrameServer from pymc_core.companion.models import QueuedMessage @@ -103,58 +96,7 @@ class CompanionFrameServer(_BaseFrameServer): if msg is None: self._write_frame(bytes([RESP_CODE_NO_MORE_MESSAGES])) return - if msg.is_channel: - path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF - txt_type = 0 - text_bytes = (msg.text or "").rstrip("\x00").encode("utf-8", errors="replace") - if self._app_target_ver >= 3: - frame = ( - bytes( - [ - RESP_CODE_CHANNEL_MSG_RECV_V3, - 0, - 0, - 0, - msg.channel_idx, - path_len_byte, - txt_type, - ] - ) - + struct.pack("= 6 else msg.sender_key.ljust(6, b"\x00") - ) - path_len_byte = msg.path_len if msg.path_len < 256 else 0xFF - text_bytes = msg.text.encode("utf-8", errors="replace") - if self._app_target_ver >= 3: - frame = ( - bytes([RESP_CODE_CONTACT_MSG_RECV_V3, 0, 0, 0]) - + prefix - + bytes([path_len_byte, msg.txt_type]) - + struct.pack(" dict: From 4f94b343cce1a803dec56a90f9a494d9b7da1efe Mon Sep 17 00:00:00 2001 From: agessaman Date: Mon, 2 Mar 2026 21:28:15 -0800 Subject: [PATCH 23/29] Modify CompanionBridge integration to support persisting NodePrefs - Added new methods to SQLiteHandler for loading and saving companion preferences as JSON, improving data persistence. - Introduced a migration to create a companion_prefs table for storing preferences, ensuring compatibility with existing data. - Refactored main.py to utilize RepeaterCompanionBridge instead of CompanionBridge, aligning with the new architecture. --- repeater/companion/__init__.py | 2 + repeater/companion/bridge.py | 92 +++++++++++++++++++++ repeater/data_acquisition/sqlite_handler.py | 62 ++++++++++++++ repeater/main.py | 7 +- 4 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 repeater/companion/bridge.py diff --git a/repeater/companion/__init__.py b/repeater/companion/__init__.py index f64af20..f104252 100644 --- a/repeater/companion/__init__.py +++ b/repeater/companion/__init__.py @@ -3,6 +3,7 @@ Exposes the MeshCore companion frame protocol over TCP for standard clients. """ +from .bridge import RepeaterCompanionBridge from .constants import ( CMD_APP_START, CMD_GET_CONTACTS, @@ -17,6 +18,7 @@ from .frame_server import CompanionFrameServer __all__ = [ "CompanionFrameServer", + "RepeaterCompanionBridge", "CMD_APP_START", "CMD_GET_CONTACTS", "CMD_SEND_TXT_MSG", diff --git a/repeater/companion/bridge.py b/repeater/companion/bridge.py new file mode 100644 index 0000000..f24ddb1 --- /dev/null +++ b/repeater/companion/bridge.py @@ -0,0 +1,92 @@ +""" +Repeater CompanionBridge with SQLite-backed preference persistence. + +Persists full NodePrefs as a JSON blob so companion settings (including +auto-add config) survive repeater restarts. Merge-on-load supports +schema evolution when NodePrefs gains or loses fields. +""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Any, Callable, Optional + +from pymc_core.companion import CompanionBridge + +logger = logging.getLogger("RepeaterCompanionBridge") + + +class RepeaterCompanionBridge(CompanionBridge): + """CompanionBridge that persists and loads prefs (full NodePrefs) via SQLite JSON blob.""" + + def __init__( + self, + identity, + packet_injector: Callable[..., Any], + node_name: str = "pyMC", + adv_type: int = 1, + max_contacts: int = 1000, + max_channels: int = 40, + offline_queue_size: int = 512, + radio_config: Optional[dict] = None, + authenticate_callback: Optional[Callable[..., tuple[bool, int]]] = None, + initial_contacts: Optional[Any] = None, + *, + sqlite_handler=None, + companion_hash: str = "", + ) -> None: + self._sqlite_handler = sqlite_handler + self._companion_hash = companion_hash + super().__init__( + identity=identity, + packet_injector=packet_injector, + node_name=node_name, + adv_type=adv_type, + max_contacts=max_contacts, + max_channels=max_channels, + offline_queue_size=offline_queue_size, + radio_config=radio_config, + authenticate_callback=authenticate_callback, + initial_contacts=initial_contacts, + ) + + def _save_prefs(self) -> None: + """Persist full NodePrefs as JSON to SQLite.""" + if not self._sqlite_handler or not self._companion_hash: + return + try: + prefs_dict = dataclasses.asdict(self.prefs) + self._sqlite_handler.companion_save_prefs(self._companion_hash, prefs_dict) + except Exception as e: + logger.warning("Failed to persist companion prefs: %s", e) + + def _load_prefs(self) -> None: + """Load prefs from SQLite JSON and merge into self.prefs (only known keys).""" + if not self._sqlite_handler or not self._companion_hash: + return + try: + stored = self._sqlite_handler.companion_load_prefs(self._companion_hash) + if not stored or not isinstance(stored, dict): + return + for key, value in stored.items(): + if not hasattr(self.prefs, key): + continue + current = getattr(self.prefs, key) + try: + if value is None: + continue + if isinstance(current, bool): + setattr(self.prefs, key, bool(value)) + elif isinstance(current, int): + setattr(self.prefs, key, int(value)) + elif isinstance(current, float): + setattr(self.prefs, key, float(value)) + elif isinstance(current, str): + setattr(self.prefs, key, str(value)) + else: + setattr(self.prefs, key, value) + except (TypeError, ValueError) as e: + logger.debug("Skip prefs key %r: %s", key, e) + except Exception as e: + logger.warning("Failed to load companion prefs: %s", e) diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index f8b1d24..4701252 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -423,6 +423,33 @@ class SQLiteHandler: ) logger.info(f"Migration '{migration_name}' applied successfully") + # Migration 7: Add companion_prefs table (JSON blob for full NodePrefs persistence) + migration_name = "add_companion_prefs" + existing = conn.execute( + "SELECT migration_name FROM migrations WHERE migration_name = ?", + (migration_name,), + ).fetchone() + + if not existing: + cursor = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='companion_prefs'" + ) + if not cursor.fetchone(): + conn.execute( + """ + CREATE TABLE companion_prefs ( + companion_hash TEXT PRIMARY KEY, + prefs_json TEXT NOT NULL + ) + """ + ) + logger.info("Created companion_prefs table") + conn.execute( + "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", + (migration_name, time.time()), + ) + logger.info(f"Migration '{migration_name}' applied successfully") + conn.commit() except Exception as e: @@ -1786,6 +1813,41 @@ class SQLiteHandler: logger.error(f"Failed to upsert companion contact: {e}") return False + def companion_load_prefs(self, companion_hash: str) -> Optional[Dict]: + """Load persisted prefs for a companion. Returns parsed JSON dict or None if no row.""" + try: + with sqlite3.connect(self.sqlite_path) as conn: + cursor = conn.execute( + "SELECT prefs_json FROM companion_prefs WHERE companion_hash = ?", + (companion_hash,), + ) + row = cursor.fetchone() + if row is None: + return None + return json.loads(row[0]) + except Exception as e: + logger.error(f"Failed to load companion prefs: {e}") + return None + + def companion_save_prefs(self, companion_hash: str, prefs: Dict) -> bool: + """Persist prefs for a companion as JSON. Upserts by companion_hash.""" + try: + prefs_json = json.dumps(prefs) + with sqlite3.connect(self.sqlite_path) as conn: + conn.execute( + """ + INSERT INTO companion_prefs (companion_hash, prefs_json) + VALUES (?, ?) + ON CONFLICT(companion_hash) DO UPDATE SET prefs_json = excluded.prefs_json + """, + (companion_hash, prefs_json), + ) + conn.commit() + return True + except Exception as e: + logger.error(f"Failed to save companion prefs: {e}") + return False + def companion_load_channels(self, companion_hash: str) -> List[Dict]: """Load channels for a companion from storage.""" try: diff --git a/repeater/main.py b/repeater/main.py index 5db5ce8..7cacf8f 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -356,10 +356,9 @@ class RepeaterDaemon: async def _load_companion_identities(self) -> None: """Load companion identities from config and create CompanionBridge + frame server for each.""" from pymc_core import LocalIdentity - from pymc_core.companion import CompanionBridge from pymc_core.companion.models import Channel, Contact - from repeater.companion import CompanionFrameServer + from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge companions_config = self.config.get("identities", {}).get("companions") or [] if not companions_config: @@ -412,11 +411,13 @@ class RepeaterDaemon: tcp_port = settings.get("tcp_port", 5000) bind_address = settings.get("bind_address", "0.0.0.0") - bridge = CompanionBridge( + bridge = RepeaterCompanionBridge( identity=identity, packet_injector=self.router.inject_packet, node_name=node_name, radio_config=radio_config, + sqlite_handler=sqlite_handler, + companion_hash=companion_hash_str, ) # Load contacts from SQLite From 1fbd99d52cc280569ce117f4afc981a2dc48d5b7 Mon Sep 17 00:00:00 2001 From: agessaman Date: Tue, 3 Mar 2026 16:20:52 -0800 Subject: [PATCH 24/29] Add JSON serialization support for companion preferences - Introduced a new utility function, _to_json_safe, to ensure companion preferences are JSON-serializable, handling various data types including enums and dataclasses. - Updated the RepeaterCompanionBridge to use the new serialization method when saving preferences to SQLite. - Modified SQLiteHandler to ensure companion_hash is consistently converted to a string before database operations. - Enhanced error handling for preference persistence in the RepeaterCompanionBridge. --- repeater/companion/bridge.py | 23 ++++++++++++++++++++- repeater/data_acquisition/sqlite_handler.py | 3 ++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/repeater/companion/bridge.py b/repeater/companion/bridge.py index f24ddb1..812e77b 100644 --- a/repeater/companion/bridge.py +++ b/repeater/companion/bridge.py @@ -10,6 +10,7 @@ from __future__ import annotations import dataclasses import logging +from enum import Enum from typing import Any, Callable, Optional from pymc_core.companion import CompanionBridge @@ -17,6 +18,23 @@ from pymc_core.companion import CompanionBridge logger = logging.getLogger("RepeaterCompanionBridge") +def _to_json_safe(value: Any) -> Any: + """Convert a value to a JSON-serializable form (avoids TypeError from enums, bytes, etc.).""" + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, Enum): + return value.value + if isinstance(value, bytes): + return value.hex() + if isinstance(value, (list, tuple)): + return [_to_json_safe(v) for v in value] + if isinstance(value, dict): + return {k: _to_json_safe(v) for k, v in value.items()} + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return {f.name: _to_json_safe(getattr(value, f.name)) for f in dataclasses.fields(value)} + return value + + class RepeaterCompanionBridge(CompanionBridge): """CompanionBridge that persists and loads prefs (full NodePrefs) via SQLite JSON blob.""" @@ -57,7 +75,10 @@ class RepeaterCompanionBridge(CompanionBridge): return try: prefs_dict = dataclasses.asdict(self.prefs) - self._sqlite_handler.companion_save_prefs(self._companion_hash, prefs_dict) + prefs_safe = _to_json_safe(prefs_dict) + self._sqlite_handler.companion_save_prefs( + str(self._companion_hash), prefs_safe + ) except Exception as e: logger.warning("Failed to persist companion prefs: %s", e) diff --git a/repeater/data_acquisition/sqlite_handler.py b/repeater/data_acquisition/sqlite_handler.py index 4701252..b8aa4b0 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -1833,6 +1833,7 @@ class SQLiteHandler: """Persist prefs for a companion as JSON. Upserts by companion_hash.""" try: prefs_json = json.dumps(prefs) + key = str(companion_hash) if companion_hash is not None else "" with sqlite3.connect(self.sqlite_path) as conn: conn.execute( """ @@ -1840,7 +1841,7 @@ class SQLiteHandler: VALUES (?, ?) ON CONFLICT(companion_hash) DO UPDATE SET prefs_json = excluded.prefs_json """, - (companion_hash, prefs_json), + (key, prefs_json), ) conn.commit() return True From 0271aa9455208eb5bd3c54d3f8305c0be8442382 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 5 Mar 2026 09:39:11 -0800 Subject: [PATCH 25/29] Add support for multi-byte hashes via local_hash_bytes - Updated the RepeaterHandler constructor to accept local_hash_bytes, improving path handling. - Implemented checks to ensure packet paths do not exceed MAX_PATH_SIZE when appending hash bytes. - Refactored direct_forward method to utilize local_hash_bytes for next hop validation and path manipulation. - Adjusted path length encoding to accommodate changes in path management logic. --- repeater/engine.py | 32 +++++++++++++++++++++++--------- repeater/main.py | 5 ++++- 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/repeater/engine.py b/repeater/engine.py index 47cf667..c3a1a62 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -19,7 +19,7 @@ from pymc_core.protocol.constants import ( ROUTE_TYPE_TRANSPORT_DIRECT, ROUTE_TYPE_TRANSPORT_FLOOD, ) -from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils +from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils, PathUtils from repeater.airtime import AirtimeManager from repeater.data_acquisition import StorageCollector @@ -36,11 +36,12 @@ class RepeaterHandler(BaseHandler): return 0xFF # Special marker (not a real payload type) - def __init__(self, config: dict, dispatcher, local_hash: int, send_advert_func=None): + def __init__(self, config: dict, dispatcher, local_hash: int, *, local_hash_bytes=None, send_advert_func=None): self.config = config self.dispatcher = dispatcher self.local_hash = local_hash + self.local_hash_bytes = local_hash_bytes or bytes([local_hash]) self.send_advert_func = send_advert_func self.airtime_mgr = AirtimeManager(config) self.seen_packets = OrderedDict() @@ -585,8 +586,17 @@ class RepeaterHandler(BaseHandler): elif not isinstance(packet.path, bytearray): packet.path = bytearray(packet.path) - packet.path.append(self.local_hash) - packet.path_len = len(packet.path) + hash_size = packet.get_path_hash_size() + hop_count = packet.get_path_hash_count() + + # Check path won't exceed MAX_PATH_SIZE after append + if (hop_count + 1) * hash_size > MAX_PATH_SIZE: + packet.drop_reason = "Path would exceed MAX_PATH_SIZE" + return None + + # Append hash_size bytes from our public key prefix + packet.path.extend(self.local_hash_bytes[:hash_size]) + packet.path_len = PathUtils.encode_path_len(hash_size, hop_count + 1) self.mark_seen(packet) @@ -594,13 +604,16 @@ class RepeaterHandler(BaseHandler): def direct_forward(self, packet: Packet) -> Optional[Packet]: + hash_size = packet.get_path_hash_size() + hop_count = packet.get_path_hash_count() + # Check if we're the next hop - if not packet.path or len(packet.path) == 0: + if not packet.path or len(packet.path) < hash_size: packet.drop_reason = "Direct: no path" return None - next_hop = packet.path[0] - if next_hop != self.local_hash: + next_hop = bytes(packet.path[:hash_size]) + if next_hop != self.local_hash_bytes[:hash_size]: packet.drop_reason = "Direct: not for us" return None @@ -610,8 +623,9 @@ class RepeaterHandler(BaseHandler): return None original_path = list(packet.path) - packet.path = bytearray(packet.path[1:]) - packet.path_len = len(packet.path) + # Remove first hash entry (hash_size bytes) + packet.path = bytearray(packet.path[hash_size:]) + packet.path_len = PathUtils.encode_path_len(hash_size, hop_count - 1) self.mark_seen(packet) diff --git a/repeater/main.py b/repeater/main.py index 7cacf8f..a6ce18e 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -124,6 +124,7 @@ class RepeaterDaemon: pubkey = local_identity.get_public_key() self.local_hash = pubkey[0] + self.local_hash_bytes = bytes(pubkey[:3]) logger.info(f"Local identity set: {local_identity.get_address_bytes().hex()}") local_hash_hex = f"0x{self.local_hash:02x}" @@ -135,7 +136,9 @@ class RepeaterDaemon: self.dispatcher._is_own_packet = lambda pkt: False self.repeater_handler = RepeaterHandler( - self.config, self.dispatcher, self.local_hash, send_advert_func=self.send_advert + self.config, self.dispatcher, self.local_hash, + local_hash_bytes=self.local_hash_bytes, + send_advert_func=self.send_advert, ) # Create router From b6757a0ca0eef1dee984ef07f43af693ec55489f Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 5 Mar 2026 14:06:43 -0800 Subject: [PATCH 26/29] Refactor path handling in RepeaterHandler to utilize hash-based representations - Replaced list-based path storage with hash-based methods for original and forwarded paths, improving efficiency and consistency. - Updated display logic to format path hashes correctly, ensuring compatibility with new hash size management. - Adjusted local transmission handling to align with the new hash representation, enhancing clarity in packet processing. --- repeater/engine.py | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/repeater/engine.py b/repeater/engine.py index c3a1a62..21ff1f7 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -133,7 +133,8 @@ class RepeaterHandler(BaseHandler): tx_delay_ms = 0.0 drop_reason = None - original_path = list(packet.path) if packet.path else [] + original_path_hashes = packet.get_path_hashes_hex() + path_hash_size = packet.get_path_hash_size() # Process for forwarding (skip if in monitor mode or if this is a local transmission) result = ( @@ -141,7 +142,7 @@ class RepeaterHandler(BaseHandler): if (monitor_mode or local_transmission) else self.process_packet(processed_packet, snr) ) - forwarded_path = None + forwarded_path_hashes = None # For local transmissions, create a direct transmission result if local_transmission and not monitor_mode: @@ -150,15 +151,15 @@ class RepeaterHandler(BaseHandler): # Calculate transmission delay for local packets delay = self._calculate_tx_delay(packet, snr) result = (packet, delay) - forwarded_path = list(packet.path) if packet.path else [] + forwarded_path_hashes = packet.get_path_hashes_hex() logger.debug(f"Local transmission: calculated delay {delay:.3f}s") - + if result: fwd_pkt, delay = result tx_delay_ms = delay * 1000.0 # Capture the forwarded path (after modification) - forwarded_path = list(fwd_pkt.path) if fwd_pkt.path else [] + forwarded_path_hashes = fwd_pkt.get_path_hashes_hex() # Check duty-cycle before scheduling TX airtime_ms = self.airtime_mgr.calculate_airtime(fwd_pkt.get_raw_length()) @@ -273,15 +274,14 @@ class RepeaterHandler(BaseHandler): drop_reason = "Duplicate" path_hash = None - display_path = ( - original_path if original_path else (list(packet.path) if packet.path else []) + display_hashes = ( + original_path_hashes if original_path_hashes else packet.get_path_hashes_hex() ) - if display_path and len(display_path) > 0: - # Format path as array of uppercase hex bytes - path_bytes = [f"{b:02X}" for b in display_path[:8]] # First 8 bytes max - if len(display_path) > 8: - path_bytes.append("...") - path_hash = "[" + ", ".join(path_bytes) + "]" + if display_hashes: + display = display_hashes[:8] + if len(display_hashes) > 8: + display = list(display) + ["..."] + path_hash = "[" + ", ".join(display) + "]" src_hash = None dst_hash = None @@ -327,10 +327,9 @@ class RepeaterHandler(BaseHandler): "path_hash": path_hash, "src_hash": src_hash, "dst_hash": dst_hash, - "original_path": ([f"{b:02X}" for b in original_path] if original_path else None), - "forwarded_path": ( - [f"{b:02X}" for b in forwarded_path] if forwarded_path is not None else None - ), + "original_path": original_path_hashes or None, + "forwarded_path": forwarded_path_hashes, + "path_hash_size": path_hash_size, "raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None, "lbt_attempts": lbt_attempts if transmitted else 0, "lbt_backoff_delays_ms": ( @@ -419,10 +418,11 @@ class RepeaterHandler(BaseHandler): return "Global flood policy disabled" if route_type == ROUTE_TYPE_DIRECT: - if not packet.path or len(packet.path) == 0: + hash_size = packet.get_path_hash_size() + if not packet.path or len(packet.path) < hash_size: return "Direct: no path" - next_hop = packet.path[0] - if next_hop != self.local_hash: + next_hop = bytes(packet.path[:hash_size]) + if next_hop != self.local_hash_bytes[:hash_size]: return "Direct: not for us" # Default reason From a6170f70ede035e8086d8288bca403f9f2f21a2c Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 5 Mar 2026 16:26:50 -0800 Subject: [PATCH 27/29] Add public key to identity details and enhance companion configuration handling - Updated IdentityManager to include public key in identity details when retrieving identities. - Introduced a new method in RepeaterDaemon for adding companions from configuration, supporting hot-reload functionality. - Enhanced error handling for companion registration, ensuring proper validation of identity keys and settings. - Updated API endpoints to include configured companions in the response, improving visibility of companion status and configuration. --- repeater/identity_manager.py | 1 + repeater/main.py | 147 +++++++++ repeater/web/api_endpoints.py | 282 ++++++++++++++---- ...D8RmhurR.js => CADCalibration-Dc9AglAf.js} | 2 +- .../web/html/assets/Companions-X5GdqESj.js | 1 + .../web/html/assets/Configuration-CkZchTBn.js | 2 + .../web/html/assets/Configuration-DRrPdRyZ.js | 2 - ...e_type_script_setup_true_lang-CVxh_fqf.js} | 2 +- .../web/html/assets/Dashboard-BCvxH1o7.css | 1 - .../web/html/assets/Dashboard-BqBlpKE8.js | 2 + .../web/html/assets/Dashboard-CZYwlk3m.css | 1 + .../web/html/assets/Dashboard-g_BZKKVl.js | 2 - .../{Help-DE02Ddt_.js => Help-DWQEtjHZ.js} | 2 +- .../{Login-dBhjapfH.js => Login-BwBtx78C.js} | 2 +- .../{Logs-DYHdAYG8.js => Logs-DXaq6_-G.js} | 2 +- ...ue_type_script_setup_true_lang-DAIhF3Fs.js | 1 + ...rs-IAQBtp4C.css => Neighbors-BPsas1hQ.css} | 2 +- ...bors-CGfNd9X5.js => Neighbors-CXfm_tfh.js} | 12 +- .../web/html/assets/RoomServers-Ba2ogzkk.js | 1 - .../web/html/assets/RoomServers-V3porqGE.js | 1 + ...sions-CkbrSNoL.js => Sessions-C6MSx2tq.js} | 2 +- repeater/web/html/assets/Setup-CLJIlSKT.js | 1 + repeater/web/html/assets/Setup-CSawSnc5.js | 1 - .../web/html/assets/Statistics-BQdSlYZ9.js | 1 - .../web/html/assets/Statistics-C56LjnFt.css | 1 + .../web/html/assets/Statistics-G140biph.css | 1 - .../web/html/assets/Statistics-JA9qMWm0.js | 1 + ...ts-SdfzbcmV.js => SystemStats-DiLdS6K6.js} | 2 +- ...minal-BCiQkOZc.js => Terminal-BAoTtMQy.js} | 2 +- ... chartjs-adapter-date-fns.esm-BYg_FBhT.js} | 2 +- repeater/web/html/assets/index-D-3p9FIW.css | 1 + repeater/web/html/assets/index-Dk6Oh8NN.css | 1 - repeater/web/html/assets/index-DyUIpN7m.js | 35 +++ repeater/web/html/assets/index-sHch0610.js | 35 --- .../web/html/assets/plotly.min-DO11Gp-n.js | 44 +-- ...SFiHBW.js => useSignalQuality-DR_wpBbb.js} | 2 +- repeater/web/html/index.html | 4 +- 37 files changed, 464 insertions(+), 140 deletions(-) rename repeater/web/html/assets/{CADCalibration-D8RmhurR.js => CADCalibration-Dc9AglAf.js} (99%) create mode 100644 repeater/web/html/assets/Companions-X5GdqESj.js create mode 100644 repeater/web/html/assets/Configuration-CkZchTBn.js delete mode 100644 repeater/web/html/assets/Configuration-DRrPdRyZ.js rename repeater/web/html/assets/{ConfirmDialog.vue_vue_type_script_setup_true_lang-CT6z2S3q.js => ConfirmDialog.vue_vue_type_script_setup_true_lang-CVxh_fqf.js} (98%) delete mode 100644 repeater/web/html/assets/Dashboard-BCvxH1o7.css create mode 100644 repeater/web/html/assets/Dashboard-BqBlpKE8.js create mode 100644 repeater/web/html/assets/Dashboard-CZYwlk3m.css delete mode 100644 repeater/web/html/assets/Dashboard-g_BZKKVl.js rename repeater/web/html/assets/{Help-DE02Ddt_.js => Help-DWQEtjHZ.js} (96%) rename repeater/web/html/assets/{Login-dBhjapfH.js => Login-BwBtx78C.js} (99%) rename repeater/web/html/assets/{Logs-DYHdAYG8.js => Logs-DXaq6_-G.js} (99%) create mode 100644 repeater/web/html/assets/MessageDialog.vue_vue_type_script_setup_true_lang-DAIhF3Fs.js rename repeater/web/html/assets/{Neighbors-IAQBtp4C.css => Neighbors-BPsas1hQ.css} (90%) rename repeater/web/html/assets/{Neighbors-CGfNd9X5.js => Neighbors-CXfm_tfh.js} (91%) delete mode 100644 repeater/web/html/assets/RoomServers-Ba2ogzkk.js create mode 100644 repeater/web/html/assets/RoomServers-V3porqGE.js rename repeater/web/html/assets/{Sessions-CkbrSNoL.js => Sessions-C6MSx2tq.js} (99%) create mode 100644 repeater/web/html/assets/Setup-CLJIlSKT.js delete mode 100644 repeater/web/html/assets/Setup-CSawSnc5.js delete mode 100644 repeater/web/html/assets/Statistics-BQdSlYZ9.js create mode 100644 repeater/web/html/assets/Statistics-C56LjnFt.css delete mode 100644 repeater/web/html/assets/Statistics-G140biph.css create mode 100644 repeater/web/html/assets/Statistics-JA9qMWm0.js rename repeater/web/html/assets/{SystemStats-SdfzbcmV.js => SystemStats-DiLdS6K6.js} (99%) rename repeater/web/html/assets/{Terminal-BCiQkOZc.js => Terminal-BAoTtMQy.js} (99%) rename repeater/web/html/assets/{chartjs-adapter-date-fns.esm-BnFZGz19.js => chartjs-adapter-date-fns.esm-BYg_FBhT.js} (99%) create mode 100644 repeater/web/html/assets/index-D-3p9FIW.css delete mode 100644 repeater/web/html/assets/index-Dk6Oh8NN.css create mode 100644 repeater/web/html/assets/index-DyUIpN7m.js delete mode 100644 repeater/web/html/assets/index-sHch0610.js rename repeater/web/html/assets/{useSignalQuality-CGSFiHBW.js => useSignalQuality-DR_wpBbb.js} (93%) diff --git a/repeater/identity_manager.py b/repeater/identity_manager.py index d5dfe6c..7de98b7 100644 --- a/repeater/identity_manager.py +++ b/repeater/identity_manager.py @@ -51,6 +51,7 @@ class IdentityManager: "name": name, "type": id_type, "address": identity.get_address_bytes().hex() if identity else "N/A", + "public_key": identity.get_public_key().hex() if identity else None, } ) return identities diff --git a/repeater/main.py b/repeater/main.py index a6ce18e..16d3982 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -511,6 +511,153 @@ class RepeaterDaemon: except Exception as e: logger.error(f"Failed to load companion '{name}': {e}", exc_info=True) + async def add_companion_from_config(self, comp_config: dict) -> None: + """ + Load a single companion from config and register it (hot-reload). + Creates RepeaterCompanionBridge, CompanionFrameServer, starts the server, + and registers with identity_manager. Raises on error. + """ + from pymc_core import LocalIdentity + from pymc_core.companion.models import Channel + + from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge + from repeater.companion.constants import DEFAULT_PUBLIC_CHANNEL_SECRET + + name = comp_config.get("name") + identity_key = comp_config.get("identity_key") + settings = comp_config.get("settings") or {} + + if not name or not identity_key: + raise ValueError("Companion config missing name or identity_key") + + if isinstance(identity_key, str): + try: + identity_key_bytes = bytes.fromhex(identity_key) + except ValueError as e: + raise ValueError(f"Companion '{name}' identity_key invalid hex: {e}") from e + elif isinstance(identity_key, bytes): + identity_key_bytes = identity_key + else: + raise ValueError(f"Companion '{name}' identity_key has unknown type") + + if len(identity_key_bytes) not in (32, 64): + raise ValueError( + f"Companion '{name}' identity_key must be 32 bytes (hex) or 64 bytes (MeshCore firmware key)" + ) + + # Already registered? + if name in self.identity_manager.named_identities: + raise ValueError(f"Companion '{name}' is already registered") + + identity = LocalIdentity(seed=identity_key_bytes) + pubkey = identity.get_public_key() + companion_hash = pubkey[0] + companion_hash_str = f"0x{companion_hash:02x}" + + if companion_hash in self.companion_bridges: + raise ValueError(f"Companion with hash 0x{companion_hash:02x} already loaded") + + sqlite_handler = None + if self.repeater_handler and self.repeater_handler.storage: + sqlite_handler = self.repeater_handler.storage.sqlite_handler + + radio_config = ( + self.repeater_handler.radio_config + if self.repeater_handler + else self.config.get("radio", {}) + ) + + node_name = settings.get("node_name", name) + tcp_port = settings.get("tcp_port", 5000) + bind_address = settings.get("bind_address", "0.0.0.0") + + bridge = RepeaterCompanionBridge( + identity=identity, + packet_injector=self.router.inject_packet, + node_name=node_name, + radio_config=radio_config, + sqlite_handler=sqlite_handler, + companion_hash=companion_hash_str, + ) + + if sqlite_handler: + contact_rows = sqlite_handler.companion_load_contacts(companion_hash_str) + if contact_rows: + records = [] + for row in contact_rows: + d = dict(row) + d["public_key"] = d.pop("pubkey", d.get("public_key", b"")) + records.append(d) + bridge.contacts.load_from_dicts(records) + + channel_rows = sqlite_handler.companion_load_channels(companion_hash_str) + for row in channel_rows: + s = row.get("secret", b"") + if isinstance(s, bytes): + raw = s + elif isinstance(s, (bytearray, memoryview)): + raw = bytes(s) + elif s: + raw = bytes.fromhex(s if isinstance(s, str) else str(s)) + else: + raw = b"" + if len(raw) < 32: + raw = raw + b"\x00" * (32 - len(raw)) + elif len(raw) > 32: + raw = raw[:32] + ch = Channel(name=row.get("name", ""), secret=raw) + bridge.channels.set(row.get("channel_idx", 0), ch) + + for msg_dict in sqlite_handler.companion_load_messages(companion_hash_str): + from pymc_core.companion.models import QueuedMessage + + sk = msg_dict.get("sender_key", b"") + if isinstance(sk, str): + sk = bytes.fromhex(sk) + bridge.message_queue.push( + QueuedMessage( + sender_key=sk, + txt_type=msg_dict.get("txt_type", 0), + timestamp=msg_dict.get("timestamp", 0), + text=msg_dict.get("text", ""), + is_channel=bool(msg_dict.get("is_channel", False)), + channel_idx=msg_dict.get("channel_idx", 0), + path_len=msg_dict.get("path_len", 0), + ) + ) + + if bridge.get_channel(0) is None: + bridge.set_channel(0, "Public", DEFAULT_PUBLIC_CHANNEL_SECRET) + + self.companion_bridges[companion_hash] = bridge + + frame_server = CompanionFrameServer( + bridge=bridge, + companion_hash=companion_hash_str, + port=tcp_port, + bind_address=bind_address, + sqlite_handler=sqlite_handler, + local_hash=self.local_hash, + stats_getter=self._get_companion_stats, + control_handler=( + self.discovery_helper.control_handler if self.discovery_helper else None + ), + ) + await frame_server.start() + self.companion_frame_servers.append(frame_server) + + self.identity_manager.register_identity( + name=name, + identity=identity, + config=comp_config, + identity_type="companion", + ) + + logger.info( + f"Hot-reload: Loaded companion '{name}': hash=0x{companion_hash:02x}, " + f"port={tcp_port}, bind={bind_address}" + ) + async def _on_raw_rx_for_companions(self, data: bytes, rssi: int, snr: float) -> None: """Raw RX subscriber: push PUSH_CODE_LOG_RX_DATA (0x88) to connected companion clients.""" servers = getattr(self, "companion_frame_servers", []) diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 0149b43..3e1a422 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -1234,9 +1234,9 @@ class APIEndpoints: self.config["radio"]["cad"]["min_threshold"] = min_val config_path = getattr(self, "_config_path", "/etc/pymc_repeater/config.yaml") - saved, err = self.config_manager.save_to_file() + saved = self.config_manager.save_to_file() if not saved: - return self._error(err or "Failed to save configuration to file") + return self._error("Failed to save configuration to file") logger.info( f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%" @@ -1766,14 +1766,14 @@ class APIEndpoints: # Update the configuration file using ConfigManager try: - saved, err = self.config_manager.save_to_file() + saved = self.config_manager.save_to_file() if saved: logger.info( f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}" ) else: - logger.error(f"Failed to save global flood policy to file: {err}") - return self._error(err or "Failed to save configuration to file") + logger.error("Failed to save global flood policy to file") + return self._error("Failed to save configuration to file") except Exception as e: logger.error(f"Failed to save global flood policy to file: {e}") return self._error(f"Failed to save configuration to file: {e}") @@ -1961,7 +1961,7 @@ class APIEndpoints: identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] - # Enhance with config data + # Enhance with config data (room servers) configured = [] for room_config in room_servers: name = room_config.get("name") @@ -1988,12 +1988,46 @@ class APIEndpoints: } ) + # Configured companions (same pattern as room servers) + companions = identities_config.get("companions") or [] + configured_companions = [] + for comp_config in companions: + name = comp_config.get("name") + identity_key = comp_config.get("identity_key", "") + settings = comp_config.get("settings", {}) + + matching = next( + ( + r + for r in registered_identities + if r["name"] == f"companion:{name}" + ), + None, + ) + + configured_companions.append( + { + "name": name, + "type": "companion", + "identity_key": ( + identity_key[:16] + "..." if len(identity_key) > 16 else identity_key + ), + "identity_key_length": len(identity_key), + "settings": settings, + "hash": matching["hash"] if matching else None, + "public_key": matching.get("public_key") if matching else None, + "registered": matching is not None, + } + ) + return self._success( { "registered": registered_identities, "configured": configured, + "configured_companions": configured_companions, "total_registered": len(registered_identities), "total_configured": len(configured), + "total_configured_companions": len(configured_companions), } ) @@ -2019,14 +2053,17 @@ class APIEndpoints: identities_config = self.config.get("identities", {}) room_servers = identities_config.get("room_servers") or [] + companions = identities_config.get("companions") or [] - # Find the identity in config + # Find the identity in config (room servers first, then companions) identity_config = next((r for r in room_servers if r.get("name") == name), None) + if identity_config is None: + identity_config = next((c for c in companions if c.get("name") == name), None) if not identity_config: return self._error(f"Identity '{name}' not found") - # Get runtime info if available + # Get runtime info if available (identity_manager uses name for both types) if self.daemon_instance and hasattr(self.daemon_instance, "identity_manager"): identity_manager = self.daemon_instance.identity_manager runtime_info = identity_manager.get_identity_by_name(name) @@ -2087,11 +2124,18 @@ class APIEndpoints: if not name: return self._error("Missing required field: name") - # Validate passwords are different if both provided - admin_pw = settings.get("admin_password") - guest_pw = settings.get("guest_password") - if admin_pw and guest_pw and admin_pw == guest_pw: - return self._error("admin_password and guest_password must be different") + # Validate identity type + if identity_type not in ["room_server", "companion"]: + return self._error( + f"Invalid identity type: {identity_type}. Only 'room_server' and 'companion' are supported." + ) + + # Room server: validate passwords are different if both provided + if identity_type == "room_server": + admin_pw = settings.get("admin_password") + guest_pw = settings.get("guest_password") + if admin_pw and guest_pw and admin_pw == guest_pw: + return self._error("admin_password and guest_password must be different") # Auto-generate identity key if not provided key_was_generated = False @@ -2106,38 +2150,58 @@ class APIEndpoints: logger.error(f"Failed to auto-generate identity key: {gen_error}") return self._error(f"Failed to auto-generate identity key: {gen_error}") - # Validate identity type - if identity_type not in ["room_server"]: - return self._error( - f"Invalid identity type: {identity_type}. Only 'room_server' is supported." - ) - - # Check if identity already exists identities_config = self.config.get("identities", {}) - room_servers = identities_config.get("room_servers") or [] - - if any(r.get("name") == name for r in room_servers): - return self._error(f"Identity with name '{name}' already exists") - - # Create new identity config - new_identity = { - "name": name, - "identity_key": identity_key, - "type": identity_type, - "settings": settings, - } - - # Add to config - room_servers.append(new_identity) - if "identities" not in self.config: self.config["identities"] = {} - self.config["identities"]["room_servers"] = room_servers + + if identity_type == "companion": + # Companion: validate key length (32 or 64 bytes hex), normalize settings + if identity_key: + try: + key_bytes = bytes.fromhex(identity_key) + if len(key_bytes) not in (32, 64): + return self._error( + "Companion identity_key must be 32 or 64 bytes (64 or 128 hex chars)" + ) + except ValueError: + return self._error("Companion identity_key must be a valid hex string") + + companions = identities_config.get("companions") or [] + if any(c.get("name") == name for c in companions): + return self._error(f"Companion with name '{name}' already exists") + + comp_settings = { + "node_name": settings.get("node_name") or name, + "tcp_port": settings.get("tcp_port", 5000), + "bind_address": settings.get("bind_address", "0.0.0.0"), + } + new_identity = { + "name": name, + "identity_key": identity_key, + "type": identity_type, + "settings": comp_settings, + } + companions.append(new_identity) + self.config["identities"]["companions"] = companions + else: + # Room server + room_servers = identities_config.get("room_servers") or [] + if any(r.get("name") == name for r in room_servers): + return self._error(f"Identity with name '{name}' already exists") + + new_identity = { + "name": name, + "identity_key": identity_key, + "type": identity_type, + "settings": settings, + } + room_servers.append(new_identity) + self.config["identities"]["room_servers"] = room_servers # Save to file - saved, err = self.config_manager.save_to_file() + saved = self.config_manager.save_to_file() if not saved: - return self._error(err or "Failed to save configuration to file") + return self._error("Failed to save configuration to file") logger.info( f"Created new identity: {name} (type: {identity_type}){' with auto-generated key' if key_was_generated else ''}" @@ -2145,7 +2209,7 @@ class APIEndpoints: # Hot reload - register identity immediately registration_success = False - if self.daemon_instance: + if identity_type == "room_server" and self.daemon_instance: try: from pymc_core import LocalIdentity @@ -2188,11 +2252,35 @@ class APIEndpoints: f"Failed to hot reload identity {name}: {reg_error}", exc_info=True ) - message = ( - f"Identity '{name}' created successfully and activated immediately!" - if registration_success - else f"Identity '{name}' created successfully. Restart required to activate." - ) + elif identity_type == "companion" and self.daemon_instance and self.event_loop: + try: + import asyncio + + future = asyncio.run_coroutine_threadsafe( + self.daemon_instance.add_companion_from_config(new_identity), + self.event_loop, + ) + future.result(timeout=15) + registration_success = True + logger.info(f"Hot reload: Companion '{name}' activated immediately") + except Exception as comp_error: + logger.warning( + f"Hot reload companion '{name}' failed: {comp_error}. Restart required to activate.", + exc_info=True, + ) + + if identity_type == "companion": + message = ( + f"Companion '{name}' created successfully and activated immediately!" + if registration_success + else f"Companion '{name}' created successfully. Restart required to activate." + ) + else: + message = ( + f"Identity '{name}' created successfully and activated immediately!" + if registration_success + else f"Identity '{name}' created successfully. Restart required to activate." + ) if key_was_generated: message += " Identity key was auto-generated." @@ -2242,10 +2330,63 @@ class APIEndpoints: if not name: return self._error("Missing required field: name") - identities_config = self.config.get("identities", {}) - room_servers = identities_config.get("room_servers") or [] + identity_type = data.get("type", "room_server") + if identity_type not in ["room_server", "companion"]: + return self._error( + f"Invalid identity type: {identity_type}. Only 'room_server' and 'companion' are supported." + ) - # Find the identity + identities_config = self.config.get("identities", {}) + + if identity_type == "companion": + companions = identities_config.get("companions") or [] + identity_index = next( + (i for i, c in enumerate(companions) if c.get("name") == name), None + ) + if identity_index is None: + return self._error(f"Companion '{name}' not found") + identity = companions[identity_index] + + if "new_name" in data: + new_name = data["new_name"] + if any( + c.get("name") == new_name + for i, c in enumerate(companions) + if i != identity_index + ): + return self._error(f"Companion with name '{new_name}' already exists") + identity["name"] = new_name + + if "identity_key" in data and data["identity_key"]: + new_key = data["identity_key"] + if "..." not in new_key: + try: + key_bytes = bytes.fromhex(new_key) + if len(key_bytes) in (32, 64): + identity["identity_key"] = new_key + logger.info(f"Updated identity_key for companion '{name}'") + except ValueError: + pass + + if "settings" in data: + if "settings" not in identity: + identity["settings"] = {} + # Only allow companion settings + for k, v in data["settings"].items(): + if k in ("node_name", "tcp_port", "bind_address"): + identity["settings"][k] = v + + companions[identity_index] = identity + self.config["identities"]["companions"] = companions + saved = self.config_manager.save_to_file() + if not saved: + return self._error("Failed to save configuration to file") + logger.info(f"Updated companion: {name}") + message = f"Companion '{name}' updated successfully. Restart required to apply changes." + return self._success(identity, message=message) + + # Room server path + room_servers = identities_config.get("room_servers") or [] identity_index = next( (i for i, r in enumerate(room_servers) if r.get("name") == name), None ) @@ -2298,9 +2439,9 @@ class APIEndpoints: room_servers[identity_index] = identity self.config["identities"]["room_servers"] = room_servers - saved, err = self.config_manager.save_to_file() + saved = self.config_manager.save_to_file() if not saved: - return self._error(err or "Failed to save configuration to file") + return self._error("Failed to save configuration to file") logger.info(f"Updated identity: {name}") @@ -2380,9 +2521,9 @@ class APIEndpoints: @cherrypy.expose @cherrypy.tools.json_out() - def delete_identity(self, name=None): + def delete_identity(self, name=None, type=None): """ - DELETE /api/delete_identity?name= - Delete an identity + DELETE /api/delete_identity?name=&type= - Delete an identity """ # Enable CORS for this endpoint only if configured self._set_cors_headers() @@ -2399,7 +2540,40 @@ class APIEndpoints: if not name: return self._error("Missing name parameter") + identity_type = (type or "room_server").lower() + if identity_type not in ["room_server", "companion"]: + return self._error( + f"Invalid type: {type}. Use 'room_server' or 'companion'." + ) + identities_config = self.config.get("identities", {}) + + if identity_type == "companion": + companions = identities_config.get("companions") or [] + initial_count = len(companions) + companions = [c for c in companions if c.get("name") != name] + if len(companions) == initial_count: + return self._error(f"Companion '{name}' not found") + self.config["identities"]["companions"] = companions + saved = self.config_manager.save_to_file() + if not saved: + return self._error("Failed to save configuration to file") + logger.info(f"Deleted companion: {name}") + unregister_success = False + if self.daemon_instance and hasattr(self.daemon_instance, "identity_manager"): + identity_manager = self.daemon_instance.identity_manager + if name in identity_manager.named_identities: + del identity_manager.named_identities[name] + logger.info(f"Removed companion {name} from named_identities") + unregister_success = True + message = ( + f"Companion '{name}' deleted successfully and deactivated immediately!" + if unregister_success + else f"Companion '{name}' deleted successfully. Restart required to fully remove." + ) + return self._success({"name": name}, message=message) + + # Room server path room_servers = identities_config.get("room_servers") or [] # Find and remove the identity @@ -2412,9 +2586,9 @@ class APIEndpoints: # Update config self.config["identities"]["room_servers"] = room_servers - saved, err = self.config_manager.save_to_file() + saved = self.config_manager.save_to_file() if not saved: - return self._error(err or "Failed to save configuration to file") + return self._error("Failed to save configuration to file") logger.info(f"Deleted identity: {name}") diff --git a/repeater/web/html/assets/CADCalibration-D8RmhurR.js b/repeater/web/html/assets/CADCalibration-Dc9AglAf.js similarity index 99% rename from repeater/web/html/assets/CADCalibration-D8RmhurR.js rename to repeater/web/html/assets/CADCalibration-Dc9AglAf.js index f2487aa..4b3b75c 100644 --- a/repeater/web/html/assets/CADCalibration-D8RmhurR.js +++ b/repeater/web/html/assets/CADCalibration-Dc9AglAf.js @@ -1 +1 @@ -import{a as G,M as K,c as Q,r as o,o as W,P as X,b as g,e as a,g as k,i as F,t as l,k as h,n as ee,L as T,Y as te,Z as ae,p as f,x as se}from"./index-sHch0610.js";import{P as M}from"./plotly.min-DO11Gp-n.js";import"./_commonjsHelpers-CqkleIqs.js";const oe={class:"p-6 space-y-6"},re={class:"glass-card rounded-[15px] p-6"},le={class:"flex justify-center"},ne={class:"flex gap-4"},ie=["disabled"],ce=["disabled"],de={class:"glass-card rounded-[15px] p-6 space-y-4"},ue={class:"text-content-primary dark:text-content-primary"},ve={key:0,class:"p-4 bg-primary/10 border border-primary/30 rounded-lg"},pe={class:"text-content-primary dark:text-primary"},me={class:"space-y-2"},be={class:"w-full bg-white/10 rounded-full h-2"},ge={class:"text-content-secondary dark:text-content-muted text-sm"},fe={class:"grid grid-cols-2 md:grid-cols-4 gap-4"},xe={class:"glass-card rounded-[15px] p-4 text-center"},ye={class:"text-2xl font-bold text-primary"},_e={class:"glass-card rounded-[15px] p-4 text-center"},ke={class:"text-2xl font-bold text-primary"},he={class:"glass-card rounded-[15px] p-4 text-center"},Ce={class:"text-2xl font-bold text-primary"},we={class:"glass-card rounded-[15px] p-4 text-center"},Re={class:"text-2xl font-bold text-primary"},Se={key:0,class:"glass-card rounded-[15px] p-6 space-y-4"},De={key:0,class:"p-4 bg-accent-green/10 border border-accent-green/30 rounded-lg"},Ae={class:"text-content-primary dark:text-content-primary mb-4"},Be={key:1,class:"p-4 bg-secondary/20 border border-secondary/40 rounded-lg"},Ee=G({name:"CADCalibrationView",__name:"CADCalibration",setup(Fe){const m=K(),I=Q(()=>document.documentElement.classList.contains("dark")),P=()=>{const e=I.value;return{title:e?"#F9FAFB":"#111827",subtitle:e?"#9CA3AF":"#6B7280",axis:e?"#D1D5DB":"#374151",tick:e?"#9CA3AF":"#6B7280",grid:e?"rgba(148, 163, 184, 0.1)":"rgba(107, 114, 128, 0.15)",zeroline:e?"rgba(148, 163, 184, 0.2)":"rgba(107, 114, 128, 0.25)",line:e?"rgba(148, 163, 184, 0.3)":"rgba(107, 114, 128, 0.35)",colorbarBorder:e?"rgba(255,255,255,0.2)":"rgba(0,0,0,0.15)",markerLine:e?"rgba(255,255,255,0.2)":"rgba(0,0,0,0.15)"}},u=o(!1),C=o(null),r=o(null),v=o({}),n=o(null),$=o([]),N=o({}),d=o("Ready to start calibration"),x=o(0),b=o(0),w=o(0),R=o(0),S=o(0),D=o(0),i=o(null),A=o(!1),B=o(!1),y=o(!1),_=o(!1);let c=null;const O={responsive:!0,displayModeBar:!0,modeBarButtonsToRemove:["pan2d","select2d","lasso2d","autoScale2d"],displaylogo:!1,toImageButtonOptions:{format:"png",filename:"cad-calibration-heatmap",height:600,width:800,scale:2}};function V(){const e=P(),t=[{x:[],y:[],z:[],mode:"markers",type:"scatter",marker:{size:12,color:[],colorscale:[[0,"rgba(75, 85, 99, 0.4)"],[.1,"rgba(6, 182, 212, 0.3)"],[.5,"rgba(6, 182, 212, 0.6)"],[1,"rgba(16, 185, 129, 0.9)"]],showscale:!0,colorbar:{title:{text:"Detection Rate (%)",font:{color:e.axis,size:14}},tickfont:{color:e.tick},bgcolor:"rgba(0,0,0,0)",bordercolor:e.colorbarBorder,borderwidth:1,thickness:15},line:{color:e.markerLine,width:1}},hovertemplate:"Peak: %{x}
Min: %{y}
Detection Rate: %{marker.color:.1f}%
",name:"Test Results"}],s={title:{text:`CAD Detection Rate
Channel Activity Detection Calibration`,font:{color:e.title,size:18},x:.5},xaxis:{title:{text:"CAD Peak Threshold",font:{color:e.axis,size:14}},tickfont:{color:e.tick},gridcolor:e.grid,zerolinecolor:e.zeroline,linecolor:e.line},yaxis:{title:{text:"CAD Min Threshold",font:{color:e.axis,size:14}},tickfont:{color:e.tick},gridcolor:e.grid,zerolinecolor:e.zeroline,linecolor:e.line},plot_bgcolor:"rgba(0, 0, 0, 0)",paper_bgcolor:"rgba(0, 0, 0, 0)",font:{color:e.title,family:"Inter, system-ui, sans-serif"},margin:{l:80,r:80,t:100,b:80},showlegend:!1};M.newPlot("plotly-chart",t,s,O)}function j(){if(Object.keys(v.value).length===0)return;const e=Object.values(v.value),t=[],s=[],p=[];for(const E of e)t.push(E.det_peak),s.push(E.det_min),p.push(E.detection_rate);const q={x:[t],y:[s],"marker.color":[p],hovertemplate:"Peak: %{x}
Min: %{y}
Detection Rate: %{marker.color:.1f}%
Status: Tested
"};M.restyle("plotly-chart",q,[0])}async function U(){try{const s=await T.post("/cad-calibration-start",{samples:10,delay_ms:50});if(s.success)u.value=!0,C.value=Date.now(),m.setCadCalibrationRunning(!0),v.value={},$.value=[],N.value={},n.value=null,A.value=!1,B.value=!1,y.value=!1,_.value=!1,w.value=0,R.value=0,S.value=0,D.value=0,x.value=0,b.value=0,c=setInterval(()=>{C.value&&(D.value=Math.floor((Date.now()-C.value)/1e3))},1e3),L();else throw new Error(s.error||"Failed to start calibration")}catch(s){d.value=`Error: ${s instanceof Error?s.message:"Unknown error"}`}}async function z(){try{(await T.post("/cad-calibration-stop")).success&&(u.value=!1,m.setCadCalibrationRunning(!1),r.value&&(r.value.close(),r.value=null),c&&(clearInterval(c),c=null))}catch(e){console.error("Failed to stop calibration:",e)}}function L(){r.value&&r.value.close();const e=te(),t=e?`?token=${encodeURIComponent(e)}`:"";r.value=new EventSource(`${ae}/api/cad-calibration-stream${t}`),r.value.onmessage=function(s){try{const p=JSON.parse(s.data);H(p)}catch(p){console.error("Failed to parse SSE data:",p)}},r.value.onerror=function(s){console.error("SSE connection error:",s),u.value||r.value&&(r.value.close(),r.value=null)}}function H(e){switch(e.type){case"status":d.value=e.message||"Status update",e.test_ranges&&(i.value=e.test_ranges,A.value=!0);break;case"progress":x.value=e.current||0,b.value=e.total||0,w.value=e.current||0;break;case"result":if(e.det_peak!==void 0&&e.det_min!==void 0&&e.detection_rate!==void 0&&e.detections!==void 0&&e.samples!==void 0){const t=`${e.det_peak}_${e.det_min}`;v.value[t]={det_peak:e.det_peak,det_min:e.det_min,detection_rate:e.detection_rate,detections:e.detections,samples:e.samples},j(),J()}break;case"complete":case"completed":u.value=!1,d.value=e.message||"Calibration completed",m.setCadCalibrationRunning(!1),Y(),r.value&&(r.value.close(),r.value=null),c&&(clearInterval(c),c=null);break;case"error":d.value=`Error: ${e.message}`,m.setCadCalibrationRunning(!1),z();break}}function J(){const e=Object.values(v.value).map(t=>t.detection_rate);e.length!==0&&(R.value=Math.max(...e),S.value=e.reduce((t,s)=>t+s,0)/e.length)}function Y(){B.value=!0;let e=null,t=0;for(const s of Object.values(v.value))s.detection_rate>t&&(t=s.detection_rate,e=s);n.value=e,e&&t>0?(y.value=!0,_.value=!1):(y.value=!1,_.value=!0)}async function Z(){if(!n.value){d.value="Error: No calibration results to save";return}try{const e=await T.post("/save_cad_settings",{peak:n.value.det_peak,min_val:n.value.det_min,detection_rate:n.value.detection_rate});if(e.success)d.value=`Settings saved! Peak=${n.value.det_peak}, Min=${n.value.det_min} applied to configuration.`;else throw new Error(e.error||"Failed to save settings")}catch(e){d.value=`Error: Failed to save settings: ${e instanceof Error?e.message:"Unknown error"}`}}return W(()=>{V()}),X(()=>{r.value&&r.value.close(),c&&clearInterval(c),m.setCadCalibrationRunning(!1),document.getElementById("plotly-chart")&&M.purge("plotly-chart")}),(e,t)=>(f(),g("div",oe,[t[14]||(t[14]=a("div",null,[a("h1",{class:"text-2xl font-bold text-content-primary dark:text-content-primary"},"CAD Calibration Tool"),a("p",{class:"text-content-secondary dark:text-content-muted mt-2"},"Channel Activity Detection calibration")],-1)),a("div",re,[a("div",le,[a("div",ne,[a("button",{onClick:U,disabled:u.value,class:"flex items-center gap-3 px-6 py-3 bg-accent-green/10 hover:bg-accent-green/20 disabled:bg-gray-500/10 text-accent-green disabled:text-gray-400 rounded-lg border border-accent-green/30 disabled:border-gray-500/20 transition-colors disabled:cursor-not-allowed"},t[0]||(t[0]=[F('
Start Calibration
Begin testing
',2)]),8,ie),a("button",{onClick:z,disabled:!u.value,class:"flex items-center gap-3 px-6 py-3 bg-accent-red/10 hover:bg-accent-red/20 disabled:bg-gray-500/10 text-accent-red disabled:text-gray-400 rounded-lg border border-accent-red/30 disabled:border-gray-500/20 transition-colors disabled:cursor-not-allowed"},t[1]||(t[1]=[F('
Stop
Halt calibration
',2)]),8,ce)])])]),a("div",de,[a("div",ue,l(d.value),1),A.value&&i.value?(f(),g("div",ve,[a("div",pe,[t[2]||(t[2]=a("strong",null,"Configuration:",-1)),h(" SF"+l(i.value.spreading_factor)+" | Peak: "+l(i.value.peak_min)+" - "+l(i.value.peak_max)+" | Min: "+l(i.value.min_min)+" - "+l(i.value.min_max)+" | "+l((i.value.peak_max-i.value.peak_min+1)*(i.value.min_max-i.value.min_min+1))+" tests ",1)])])):k("",!0),a("div",me,[a("div",be,[a("div",{class:"bg-gradient-to-r from-primary to-accent-green h-2 rounded-full transition-all duration-300",style:ee({width:b.value>0?`${x.value/b.value*100}%`:"0%"})},null,4)]),a("div",ge,l(x.value)+" / "+l(b.value)+" tests completed",1)])]),a("div",fe,[a("div",xe,[a("div",ye,l(w.value),1),t[3]||(t[3]=a("div",{class:"text-content-secondary dark:text-content-muted text-sm"},"Tests Completed",-1))]),a("div",_e,[a("div",ke,l(R.value.toFixed(1))+"%",1),t[4]||(t[4]=a("div",{class:"text-content-secondary dark:text-content-muted text-sm"},"Best Detection Rate",-1))]),a("div",he,[a("div",Ce,l(S.value.toFixed(1))+"%",1),t[5]||(t[5]=a("div",{class:"text-content-secondary dark:text-content-muted text-sm"},"Average Rate",-1))]),a("div",we,[a("div",Re,l(D.value)+"s",1),t[6]||(t[6]=a("div",{class:"text-content-secondary dark:text-content-muted text-sm"},"Elapsed Time",-1))])]),t[15]||(t[15]=a("div",{class:"glass-card rounded-[15px] p-6"},[a("div",{id:"plotly-chart",class:"w-full h-96"})],-1)),B.value?(f(),g("div",Se,[t[13]||(t[13]=a("h3",{class:"text-xl font-bold text-content-primary dark:text-content-primary"},"Calibration Results",-1)),y.value&&n.value?(f(),g("div",De,[t[11]||(t[11]=a("h4",{class:"font-medium text-accent-green mb-2"},"Optimal Settings Found:",-1)),a("p",Ae,[t[7]||(t[7]=h(" Peak: ",-1)),a("strong",null,l(n.value.det_peak),1),t[8]||(t[8]=h(", Min: ",-1)),a("strong",null,l(n.value.det_min),1),t[9]||(t[9]=h(", Rate: ",-1)),a("strong",null,l(n.value.detection_rate.toFixed(1))+"%",1)]),a("div",{class:"flex justify-center"},[a("button",{onClick:Z,class:"flex items-center gap-3 px-6 py-3 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"},t[10]||(t[10]=[F('
Save Settings
Apply to configuration
',2)]))])])):k("",!0),_.value?(f(),g("div",Be,t[12]||(t[12]=[a("h4",{class:"font-medium text-secondary mb-2"},"No Optimal Settings Found",-1),a("p",{class:"text-content-secondary dark:text-content-muted"},"All tested combinations showed low detection rates. Consider running calibration again or adjusting test parameters.",-1)]))):k("",!0)])):k("",!0)]))}}),Ie=se(Ee,[["__scopeId","data-v-c30e5f38"]]);export{Ie as default}; +import{a as G,M as K,c as Q,r as o,o as W,P as X,b as g,e as a,g as k,i as F,t as l,k as h,n as ee,L as T,Y as te,Z as ae,p as f,x as se}from"./index-DyUIpN7m.js";import{P as M}from"./plotly.min-DO11Gp-n.js";import"./_commonjsHelpers-CqkleIqs.js";const oe={class:"p-6 space-y-6"},re={class:"glass-card rounded-[15px] p-6"},le={class:"flex justify-center"},ne={class:"flex gap-4"},ie=["disabled"],ce=["disabled"],de={class:"glass-card rounded-[15px] p-6 space-y-4"},ue={class:"text-content-primary dark:text-content-primary"},ve={key:0,class:"p-4 bg-primary/10 border border-primary/30 rounded-lg"},pe={class:"text-content-primary dark:text-primary"},me={class:"space-y-2"},be={class:"w-full bg-white/10 rounded-full h-2"},ge={class:"text-content-secondary dark:text-content-muted text-sm"},fe={class:"grid grid-cols-2 md:grid-cols-4 gap-4"},xe={class:"glass-card rounded-[15px] p-4 text-center"},ye={class:"text-2xl font-bold text-primary"},_e={class:"glass-card rounded-[15px] p-4 text-center"},ke={class:"text-2xl font-bold text-primary"},he={class:"glass-card rounded-[15px] p-4 text-center"},Ce={class:"text-2xl font-bold text-primary"},we={class:"glass-card rounded-[15px] p-4 text-center"},Re={class:"text-2xl font-bold text-primary"},Se={key:0,class:"glass-card rounded-[15px] p-6 space-y-4"},De={key:0,class:"p-4 bg-accent-green/10 border border-accent-green/30 rounded-lg"},Ae={class:"text-content-primary dark:text-content-primary mb-4"},Be={key:1,class:"p-4 bg-secondary/20 border border-secondary/40 rounded-lg"},Ee=G({name:"CADCalibrationView",__name:"CADCalibration",setup(Fe){const m=K(),I=Q(()=>document.documentElement.classList.contains("dark")),P=()=>{const e=I.value;return{title:e?"#F9FAFB":"#111827",subtitle:e?"#9CA3AF":"#6B7280",axis:e?"#D1D5DB":"#374151",tick:e?"#9CA3AF":"#6B7280",grid:e?"rgba(148, 163, 184, 0.1)":"rgba(107, 114, 128, 0.15)",zeroline:e?"rgba(148, 163, 184, 0.2)":"rgba(107, 114, 128, 0.25)",line:e?"rgba(148, 163, 184, 0.3)":"rgba(107, 114, 128, 0.35)",colorbarBorder:e?"rgba(255,255,255,0.2)":"rgba(0,0,0,0.15)",markerLine:e?"rgba(255,255,255,0.2)":"rgba(0,0,0,0.15)"}},u=o(!1),C=o(null),r=o(null),v=o({}),n=o(null),$=o([]),N=o({}),d=o("Ready to start calibration"),x=o(0),b=o(0),w=o(0),R=o(0),S=o(0),D=o(0),i=o(null),A=o(!1),B=o(!1),y=o(!1),_=o(!1);let c=null;const O={responsive:!0,displayModeBar:!0,modeBarButtonsToRemove:["pan2d","select2d","lasso2d","autoScale2d"],displaylogo:!1,toImageButtonOptions:{format:"png",filename:"cad-calibration-heatmap",height:600,width:800,scale:2}};function V(){const e=P(),t=[{x:[],y:[],z:[],mode:"markers",type:"scatter",marker:{size:12,color:[],colorscale:[[0,"rgba(75, 85, 99, 0.4)"],[.1,"rgba(6, 182, 212, 0.3)"],[.5,"rgba(6, 182, 212, 0.6)"],[1,"rgba(16, 185, 129, 0.9)"]],showscale:!0,colorbar:{title:{text:"Detection Rate (%)",font:{color:e.axis,size:14}},tickfont:{color:e.tick},bgcolor:"rgba(0,0,0,0)",bordercolor:e.colorbarBorder,borderwidth:1,thickness:15},line:{color:e.markerLine,width:1}},hovertemplate:"Peak: %{x}
Min: %{y}
Detection Rate: %{marker.color:.1f}%
",name:"Test Results"}],s={title:{text:`CAD Detection Rate
Channel Activity Detection Calibration`,font:{color:e.title,size:18},x:.5},xaxis:{title:{text:"CAD Peak Threshold",font:{color:e.axis,size:14}},tickfont:{color:e.tick},gridcolor:e.grid,zerolinecolor:e.zeroline,linecolor:e.line},yaxis:{title:{text:"CAD Min Threshold",font:{color:e.axis,size:14}},tickfont:{color:e.tick},gridcolor:e.grid,zerolinecolor:e.zeroline,linecolor:e.line},plot_bgcolor:"rgba(0, 0, 0, 0)",paper_bgcolor:"rgba(0, 0, 0, 0)",font:{color:e.title,family:"Inter, system-ui, sans-serif"},margin:{l:80,r:80,t:100,b:80},showlegend:!1};M.newPlot("plotly-chart",t,s,O)}function j(){if(Object.keys(v.value).length===0)return;const e=Object.values(v.value),t=[],s=[],p=[];for(const E of e)t.push(E.det_peak),s.push(E.det_min),p.push(E.detection_rate);const q={x:[t],y:[s],"marker.color":[p],hovertemplate:"Peak: %{x}
Min: %{y}
Detection Rate: %{marker.color:.1f}%
Status: Tested
"};M.restyle("plotly-chart",q,[0])}async function U(){try{const s=await T.post("/cad-calibration-start",{samples:10,delay_ms:50});if(s.success)u.value=!0,C.value=Date.now(),m.setCadCalibrationRunning(!0),v.value={},$.value=[],N.value={},n.value=null,A.value=!1,B.value=!1,y.value=!1,_.value=!1,w.value=0,R.value=0,S.value=0,D.value=0,x.value=0,b.value=0,c=setInterval(()=>{C.value&&(D.value=Math.floor((Date.now()-C.value)/1e3))},1e3),L();else throw new Error(s.error||"Failed to start calibration")}catch(s){d.value=`Error: ${s instanceof Error?s.message:"Unknown error"}`}}async function z(){try{(await T.post("/cad-calibration-stop")).success&&(u.value=!1,m.setCadCalibrationRunning(!1),r.value&&(r.value.close(),r.value=null),c&&(clearInterval(c),c=null))}catch(e){console.error("Failed to stop calibration:",e)}}function L(){r.value&&r.value.close();const e=te(),t=e?`?token=${encodeURIComponent(e)}`:"";r.value=new EventSource(`${ae}/api/cad-calibration-stream${t}`),r.value.onmessage=function(s){try{const p=JSON.parse(s.data);H(p)}catch(p){console.error("Failed to parse SSE data:",p)}},r.value.onerror=function(s){console.error("SSE connection error:",s),u.value||r.value&&(r.value.close(),r.value=null)}}function H(e){switch(e.type){case"status":d.value=e.message||"Status update",e.test_ranges&&(i.value=e.test_ranges,A.value=!0);break;case"progress":x.value=e.current||0,b.value=e.total||0,w.value=e.current||0;break;case"result":if(e.det_peak!==void 0&&e.det_min!==void 0&&e.detection_rate!==void 0&&e.detections!==void 0&&e.samples!==void 0){const t=`${e.det_peak}_${e.det_min}`;v.value[t]={det_peak:e.det_peak,det_min:e.det_min,detection_rate:e.detection_rate,detections:e.detections,samples:e.samples},j(),J()}break;case"complete":case"completed":u.value=!1,d.value=e.message||"Calibration completed",m.setCadCalibrationRunning(!1),Y(),r.value&&(r.value.close(),r.value=null),c&&(clearInterval(c),c=null);break;case"error":d.value=`Error: ${e.message}`,m.setCadCalibrationRunning(!1),z();break}}function J(){const e=Object.values(v.value).map(t=>t.detection_rate);e.length!==0&&(R.value=Math.max(...e),S.value=e.reduce((t,s)=>t+s,0)/e.length)}function Y(){B.value=!0;let e=null,t=0;for(const s of Object.values(v.value))s.detection_rate>t&&(t=s.detection_rate,e=s);n.value=e,e&&t>0?(y.value=!0,_.value=!1):(y.value=!1,_.value=!0)}async function Z(){if(!n.value){d.value="Error: No calibration results to save";return}try{const e=await T.post("/save_cad_settings",{peak:n.value.det_peak,min_val:n.value.det_min,detection_rate:n.value.detection_rate});if(e.success)d.value=`Settings saved! Peak=${n.value.det_peak}, Min=${n.value.det_min} applied to configuration.`;else throw new Error(e.error||"Failed to save settings")}catch(e){d.value=`Error: Failed to save settings: ${e instanceof Error?e.message:"Unknown error"}`}}return W(()=>{V()}),X(()=>{r.value&&r.value.close(),c&&clearInterval(c),m.setCadCalibrationRunning(!1),document.getElementById("plotly-chart")&&M.purge("plotly-chart")}),(e,t)=>(f(),g("div",oe,[t[14]||(t[14]=a("div",null,[a("h1",{class:"text-2xl font-bold text-content-primary dark:text-content-primary"},"CAD Calibration Tool"),a("p",{class:"text-content-secondary dark:text-content-muted mt-2"},"Channel Activity Detection calibration")],-1)),a("div",re,[a("div",le,[a("div",ne,[a("button",{onClick:U,disabled:u.value,class:"flex items-center gap-3 px-6 py-3 bg-accent-green/10 hover:bg-accent-green/20 disabled:bg-gray-500/10 text-accent-green disabled:text-gray-400 rounded-lg border border-accent-green/30 disabled:border-gray-500/20 transition-colors disabled:cursor-not-allowed"},t[0]||(t[0]=[F('
Start Calibration
Begin testing
',2)]),8,ie),a("button",{onClick:z,disabled:!u.value,class:"flex items-center gap-3 px-6 py-3 bg-accent-red/10 hover:bg-accent-red/20 disabled:bg-gray-500/10 text-accent-red disabled:text-gray-400 rounded-lg border border-accent-red/30 disabled:border-gray-500/20 transition-colors disabled:cursor-not-allowed"},t[1]||(t[1]=[F('
Stop
Halt calibration
',2)]),8,ce)])])]),a("div",de,[a("div",ue,l(d.value),1),A.value&&i.value?(f(),g("div",ve,[a("div",pe,[t[2]||(t[2]=a("strong",null,"Configuration:",-1)),h(" SF"+l(i.value.spreading_factor)+" | Peak: "+l(i.value.peak_min)+" - "+l(i.value.peak_max)+" | Min: "+l(i.value.min_min)+" - "+l(i.value.min_max)+" | "+l((i.value.peak_max-i.value.peak_min+1)*(i.value.min_max-i.value.min_min+1))+" tests ",1)])])):k("",!0),a("div",me,[a("div",be,[a("div",{class:"bg-gradient-to-r from-primary to-accent-green h-2 rounded-full transition-all duration-300",style:ee({width:b.value>0?`${x.value/b.value*100}%`:"0%"})},null,4)]),a("div",ge,l(x.value)+" / "+l(b.value)+" tests completed",1)])]),a("div",fe,[a("div",xe,[a("div",ye,l(w.value),1),t[3]||(t[3]=a("div",{class:"text-content-secondary dark:text-content-muted text-sm"},"Tests Completed",-1))]),a("div",_e,[a("div",ke,l(R.value.toFixed(1))+"%",1),t[4]||(t[4]=a("div",{class:"text-content-secondary dark:text-content-muted text-sm"},"Best Detection Rate",-1))]),a("div",he,[a("div",Ce,l(S.value.toFixed(1))+"%",1),t[5]||(t[5]=a("div",{class:"text-content-secondary dark:text-content-muted text-sm"},"Average Rate",-1))]),a("div",we,[a("div",Re,l(D.value)+"s",1),t[6]||(t[6]=a("div",{class:"text-content-secondary dark:text-content-muted text-sm"},"Elapsed Time",-1))])]),t[15]||(t[15]=a("div",{class:"glass-card rounded-[15px] p-6"},[a("div",{id:"plotly-chart",class:"w-full h-96"})],-1)),B.value?(f(),g("div",Se,[t[13]||(t[13]=a("h3",{class:"text-xl font-bold text-content-primary dark:text-content-primary"},"Calibration Results",-1)),y.value&&n.value?(f(),g("div",De,[t[11]||(t[11]=a("h4",{class:"font-medium text-accent-green mb-2"},"Optimal Settings Found:",-1)),a("p",Ae,[t[7]||(t[7]=h(" Peak: ",-1)),a("strong",null,l(n.value.det_peak),1),t[8]||(t[8]=h(", Min: ",-1)),a("strong",null,l(n.value.det_min),1),t[9]||(t[9]=h(", Rate: ",-1)),a("strong",null,l(n.value.detection_rate.toFixed(1))+"%",1)]),a("div",{class:"flex justify-center"},[a("button",{onClick:Z,class:"flex items-center gap-3 px-6 py-3 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"},t[10]||(t[10]=[F('
Save Settings
Apply to configuration
',2)]))])])):k("",!0),_.value?(f(),g("div",Be,t[12]||(t[12]=[a("h4",{class:"font-medium text-secondary mb-2"},"No Optimal Settings Found",-1),a("p",{class:"text-content-secondary dark:text-content-muted"},"All tested combinations showed low detection rates. Consider running calibration again or adjusting test parameters.",-1)]))):k("",!0)])):k("",!0)]))}}),Ie=se(Ee,[["__scopeId","data-v-c30e5f38"]]);export{Ie as default}; diff --git a/repeater/web/html/assets/Companions-X5GdqESj.js b/repeater/web/html/assets/Companions-X5GdqESj.js new file mode 100644 index 0000000..db332a2 --- /dev/null +++ b/repeater/web/html/assets/Companions-X5GdqESj.js @@ -0,0 +1 @@ +import{a as z,r as d,o as O,L as C,b as s,e,f as A,g as x,i as J,k as N,t as i,F as T,h as R,w as u,v as c,j as B,p as a}from"./index-DyUIpN7m.js";import{_ as q}from"./ConfirmDialog.vue_vue_type_script_setup_true_lang-CVxh_fqf.js";import{_ as W}from"./MessageDialog.vue_vue_type_script_setup_true_lang-DAIhF3Fs.js";const G={class:"p-6 space-y-6"},Q={class:"relative overflow-hidden rounded-[20px] p-6 mb-6 glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10"},X={class:"relative flex items-center justify-between"},Y={key:0,class:"grid grid-cols-1 md:grid-cols-2 gap-4"},Z={class:"group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5"},ee={class:"relative flex items-center justify-between"},te={class:"text-3xl font-bold text-content-primary dark:text-content-primary"},re={class:"glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6"},oe={key:0,class:"flex items-center justify-center py-12"},ne={key:1,class:"flex items-center justify-center py-12"},se={class:"text-center"},ae={class:"text-content-secondary dark:text-content-muted text-sm mb-4"},le={key:2,class:"space-y-4"},de={class:"relative flex items-start justify-between"},ie={class:"flex-1"},ue={class:"flex items-center gap-3 mb-4"},ce={class:"relative"},pe={key:0,class:"absolute inset-0 bg-accent-green/50 rounded-full animate-ping"},me={class:"text-xl font-bold text-content-primary dark:text-content-primary"},be={key:0,class:"text-content-muted dark:text-content-muted text-sm"},xe={class:"grid grid-cols-1 md:grid-cols-2 gap-3 text-sm mb-3"},ye={class:"text-content-primary dark:text-content-primary/90 ml-2"},ve={class:"text-content-primary dark:text-content-primary/90 ml-2"},ke={class:"text-content-primary dark:text-content-primary/90 ml-2"},ge={class:"flex items-center gap-2"},fe={key:0,class:"text-content-primary dark:text-content-primary/90 font-mono ml-2 text-xs"},we={key:1,class:"text-content-muted dark:text-content-muted ml-2 text-xs"},he=["onClick"],_e={class:"text-xs text-content-muted dark:text-content-muted"},Ce={key:0,class:"ml-2 font-mono text-content-primary dark:text-content-primary/90 break-all"},Me={key:1,class:"ml-2 text-content-muted dark:text-content-muted"},$e={class:"ml-4 flex flex-wrap gap-2"},Ie=["onClick"],Ne=["onClick"],Ve={key:3,class:"text-center py-12 text-content-secondary dark:text-content-muted"},je={key:1,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"},Ee={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"},De={class:"space-y-4"},Ue={class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},Ae={key:0},Te={key:1,class:"text-content-secondary dark:text-content-muted text-sm"},Be={class:"grid grid-cols-2 gap-4"},Fe={key:2,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"},Pe={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"},Se={class:"space-y-4"},He=["value"],Ke={class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},Le={key:0},ze={class:"grid grid-cols-2 gap-4"},We=z({name:"CompanionsView",__name:"Companions",setup(Oe){const M=d(!1),y=d(null),v=d(null),k=d(!1),w=d(!1),n=d(null),m=d(!1),b=d(!1),g=d(new Set),h=d(!1),_=d(""),$=d(!1),I=d({message:"",variant:"success"}),l=d({name:"",identity_key:"",type:"companion",settings:{node_name:"",tcp_port:5e3,bind_address:"0.0.0.0"}});O(async()=>{await f()});async function f(){M.value=!0,y.value=null;try{const o=await C.getIdentities();o.success?v.value=o.data:y.value=o.error||"Failed to load identities"}catch(o){y.value=o instanceof Error?o.message:"Failed to load identities"}finally{M.value=!1}}async function F(){try{const o=await C.createIdentity({...l.value,settings:{node_name:l.value.settings.node_name||l.value.name,tcp_port:l.value.settings.tcp_port??5e3,bind_address:l.value.settings.bind_address||"0.0.0.0"}});o.success?(k.value=!1,V(),await f(),p(o.message||"Companion created successfully!","success")):p(`Failed to create companion: ${o.error}`,"error")}catch(o){p(`Error creating companion: ${o}`,"error")}}async function P(){try{const o=await C.updateIdentity({name:n.value.name,new_name:n.value.new_name,identity_key:n.value.identity_key,type:"companion",settings:{node_name:n.value.settings?.node_name,tcp_port:n.value.settings?.tcp_port,bind_address:n.value.settings?.bind_address}});o.success?(w.value=!1,n.value=null,await f(),p(o.message||"Companion updated successfully!","success")):p(`Failed to update companion: ${o.error}`,"error")}catch(o){p(`Error updating companion: ${o}`,"error")}}function S(o){_.value=o,h.value=!0}async function H(){const o=_.value;h.value=!1;try{const t=await C.deleteIdentity(o,"companion");t.success?(await f(),p(t.message||"Companion deleted successfully!","success")):p(`Failed to delete companion: ${t.error}`,"error")}catch(t){p(`Error deleting companion: ${t}`,"error")}finally{_.value=""}}function p(o,t){I.value={message:o,variant:t},$.value=!0}function K(o){n.value=JSON.parse(JSON.stringify(o)),n.value.settings||(n.value.settings={node_name:"",tcp_port:5e3,bind_address:"0.0.0.0"}),n.value.new_name="",b.value=!1,w.value=!0}function V(){l.value={name:"",identity_key:"",type:"companion",settings:{node_name:"",tcp_port:5e3,bind_address:"0.0.0.0"}},m.value=!1}function j(){k.value=!1,w.value=!1,n.value=null,m.value=!1,b.value=!1,V()}function L(o){g.value.has(o)?g.value.delete(o):g.value.add(o)}const E=()=>v.value?.configured_companions??[],D=()=>v.value?.total_configured_companions??0;return(o,t)=>(a(),s(T,null,[e("div",G,[e("div",Q,[t[18]||(t[18]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-primary/20 via-secondary/10 to-accent-purple/20 opacity-50"},null,-1)),t[19]||(t[19]=e("div",{class:"absolute inset-0 bg-gradient-to-tl from-accent-green/10 via-transparent to-primary/10 animate-pulse"},null,-1)),e("div",X,[t[17]||(t[17]=J('

Companions

Manage companion identities (TCP frame server)

',1)),e("button",{onClick:t[0]||(t[0]=r=>k.value=!0),class:"group relative px-6 py-3 bg-gradient-to-r from-primary/30 to-secondary/30 hover:from-primary/40 hover:to-secondary/40 text-content-primary dark:text-content-primary rounded-[12px] border border-primary/50 transition-all hover:scale-105 hover:shadow-lg hover:shadow-primary/20"},t[16]||(t[16]=[e("span",{class:"flex items-center gap-2"},[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})]),N(" Add Companion ")],-1)]))])]),v.value&&D()>0?(a(),s("div",Y,[e("div",Z,[e("div",ee,[e("div",null,[t[20]||(t[20]=e("div",{class:"text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide"},"Total Configured",-1)),e("div",te,i(D()),1)])])])])):x("",!0),e("div",re,[M.value?(a(),s("div",oe,t[21]||(t[21]=[e("div",{class:"text-center"},[e("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-primary rounded-full mx-auto mb-4"}),e("div",{class:"text-content-secondary dark:text-content-primary/70"},"Loading companions...")],-1)]))):y.value?(a(),s("div",ne,[e("div",se,[t[22]||(t[22]=e("div",{class:"text-red-600 dark:text-red-400 mb-2"},"Failed to load companions",-1)),e("div",ae,i(y.value),1),e("button",{onClick:f,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors"}," Retry ")])])):v.value&&E().length>0?(a(),s("div",le,[(a(!0),s(T,null,R(E(),r=>(a(),s("div",{key:r.name,class:"group relative overflow-hidden glass-card backdrop-blur-xl rounded-[15px] p-5 border border-stroke-subtle dark:border-white/10 hover:border-primary/30 transition-all duration-300"},[e("div",de,[e("div",ie,[e("div",ue,[e("div",ce,[r.registered?(a(),s("div",pe)):x("",!0),e("div",{class:B(["relative w-3 h-3 rounded-full",r.registered?"bg-accent-green":"bg-accent-red"])},null,2)]),e("h3",me,i(r.name),1),e("span",{class:B(["px-3 py-1 text-xs font-semibold rounded-full",r.registered?"bg-accent-green/20 text-accent-green border border-accent-green/30":"bg-accent-red/20 text-accent-red border border-accent-red/30"])},i(r.registered?"● Active":"○ Inactive"),3),r.hash?(a(),s("span",be,i(r.hash),1)):x("",!0)]),e("div",xe,[e("div",null,[t[23]||(t[23]=e("span",{class:"text-content-muted dark:text-content-muted"},"Node Name:",-1)),e("span",ye,i(r.settings?.node_name||r.name),1)]),e("div",null,[t[24]||(t[24]=e("span",{class:"text-content-muted dark:text-content-muted"},"TCP Port:",-1)),e("span",ve,i(r.settings?.tcp_port??5e3),1)]),e("div",null,[t[25]||(t[25]=e("span",{class:"text-content-muted dark:text-content-muted"},"Bind Address:",-1)),e("span",ke,i(r.settings?.bind_address||"0.0.0.0"),1)]),e("div",ge,[t[26]||(t[26]=e("span",{class:"text-content-muted dark:text-content-muted"},"Identity Key:",-1)),g.value.has(r.name)?(a(),s("span",fe,i(r.identity_key),1)):(a(),s("span",we,"••••••••••••••••")),e("button",{onClick:U=>L(r.name),class:"text-primary/70 hover:text-primary text-xs underline"},i(g.value.has(r.name)?"Hide":"Show"),9,he)])]),e("div",_e,[t[27]||(t[27]=e("span",{class:"text-content-muted dark:text-content-muted"},"Public Key:",-1)),r.public_key?(a(),s("span",Ce,i(r.public_key),1)):(a(),s("span",Me,"—"))])]),e("div",$e,[e("button",{onClick:U=>K(r),class:"px-3 py-1 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors"}," Edit ",8,Ie),e("button",{onClick:U=>S(r.name),class:"px-3 py-1 bg-accent-red/20 hover:bg-accent-red/30 text-accent-red rounded text-xs transition-colors"}," Delete ",8,Ne)])])]))),128))])):(a(),s("div",Ve,[t[28]||(t[28]=e("svg",{class:"w-16 h-16 mx-auto mb-4 text-content-muted dark:text-content-muted/60",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"})],-1)),t[29]||(t[29]=e("p",{class:"text-lg mb-2"},"No companions configured",-1)),t[30]||(t[30]=e("p",{class:"text-sm mb-4"},"Add a companion to run a TCP frame server for firmware or other clients",-1)),e("button",{onClick:t[1]||(t[1]=r=>k.value=!0),class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"}," + Add Companion ")]))]),k.value?(a(),s("div",je,[e("div",Ee,[t[37]||(t[37]=e("h2",{class:"text-xl font-bold text-content-primary dark:text-content-primary mb-4"},"Add Companion",-1)),e("div",De,[e("div",null,[t[31]||(t[31]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Name *",-1)),u(e("input",{"onUpdate:modelValue":t[2]||(t[2]=r=>l.value.name=r),type:"text",placeholder:"e.g., TestCompanion",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[c,l.value.name]])]),e("div",null,[e("label",Ue,[t[32]||(t[32]=N(" Identity Key (Optional) ",-1)),e("button",{onClick:t[3]||(t[3]=r=>m.value=!m.value),type:"button",class:"ml-2 text-primary/70 hover:text-primary text-xs underline"},i(m.value?"Hide":"Show/Edit"),1)]),m.value?(a(),s("div",Ae,[u(e("input",{"onUpdate:modelValue":t[4]||(t[4]=r=>l.value.identity_key=r),type:"text",placeholder:"Leave empty to auto-generate (32 bytes hex)",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary font-mono text-sm placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[c,l.value.identity_key]]),t[33]||(t[33]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"32 or 64 bytes hex. Leave empty to auto-generate.",-1))])):(a(),s("div",Te," Will be auto-generated if not provided "))]),e("div",null,[t[34]||(t[34]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Node Name",-1)),u(e("input",{"onUpdate:modelValue":t[5]||(t[5]=r=>l.value.settings.node_name=r),type:"text",placeholder:"Display name (defaults to Name)",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[c,l.value.settings.node_name]])]),e("div",Be,[e("div",null,[t[35]||(t[35]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"TCP Port",-1)),u(e("input",{"onUpdate:modelValue":t[6]||(t[6]=r=>l.value.settings.tcp_port=r),type:"number",min:"1",max:"65535",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[c,l.value.settings.tcp_port,void 0,{number:!0}]])]),e("div",null,[t[36]||(t[36]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Bind Address",-1)),u(e("input",{"onUpdate:modelValue":t[7]||(t[7]=r=>l.value.settings.bind_address=r),type:"text",placeholder:"0.0.0.0",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[c,l.value.settings.bind_address]])])])]),e("div",{class:"flex justify-end gap-3 mt-6"},[e("button",{onClick:j,class:"px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),e("button",{onClick:F,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"}," Create ")])])])):x("",!0),w.value&&n.value?(a(),s("div",Fe,[e("div",Pe,[t[44]||(t[44]=e("h2",{class:"text-xl font-bold text-content-primary dark:text-content-primary mb-4"},"Edit Companion",-1)),e("div",Se,[e("div",null,[t[38]||(t[38]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Current Name",-1)),e("input",{value:n.value.name,disabled:"",type:"text",class:"w-full bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-muted dark:text-content-muted cursor-not-allowed"},null,8,He)]),e("div",null,[t[39]||(t[39]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"New Name (optional)",-1)),u(e("input",{"onUpdate:modelValue":t[8]||(t[8]=r=>n.value.new_name=r),type:"text",placeholder:"Leave empty to keep current name",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[c,n.value.new_name]])]),e("div",null,[e("label",Ke,[t[40]||(t[40]=N(" Identity Key (Optional) ",-1)),e("button",{onClick:t[9]||(t[9]=r=>b.value=!b.value),type:"button",class:"ml-2 text-primary/70 hover:text-primary text-xs underline"},i(b.value?"Hide":"Show/Edit"),1)]),b.value?(a(),s("div",Le,[u(e("input",{"onUpdate:modelValue":t[10]||(t[10]=r=>n.value.identity_key=r),type:"text",placeholder:"Leave empty to keep current key",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary font-mono text-sm placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[c,n.value.identity_key]])])):x("",!0)]),e("div",null,[t[41]||(t[41]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Node Name",-1)),u(e("input",{"onUpdate:modelValue":t[11]||(t[11]=r=>n.value.settings.node_name=r),type:"text",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[c,n.value.settings.node_name]])]),e("div",ze,[e("div",null,[t[42]||(t[42]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"TCP Port",-1)),u(e("input",{"onUpdate:modelValue":t[12]||(t[12]=r=>n.value.settings.tcp_port=r),type:"number",min:"1",max:"65535",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[c,n.value.settings.tcp_port,void 0,{number:!0}]])]),e("div",null,[t[43]||(t[43]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Bind Address",-1)),u(e("input",{"onUpdate:modelValue":t[13]||(t[13]=r=>n.value.settings.bind_address=r),type:"text",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[c,n.value.settings.bind_address]])])])]),e("div",{class:"flex justify-end gap-3 mt-6"},[e("button",{onClick:j,class:"px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),e("button",{onClick:P,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"}," Update ")])])])):x("",!0)]),A(q,{show:h.value,title:"Delete Companion",message:`Are you sure you want to delete '${_.value}'? Restart required to fully remove.`,"confirm-text":"Delete","cancel-text":"Cancel",variant:"danger",onClose:t[14]||(t[14]=r=>h.value=!1),onConfirm:H},null,8,["show","message"]),A(W,{show:$.value,message:I.value.message,variant:I.value.variant,onClose:t[15]||(t[15]=r=>$.value=!1)},null,8,["show","message","variant"])],64))}});export{We as default}; diff --git a/repeater/web/html/assets/Configuration-CkZchTBn.js b/repeater/web/html/assets/Configuration-CkZchTBn.js new file mode 100644 index 0000000..709c6f9 --- /dev/null +++ b/repeater/web/html/assets/Configuration-CkZchTBn.js @@ -0,0 +1,2 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/leaflet-src-BtisrQHC.js","assets/_commonjsHelpers-CqkleIqs.js"])))=>i.map(i=>d[i]); +import{a as G,M as re,c as B,r as m,D as te,b as r,g as L,e,t as c,F as K,w as S,v as V,h as Z,q as oe,k as U,L as q,p as o,P as we,s as Y,E as ie,S as me,x as pe,f as O,y as de,d as _e,U as ce,j as D,l as ve,N as xe,V as be,T as Ce,i as Q,W as ne,o as ae,u as J,X as $e,Q as ee}from"./index-DyUIpN7m.js";/* empty css */import{_ as Me}from"./ConfirmDialog.vue_vue_type_script_setup_true_lang-CVxh_fqf.js";import{g as Ae,s as Se}from"./preferences-DtwbSSgO.js";const je={class:"space-y-4"},Te={key:0,class:"bg-green-100 dark:bg-green-500/20 border border-green-500/50 rounded-lg p-3"},Ne={class:"text-green-600 dark:text-green-400 text-sm"},Be={key:1,class:"bg-red-100 dark:bg-red-500/20 border border-red-500/50 rounded-lg p-3"},Ee={class:"text-red-600 dark:text-red-400 text-sm"},Le={class:"flex justify-end gap-2"},Fe=["disabled"],Pe=["disabled"],ze={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},Ve={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},De={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Ie={key:1,class:"flex items-center gap-2"},He={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Ue={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Re={key:1},Ke=["value"],Oe={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},qe={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},We={key:1},Ge=["value"],Qe={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Ye={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Xe={key:1,class:"flex items-center gap-2"},Je={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Ze={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},et={key:1},tt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 gap-1"},ot={class:"text-content-primary dark:text-content-primary font-mono text-sm"},rt={key:2,class:"bg-yellow-500/10 dark:bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3"},st=G({__name:"RadioSettings",setup(I){const _=re(),d=B(()=>_.stats?.config?.radio||{}),v=m(!1),k=m(!1),i=m(null),u=m(null),s=m(0),p=m(0),y=m(0),h=m(0),N=m(0),M=m(0),$=[{value:7.8,label:"7.8 kHz"},{value:10.4,label:"10.4 kHz"},{value:15.6,label:"15.6 kHz"},{value:20.8,label:"20.8 kHz"},{value:31.25,label:"31.25 kHz"},{value:41.7,label:"41.7 kHz"},{value:62.5,label:"62.5 kHz"},{value:125,label:"125 kHz"},{value:250,label:"250 kHz"},{value:500,label:"500 kHz"}];te(d,C=>{C&&!v.value&&(s.value=C.frequency?Number((C.frequency/1e6).toFixed(3)):0,p.value=C.spreading_factor??0,y.value=C.bandwidth?Number((C.bandwidth/1e3).toFixed(1)):0,h.value=C.tx_power??0,N.value=C.coding_rate??0,M.value=C.preamble_length??0)},{immediate:!0});const n=B(()=>{const C=d.value.frequency;return C?(C/1e6).toFixed(3)+" MHz":"Not set"}),t=B(()=>{const C=d.value.bandwidth;return C?(C/1e3).toFixed(1)+" kHz":"Not set"}),a=B(()=>{const C=d.value.tx_power;return C!==void 0?C+" dBm":"Not set"}),A=B(()=>{const C=d.value.coding_rate;return C?"4/"+C:"Not set"}),b=B(()=>{const C=d.value.preamble_length;return C?C+" symbols":"Not set"}),x=B(()=>d.value.spreading_factor??"Not set"),g=()=>{v.value=!0,i.value=null,u.value=null},z=()=>{v.value=!1,i.value=null;const C=d.value;s.value=C.frequency?Number((C.frequency/1e6).toFixed(3)):0,p.value=C.spreading_factor??0,y.value=C.bandwidth?Number((C.bandwidth/1e3).toFixed(1)):0,h.value=C.tx_power??0,N.value=C.coding_rate??0,M.value=C.preamble_length??0},W=async()=>{k.value=!0,i.value=null,u.value=null;try{const C={};s.value&&(C.frequency=s.value*1e6),p.value&&(C.spreading_factor=p.value),y.value&&(C.bandwidth=y.value*1e3),h.value&&(C.tx_power=h.value),N.value&&(C.coding_rate=N.value);const P=(await q.post("/update_radio_config",C)).data;P.message||P.persisted?(u.value=P.message||"Settings saved successfully",v.value=!1,await _.fetchStats(),setTimeout(()=>{u.value=null},3e3)):P.error?i.value=P.error:i.value="Unknown response from server"}catch(C){console.error("Failed to update radio settings:",C);const j=C;i.value=j.response?.data?.error||"Failed to update settings"}finally{k.value=!1}};return(C,j)=>(o(),r("div",je,[u.value?(o(),r("div",Te,[e("p",Ne,c(u.value),1)])):L("",!0),i.value?(o(),r("div",Be,[e("p",Ee,c(i.value),1)])):L("",!0),e("div",Le,[v.value?(o(),r(K,{key:1},[e("button",{onClick:z,disabled:k.value,class:"px-3 sm:px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"}," Cancel ",8,Fe),e("button",{onClick:W,disabled:k.value,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"},c(k.value?"Saving...":"Save Changes"),9,Pe)],64)):(o(),r("button",{key:0,onClick:g,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Edit Settings "))]),e("div",ze,[e("div",Ve,[j[6]||(j[6]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Frequency",-1)),v.value?(o(),r("div",Ie,[S(e("input",{"onUpdate:modelValue":j[0]||(j[0]=P=>s.value=P),type:"number",step:"0.001",min:"100",max:"1000",class:"w-32 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,s.value,void 0,{number:!0}]]),j[5]||(j[5]=e("span",{class:"text-content-muted dark:text-content-muted text-sm"},"MHz",-1))])):(o(),r("div",De,c(n.value),1))]),e("div",He,[j[7]||(j[7]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Spreading Factor",-1)),v.value?(o(),r("div",Re,[S(e("select",{"onUpdate:modelValue":j[1]||(j[1]=P=>p.value=P),class:"px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},[(o(),r(K,null,Z([5,6,7,8,9,10,11,12],P=>e("option",{key:P,value:P},c(P),9,Ke)),64))],512),[[oe,p.value,void 0,{number:!0}]])])):(o(),r("div",Ue,c(x.value),1))]),e("div",Oe,[j[8]||(j[8]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Bandwidth",-1)),v.value?(o(),r("div",We,[S(e("select",{"onUpdate:modelValue":j[2]||(j[2]=P=>y.value=P),class:"px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},[(o(),r(K,null,Z($,P=>e("option",{key:P.value,value:P.value},c(P.label),9,Ge)),64))],512),[[oe,y.value,void 0,{number:!0}]])])):(o(),r("div",qe,c(t.value),1))]),e("div",Qe,[j[10]||(j[10]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"TX Power",-1)),v.value?(o(),r("div",Xe,[S(e("input",{"onUpdate:modelValue":j[3]||(j[3]=P=>h.value=P),type:"number",min:"2",max:"30",class:"w-20 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,h.value,void 0,{number:!0}]]),j[9]||(j[9]=e("span",{class:"text-content-muted dark:text-content-muted text-sm"},"dBm",-1))])):(o(),r("div",Ye,c(a.value),1))]),e("div",Je,[j[12]||(j[12]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Coding Rate",-1)),v.value?(o(),r("div",et,[S(e("select",{"onUpdate:modelValue":j[4]||(j[4]=P=>N.value=P),class:"px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},j[11]||(j[11]=[e("option",{value:5},"4/5",-1),e("option",{value:6},"4/6",-1),e("option",{value:7},"4/7",-1),e("option",{value:8},"4/8",-1)]),512),[[oe,N.value,void 0,{number:!0}]])])):(o(),r("div",Ze,c(A.value),1))]),e("div",tt,[j[13]||(j[13]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Preamble Length",-1)),e("span",ot,c(b.value),1)])]),v.value?(o(),r("div",rt,j[14]||(j[14]=[e("p",{class:"text-yellow-700 dark:text-yellow-400 text-xs"},[e("strong",null,"Note:"),U(" Radio hardware changes (frequency, bandwidth, spreading factor, coding rate) may require a service restart to apply. ")],-1)]))):L("",!0)]))}}),nt={class:"glass-card border border-stroke-subtle dark:border-white/20 rounded-[15px] w-full max-w-3xl max-h-[90vh] flex flex-col shadow-2xl"},at={class:"flex-1 relative min-h-[400px]"},lt={class:"p-6 border-t border-stroke-subtle dark:border-stroke/10 space-y-4"},dt={class:"grid grid-cols-2 gap-4"},it=G({__name:"LocationPicker",props:{isOpen:{type:Boolean},latitude:{},longitude:{}},emits:["close","select"],setup(I,{emit:_}){const d=I,v=_,k=m(null),i=m(d.latitude||0),u=m(d.longitude||0);let s=null,p=null;const y=async()=>{if(k.value){h();try{const n=(await me(async()=>{const{default:b}=await import("./leaflet-src-BtisrQHC.js").then(x=>x.l);return{default:b}},__vite__mapDeps([0,1]))).default;delete n.Icon.Default.prototype._getIconUrl,n.Icon.Default.mergeOptions({iconRetinaUrl:"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",iconUrl:"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",shadowUrl:"https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png"}),await ie();const t=i.value||0,a=u.value||0,A=t===0&&a===0?2:13;s=n.map(k.value).setView([t,a],A);try{const b=n.tileLayer("https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png",{maxZoom:19,attribution:'© OpenStreetMap contributors © CARTO',errorTileUrl:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}),x=n.tileLayer("https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png",{maxZoom:19,attribution:"",errorTileUrl:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="});b.addTo(s),x.addTo(s)}catch(b){console.warn("Error loading tiles:",b)}(t!==0||a!==0)&&(p=n.marker([t,a]).addTo(s)),s.on("click",b=>{i.value=b.latlng.lat,u.value=b.latlng.lng,p?p.setLatLng(b.latlng):p=n.marker(b.latlng).addTo(s)}),setTimeout(()=>{s?.invalidateSize()},200)}catch(n){console.error("Failed to initialize map:",n)}}},h=()=>{s&&(s.remove(),s=null,p=null)};te(()=>d.isOpen,async n=>{n?(await ie(),await y()):h()}),te(()=>[d.latitude,d.longitude],([n,t])=>{i.value=n,u.value=t});const N=()=>{v("select",{latitude:i.value,longitude:u.value}),v("close")},M=()=>{v("close")},$=()=>{navigator.geolocation?navigator.geolocation.getCurrentPosition(async n=>{if(i.value=n.coords.latitude,u.value=n.coords.longitude,s){s.setView([i.value,u.value],13);const t=(await me(async()=>{const{default:a}=await import("./leaflet-src-BtisrQHC.js").then(A=>A.l);return{default:a}},__vite__mapDeps([0,1]))).default;p?p.setLatLng([i.value,u.value]):p=t.marker([i.value,u.value]).addTo(s)}},n=>{console.error("Error getting location:",n),alert("Unable to get current location. Please check browser permissions.")}):alert("Geolocation is not supported by this browser.")};return we(()=>{h()}),(n,t)=>n.isOpen?(o(),r("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm",onClick:Y(M,["self"])},[e("div",nt,[e("div",{class:"flex items-center justify-between p-6 border-b border-stroke-subtle dark:border-stroke/10"},[t[3]||(t[3]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"Select Location",-1)),e("button",{onClick:M,class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},t[2]||(t[2]=[e("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),e("div",at,[e("div",{ref_key:"mapContainer",ref:k,class:"absolute inset-0 rounded-b-[15px] overflow-hidden"},null,512)]),e("div",lt,[e("div",dt,[e("div",null,[t[4]||(t[4]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-muted mb-2"},"Latitude",-1)),S(e("input",{"onUpdate:modelValue":t[0]||(t[0]=a=>i.value=a),type:"number",step:"0.000001",class:"w-full px-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary focus:outline-none focus:border-primary",readonly:""},null,512),[[V,i.value,void 0,{number:!0}]])]),e("div",null,[t[5]||(t[5]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-muted mb-2"},"Longitude",-1)),S(e("input",{"onUpdate:modelValue":t[1]||(t[1]=a=>u.value=a),type:"number",step:"0.000001",class:"w-full px-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary focus:outline-none focus:border-primary",readonly:""},null,512),[[V,u.value,void 0,{number:!0}]])])]),e("div",{class:"flex gap-3"},[e("button",{onClick:$,class:"flex-1 px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm flex items-center justify-center gap-2"},t[6]||(t[6]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"}),e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 11a3 3 0 11-6 0 3 3 0 016 0z"})],-1),U(" Use Current Location ",-1)])),e("button",{onClick:M,class:"px-6 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm"}," Cancel "),e("button",{onClick:N,class:"px-6 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Select Location ")]),t[7]||(t[7]=e("p",{class:"text-content-muted dark:text-content-muted text-xs text-center"},"Click on the map to select a location",-1))])])])):L("",!0)}}),ct=pe(it,[["__scopeId","data-v-186d3c86"]]),ut={class:"space-y-4"},mt={key:0,class:"bg-green-100 dark:bg-green-500/10 border border-green-300 dark:border-green-500/30 rounded-lg p-3"},pt={class:"text-green-700 dark:text-green-400 text-sm"},vt={key:1,class:"bg-red-100 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded-lg p-3"},xt={class:"text-red-700 dark:text-red-400 text-sm"},bt={class:"flex justify-end gap-2"},kt=["disabled"],gt=["disabled"],yt={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},ft={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},ht={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm break-all"},wt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},_t={class:"text-content-primary dark:text-content-primary font-mono text-xs break-all"},Ct={class:"flex flex-col sm:flex-row sm:justify-between sm:items-start py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},$t={class:"text-content-primary dark:text-content-primary font-mono text-xs break-all sm:text-right sm:max-w-xs"},Mt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},At={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},St={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},jt={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Tt={key:0,class:"flex justify-end"},Nt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Bt={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Et={class:"flex flex-col py-2 gap-2"},Lt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-start gap-1"},Ft={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm sm:ml-4"},Pt={key:1,class:"flex items-center gap-2"},zt=G({__name:"RepeaterSettings",setup(I){const _=re(),d=B(()=>_.stats?.config||{}),v=B(()=>d.value.repeater||{}),k=B(()=>_.stats),i=m(!1),u=m(!1),s=m(null),p=m(null),y=m(!1),h=m(""),N=m(0),M=m(0),$=m(0);te([d,v],()=>{i.value||(h.value=d.value.node_name||"",N.value=v.value.latitude||0,M.value=v.value.longitude||0,$.value=v.value.send_advert_interval_hours||0)},{immediate:!0});const n=B(()=>d.value.node_name||"Not set"),t=B(()=>k.value?.local_hash||"Not available"),a=B(()=>{const F=k.value?.public_key;return!F||F==="Not set"?"Not set":F}),A=B(()=>{const F=v.value.latitude;return F&&F!==0?F.toFixed(6):"Not set"}),b=B(()=>{const F=v.value.longitude;return F&&F!==0?F.toFixed(6):"Not set"}),x=B(()=>{const F=v.value.mode;return F?F.charAt(0).toUpperCase()+F.slice(1):"Not set"}),g=B(()=>{const F=v.value.send_advert_interval_hours;return F===void 0?"Not set":F===0?"Disabled":F+" hour"+(F!==1?"s":"")}),z=()=>{i.value=!0,s.value=null,p.value=null},W=()=>{i.value=!1,s.value=null,h.value=d.value.node_name||"",N.value=v.value.latitude||0,M.value=v.value.longitude||0,$.value=v.value.send_advert_interval_hours||0},C=async()=>{u.value=!0,s.value=null,p.value=null;try{const F={};h.value&&(F.node_name=h.value),F.latitude=N.value,F.longitude=M.value,F.flood_advert_interval_hours=$.value;const w=(await q.post("/update_radio_config",F)).data;w.message||w.persisted?(p.value=w.message||"Settings saved successfully",i.value=!1,await _.fetchStats(),setTimeout(()=>{p.value=null},3e3)):w.error?s.value=w.error:s.value="Unknown response from server"}catch(F){console.error("Failed to update repeater settings:",F);const E=F;s.value=E.response?.data?.error||"Failed to update settings"}finally{u.value=!1}},j=()=>{y.value=!0},P=F=>{N.value=F.latitude,M.value=F.longitude};return(F,E)=>(o(),r("div",ut,[p.value?(o(),r("div",mt,[e("p",pt,c(p.value),1)])):L("",!0),s.value?(o(),r("div",vt,[e("p",xt,c(s.value),1)])):L("",!0),e("div",bt,[i.value?(o(),r(K,{key:1},[e("button",{onClick:W,disabled:u.value,class:"px-3 sm:px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"}," Cancel ",8,kt),e("button",{onClick:C,disabled:u.value,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"},c(u.value?"Saving...":"Save Changes"),9,gt)],64)):(o(),r("button",{key:0,onClick:z,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Edit Settings "))]),e("div",yt,[e("div",ft,[E[5]||(E[5]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Node Name",-1)),i.value?S((o(),r("input",{key:1,"onUpdate:modelValue":E[0]||(E[0]=w=>h.value=w),type:"text",maxlength:"50",class:"w-full sm:w-64 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary",placeholder:"Enter node name"},null,512)),[[V,h.value]]):(o(),r("div",ht,c(n.value),1))]),e("div",wt,[E[6]||(E[6]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Local Hash",-1)),e("span",_t,c(t.value),1)]),e("div",Ct,[E[7]||(E[7]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm flex-shrink-0"},"Public Key",-1)),e("span",$t,c(a.value),1)]),e("div",Mt,[E[8]||(E[8]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Latitude",-1)),i.value?S((o(),r("input",{key:1,"onUpdate:modelValue":E[1]||(E[1]=w=>N.value=w),type:"number",step:"0.000001",min:"-90",max:"90",class:"w-full sm:w-48 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,N.value,void 0,{number:!0}]]):(o(),r("div",At,c(A.value),1))]),e("div",St,[E[9]||(E[9]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Longitude",-1)),i.value?S((o(),r("input",{key:1,"onUpdate:modelValue":E[2]||(E[2]=w=>M.value=w),type:"number",step:"0.000001",min:"-180",max:"180",class:"w-full sm:w-48 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,M.value,void 0,{number:!0}]]):(o(),r("div",jt,c(b.value),1))]),i.value?(o(),r("div",Tt,[e("button",{onClick:j,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm flex items-center gap-2",title:"Pick location on map"},E[10]||(E[10]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"}),e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 11a3 3 0 11-6 0 3 3 0 016 0z"})],-1),U(" Pick Location on Map ",-1)]))])):L("",!0),e("div",Nt,[E[11]||(E[11]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Mode",-1)),e("span",Bt,c(x.value),1)]),e("div",Et,[e("div",Lt,[E[13]||(E[13]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Periodic Advertisement Interval",-1)),i.value?(o(),r("div",Pt,[S(e("input",{"onUpdate:modelValue":E[3]||(E[3]=w=>$.value=w),type:"number",min:"0",max:"48",class:"w-20 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,$.value,void 0,{number:!0}]]),E[12]||(E[12]=e("span",{class:"text-content-muted dark:text-content-muted text-sm"},"hours",-1))])):(o(),r("div",Ft,c(g.value),1))]),E[14]||(E[14]=e("span",{class:"text-content-muted dark:text-content-muted text-xs"},"How often the repeater sends an advertisement packet (0 = disabled, 3-48 hours)",-1))])]),O(ct,{"is-open":y.value,latitude:N.value,longitude:M.value,onClose:E[4]||(E[4]=w=>y.value=!1),onSelect:P},null,8,["is-open","latitude","longitude"])]))}}),Vt={class:"space-y-4"},Dt={key:0,class:"bg-green-100 dark:bg-green-500/20 border border-green-500 dark:border-green-500/50 rounded-lg p-3 text-green-700 dark:text-green-400 text-sm"},It={key:1,class:"bg-red-100 dark:bg-red-500/20 border border-red-500 dark:border-red-500/50 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm"},Ht={class:"flex justify-end gap-2"},Ut=["disabled"],Rt=["disabled"],Kt={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},Ot={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},qt={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Wt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 gap-1"},Gt={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Qt=G({__name:"DutyCycle",setup(I){const _=re(),d=B(()=>_.stats?.config?.duty_cycle||{}),v=B(()=>{const n=d.value.max_airtime_percent;return typeof n=="number"?n.toFixed(1)+"%":n&&typeof n=="object"&&"parsedValue"in n?(n.parsedValue||0).toFixed(1)+"%":"Not set"}),k=B(()=>d.value.enforcement_enabled?"Enabled":"Disabled"),i=m(!1),u=m(!1),s=m(""),p=m(""),y=m(0),h=m(!0),N=()=>{const n=d.value.max_airtime_percent;typeof n=="number"?y.value=n:n&&typeof n=="object"&&"parsedValue"in n?y.value=n.parsedValue||0:y.value=6,h.value=d.value.enforcement_enabled!==!1,i.value=!0,s.value="",p.value=""},M=()=>{i.value=!1,s.value="",p.value=""},$=async()=>{u.value=!0,p.value="",s.value="";try{const t=(await de.post("/api/update_duty_cycle_config",{max_airtime_percent:y.value,enforcement_enabled:h.value})).data;t.message||t.persisted?(s.value=t.message||"Settings saved successfully",i.value=!1,await _.fetchStats(),setTimeout(()=>{s.value=""},3e3)):p.value="Failed to save settings"}catch(n){console.error("Failed to save duty cycle settings:",n),p.value=n.response?.data?.error||"Failed to save settings"}finally{u.value=!1}};return(n,t)=>(o(),r("div",Vt,[s.value?(o(),r("div",Dt,c(s.value),1)):L("",!0),p.value?(o(),r("div",It,c(p.value),1)):L("",!0),e("div",Ht,[i.value?(o(),r(K,{key:1},[e("button",{onClick:M,disabled:u.value,class:"px-3 sm:px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"}," Cancel ",8,Ut),e("button",{onClick:$,disabled:u.value,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"},c(u.value?"Saving...":"Save Changes"),9,Rt)],64)):(o(),r("button",{key:0,onClick:N,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Edit Settings "))]),e("div",Kt,[e("div",Ot,[t[2]||(t[2]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Max Airtime %",-1)),i.value?S((o(),r("input",{key:1,"onUpdate:modelValue":t[0]||(t[0]=a=>y.value=a),type:"number",step:"0.1",min:"0.1",max:"100",class:"w-full sm:w-32 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,y.value,void 0,{number:!0}]]):(o(),r("div",qt,c(v.value),1))]),e("div",Wt,[t[4]||(t[4]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Enforcement",-1)),i.value?S((o(),r("select",{key:1,"onUpdate:modelValue":t[1]||(t[1]=a=>h.value=a),class:"w-full sm:w-32 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},t[3]||(t[3]=[e("option",{value:!0},"Enabled",-1),e("option",{value:!1},"Disabled",-1)]),512)),[[oe,h.value]]):(o(),r("div",Gt,c(k.value),1))])])]))}}),Yt={class:"space-y-4"},Xt={key:0,class:"bg-green-100 dark:bg-green-500/20 border border-green-500 dark:border-green-500/50 rounded-lg p-3 text-green-700 dark:text-green-400 text-sm"},Jt={key:1,class:"bg-red-100 dark:bg-red-500/20 border border-red-500 dark:border-red-500/50 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm"},Zt={class:"flex justify-end gap-2"},eo=["disabled"],to=["disabled"],oo={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},ro={class:"flex flex-col py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-2"},so={class:"flex flex-col sm:flex-row sm:justify-between sm:items-start gap-1"},no={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm sm:ml-4"},ao={class:"flex flex-col py-2 gap-2"},lo={class:"flex flex-col sm:flex-row sm:justify-between sm:items-start gap-1"},io={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm sm:ml-4"},co=G({__name:"TransmissionDelays",setup(I){const _=re(),d=B(()=>_.stats?.config?.delays||{}),v=B(()=>{const n=d.value.tx_delay_factor;if(n&&typeof n=="object"&&n!==null&&"parsedValue"in n){const t=n.parsedValue;if(typeof t=="number")return t.toFixed(2)+"x"}return"Not set"}),k=B(()=>{const n=d.value.direct_tx_delay_factor;return typeof n=="number"?n.toFixed(2)+"s":"Not set"}),i=m(!1),u=m(!1),s=m(""),p=m(""),y=m(0),h=m(0),N=()=>{const n=d.value.tx_delay_factor;n&&typeof n=="object"&&"parsedValue"in n?y.value=n.parsedValue||1:typeof n=="number"?y.value=n:y.value=1;const t=d.value.direct_tx_delay_factor;h.value=typeof t=="number"?t:.5,i.value=!0,s.value="",p.value=""},M=()=>{i.value=!1,s.value="",p.value=""},$=async()=>{u.value=!0,p.value="",s.value="";try{const t=(await de.post("/api/update_radio_config",{tx_delay_factor:y.value,direct_tx_delay_factor:h.value})).data;t.message||t.persisted?(s.value=t.message||"Settings saved successfully",i.value=!1,await _.fetchStats(),setTimeout(()=>{s.value=""},3e3)):p.value="Failed to save settings"}catch(n){console.error("Failed to save delay settings:",n),p.value=n.response?.data?.error||"Failed to save settings"}finally{u.value=!1}};return(n,t)=>(o(),r("div",Yt,[s.value?(o(),r("div",Xt,c(s.value),1)):L("",!0),p.value?(o(),r("div",Jt,c(p.value),1)):L("",!0),e("div",Zt,[i.value?(o(),r(K,{key:1},[e("button",{onClick:M,disabled:u.value,class:"px-3 sm:px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"}," Cancel ",8,eo),e("button",{onClick:$,disabled:u.value,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"},c(u.value?"Saving...":"Save Changes"),9,to)],64)):(o(),r("button",{key:0,onClick:N,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Edit Settings "))]),e("div",oo,[e("div",ro,[e("div",so,[t[2]||(t[2]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Flood TX Delay Factor",-1)),i.value?S((o(),r("input",{key:1,"onUpdate:modelValue":t[0]||(t[0]=a=>y.value=a),type:"number",step:"0.1",min:"0",max:"5",class:"w-full sm:w-32 px-3 py-1.5 bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,y.value,void 0,{number:!0}]]):(o(),r("div",no,c(v.value),1))]),t[3]||(t[3]=e("span",{class:"text-content-muted dark:text-content-muted text-xs"},"Multiplier for flood packet transmission delays (collision avoidance)",-1))]),e("div",ao,[e("div",lo,[t[4]||(t[4]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Direct TX Delay Factor",-1)),i.value?S((o(),r("input",{key:1,"onUpdate:modelValue":t[1]||(t[1]=a=>h.value=a),type:"number",step:"0.1",min:"0",max:"5",class:"w-full sm:w-32 px-3 py-1.5 bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,h.value,void 0,{number:!0}]]):(o(),r("div",io,c(k.value),1))]),t[5]||(t[5]=e("span",{class:"text-content-muted dark:text-content-muted text-xs"},"Base delay for direct-routed packet transmission (seconds)",-1))])])]))}}),ke=_e("treeState",()=>{const I=ce(new Set),_=ce({value:null}),d=s=>{I.add(s)},v=s=>{I.delete(s)};return{expandedNodes:I,selectedNodeId:_,addExpandedNode:d,removeExpandedNode:v,isNodeExpanded:s=>I.has(s),setSelectedNode:s=>{_.value=s},toggleExpanded:s=>{I.has(s)?v(s):d(s)}}}),uo={class:"select-none"},mo={class:"flex-shrink-0"},po={key:0,class:"w-3.5 h-3.5 sm:w-4 sm:h-4 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},vo={key:1,class:"w-3.5 h-3.5 sm:w-4 sm:h-4 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},xo={key:0,class:"hidden sm:flex items-center gap-1 ml-2"},bo={class:"relative group"},ko=["title"],go={key:0,class:"text-xs font-mono text-white/50 bg-white/5 px-1.5 py-0.5 rounded border border-white/10"},yo={class:"flex justify-between items-start mb-4"},fo={class:"bg-black/20 border border-white/10 rounded-md p-4 mb-4"},ho={class:"text-sm font-mono text-white/80 break-all leading-relaxed"},wo={class:"flex items-center gap-1 sm:gap-2 ml-auto flex-shrink-0"},_o={key:0,class:"hidden sm:flex items-center gap-1"},Co=["title"],$o={key:1,class:"hidden sm:flex items-center gap-1"},Mo={key:2,class:"hidden sm:inline-block px-2 py-1 bg-white/10 text-white/60 text-xs rounded-full ml-1"},Ao={key:0,class:"space-y-1"},So=G({__name:"TreeNode",props:{node:{},selectedNodeId:{},level:{},disabled:{type:Boolean}},emits:["select"],setup(I,{emit:_}){const d=I,v=_,k=ke(),i=m(!1),u=B({get:()=>k.isNodeExpanded(d.node.id),set:t=>{t?k.addExpandedNode(d.node.id):k.removeExpandedNode(d.node.id)}}),s=B(()=>d.node.children.length>0);function p(t){if(!t)return"Never";const A=new Date().getTime()-t.getTime(),b=Math.floor(A/(1e3*60)),x=Math.floor(A/(1e3*60*60)),g=Math.floor(A/(1e3*60*60*24)),z=Math.floor(g/365);return b<60?`${b}m ago`:x<24?`${x}h ago`:g<365?`${g}d ago`:`${z}y ago`}function y(t){return t?t.length<=16?t:`${t.slice(0,8)}...${t.slice(-8)}`:"No key"}function h(){if(s.value){const t=!u.value;u.value=t}}function N(){v("select",d.node.id)}function M(t){v("select",t)}function $(t){t.stopPropagation(),i.value=!i.value}function n(t){t.stopPropagation(),d.node.transport_key&&window.navigator?.clipboard&&window.navigator.clipboard.writeText(d.node.transport_key)}return(t,a)=>{const A=be("TreeNode",!0);return o(),r("div",uo,[e("div",{class:D(["flex flex-wrap sm:flex-nowrap items-start sm:items-center gap-1 sm:gap-2 py-2 px-2 sm:px-3 rounded-lg cursor-pointer transition-all duration-200",d.disabled?"opacity-50 cursor-not-allowed":"hover:bg-white/5",t.selectedNodeId===t.node.id&&!d.disabled?"bg-primary/20 text-primary":"text-white/80 hover:text-white",`ml-${t.level*4}`]),onClick:a[3]||(a[3]=b=>!d.disabled&&N())},[e("div",{class:"flex-shrink-0 w-3 h-3 sm:w-4 sm:h-4 flex items-center justify-center",onClick:Y(h,["stop"])},[s.value?(o(),r("svg",{key:0,class:D(["w-2.5 h-2.5 sm:w-3 sm:h-3 transition-transform duration-200",u.value?"rotate-90":"rotate-0"]),fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},a[4]||(a[4]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 5l7 7-7 7"},null,-1)]),2)):L("",!0)]),e("div",mo,[d.node.name.startsWith("#")?(o(),r("svg",po,a[5]||(a[5]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(o(),r("svg",vo,a[6]||(a[6]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"},null,-1)])))]),e("span",{class:D(["font-mono text-xs sm:text-sm transition-colors duration-200 break-all",t.selectedNodeId===t.node.id?"text-primary font-medium":""])},c(t.node.name),3),t.node.transport_key?(o(),r("div",xo,[e("div",bo,[e("button",{onClick:$,class:"p-1 rounded hover:bg-white/10 transition-colors",title:i.value?"Hide full key":"Show full key"},a[7]||(a[7]=[e("svg",{class:"w-3 h-3 text-white/60 hover:text-white/80",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 12a3 3 0 11-6 0 3 3 0 016 0z"}),e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"})],-1)]),8,ko),i.value?L("",!0):(o(),r("span",go,c(y(t.node.transport_key)),1)),i.value?(o(),r("div",{key:1,class:"fixed inset-0 z-[9998] flex items-center justify-center bg-black/70 backdrop-blur-md",onClick:a[2]||(a[2]=b=>i.value=!1)},[e("div",{class:"bg-black/20 border border-white/20 rounded-lg shadow-lg p-6 max-w-2xl w-full mx-4",onClick:a[1]||(a[1]=Y(()=>{},["stop"]))},[e("div",yo,[a[9]||(a[9]=e("h3",{class:"text-lg font-semibold text-white"},"Transport Key",-1)),e("button",{onClick:a[0]||(a[0]=b=>i.value=!1),class:"text-white/60 hover:text-white transition-colors"},a[8]||(a[8]=[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),e("div",fo,[e("div",ho,c(t.node.transport_key),1)]),e("div",{class:"flex justify-end"},[e("button",{onClick:n,class:"px-4 py-2 bg-accent-green/20 hover:bg-accent-green/30 border border-accent-green/50 text-accent-green rounded-lg transition-colors flex items-center gap-2",title:"Copy to clipboard"},a[10]||(a[10]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"})],-1),U(" Copy Key ",-1)]))])])])):L("",!0)])])):L("",!0),e("div",wo,[t.node.last_used?(o(),r("div",_o,[a[11]||(a[11]=e("svg",{class:"w-3 h-3 text-white/40",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"})],-1)),e("span",{class:"text-xs text-white/50",title:t.node.last_used.toLocaleString()},c(p(t.node.last_used)),9,Co)])):(o(),r("div",$o,a[12]||(a[12]=[e("svg",{class:"w-3 h-3 text-white/30",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"})],-1),e("span",{class:"text-xs text-white/30 italic"},"Never",-1)]))),e("span",{class:D(["px-1.5 sm:px-2 py-0.5 text-[10px] sm:text-xs font-medium rounded-md transition-colors",t.node.floodPolicy==="allow"?"bg-accent-green/10 text-accent-green/90 border border-accent-green/20":"bg-accent-red/10 text-accent-red/90 border border-accent-red/20"])},c(t.node.floodPolicy==="allow"?"ALLOW":"DENY"),3),s.value?(o(),r("span",Mo," > "+c(t.node.children.length),1)):L("",!0)])],2),O(Ce,{"enter-active-class":"transition-all duration-300 ease-out","enter-from-class":"opacity-0 max-h-0 overflow-hidden","enter-to-class":"opacity-100 max-h-screen overflow-visible","leave-active-class":"transition-all duration-300 ease-in","leave-from-class":"opacity-100 max-h-screen overflow-visible","leave-to-class":"opacity-0 max-h-0 overflow-hidden"},{default:ve(()=>[u.value&&t.node.children.length>0?(o(),r("div",Ao,[(o(!0),r(K,null,Z(t.node.children,b=>(o(),xe(A,{key:b.id,node:b,"selected-node-id":t.selectedNodeId,level:t.level+1,disabled:d.disabled,onSelect:M},null,8,["node","selected-node-id","level","disabled"]))),128))])):L("",!0)]),_:1})])}}}),jo=pe(So,[["__scopeId","data-v-59e9974c"]]),To={class:"flex items-center justify-between mb-6"},No={class:"text-content-secondary dark:text-content-muted text-sm mt-1"},Bo={key:0},Eo={class:"text-primary font-mono"},Lo={key:1},Fo={for:"keyName",class:"block text-sm font-medium text-white mb-2"},Po={class:"flex items-center gap-2"},zo={key:0,class:"w-4 h-4 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Vo={key:1,class:"w-4 h-4 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Do={class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4"},Io={class:"flex items-center gap-3 mb-2"},Ho={class:"flex items-center gap-2"},Uo={key:0,class:"w-5 h-5 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Ro={key:1,class:"w-5 h-5 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Ko={class:"text-content-secondary dark:text-content-muted text-sm"},Oo={class:"grid grid-cols-2 gap-3"},qo={class:"relative cursor-pointer group"},Wo={class:"relative cursor-pointer group"},Go={class:"flex gap-3 pt-4"},Qo=["disabled"],Yo=G({__name:"AddKeyModal",props:{show:{type:Boolean},selectedNodeName:{},selectedNodeId:{}},emits:["close","add"],setup(I,{emit:_}){const d=I,v=_,k=m(""),i=m(""),u=m("allow"),s=B(()=>k.value.startsWith("#")),p=B(()=>({type:s.value?"Region":"Private Key",description:s.value?"Regional organizational key":"Individual assigned key"}));te(s,$=>{$?i.value="This will create a new region for organizing keys":i.value="This will create a new private key entry"},{immediate:!0});const y=B(()=>k.value.trim().length>0),h=()=>{y.value&&(v("add",{name:k.value.trim(),floodPolicy:u.value,parentId:d.selectedNodeId}),k.value="",i.value="",u.value="allow")},N=()=>{k.value="",i.value="",u.value="allow",v("close")},M=$=>{$.target===$.currentTarget&&N()};return($,n)=>$.show?(o(),r("div",{key:0,onClick:M,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:n[3]||(n[3]=Y(()=>{},["stop"]))},[e("div",To,[e("div",null,[n[5]||(n[5]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"Add New Entry",-1)),e("p",No,[d.selectedNodeName?(o(),r("span",Bo,[n[4]||(n[4]=U(" Add to: ",-1)),e("span",Eo,c(d.selectedNodeName),1)])):(o(),r("span",Lo," Add to root level (#uk) "))])]),e("button",{onClick:N,class:"text-white/60 hover:text-white transition-colors"},n[6]||(n[6]=[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),e("form",{onSubmit:Y(h,["prevent"]),class:"space-y-4"},[e("div",null,[e("label",Fo,[e("div",Po,[s.value?(o(),r("svg",zo,n[7]||(n[7]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(o(),r("svg",Vo,n[8]||(n[8]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"},null,-1)]))),n[9]||(n[9]=U(" Region/Key Name ",-1))])]),S(e("input",{id:"keyName","onUpdate:modelValue":n[0]||(n[0]=t=>k.value=t),type:"text",placeholder:"Enter name (prefix with # for regions)",class:"w-full px-4 py-3 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/20 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors",autocomplete:"off"},null,512),[[V,k.value]])]),e("div",Do,[e("div",Io,[e("div",Ho,[s.value?(o(),r("svg",Uo,n[10]||(n[10]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(o(),r("svg",Ro,n[11]||(n[11]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1221 9z"},null,-1)]))),e("span",{class:D([s.value?"text-secondary":"text-accent-green","font-medium"])},c(p.value.type),3)]),e("div",{class:D(["flex-1 h-px",s.value?"bg-secondary/20":"bg-accent-green/20"])},null,2)]),e("p",Ko,c(p.value.description),1)]),e("div",null,[n[14]||(n[14]=e("label",{class:"block text-sm font-medium text-content-primary dark:text-content-primary mb-3"},[e("div",{class:"flex items-center gap-2"},[e("svg",{class:"w-4 h-4 text-primary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"})]),U(" Flood Policy ")])],-1)),e("div",Oo,[e("label",qo,[S(e("input",{type:"radio","onUpdate:modelValue":n[1]||(n[1]=t=>u.value=t),value:"allow",class:"sr-only"},null,512),[[ne,u.value]]),n[12]||(n[12]=Q('
Allow

Permit flooding

',1))]),e("label",Wo,[S(e("input",{type:"radio","onUpdate:modelValue":n[2]||(n[2]=t=>u.value=t),value:"deny",class:"sr-only"},null,512),[[ne,u.value]]),n[13]||(n[13]=Q('
Deny

Block flooding

',1))])])]),e("div",Go,[e("button",{type:"button",onClick:N,class:"flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),e("button",{type:"submit",disabled:!y.value,class:D(["flex-1 px-4 py-3 rounded-lg transition-colors font-medium",y.value?"bg-accent-green/20 hover:bg-accent-green/30 border border-accent-green/50 text-accent-green":"bg-background-mute dark:bg-stroke/5 border border-stroke-subtle dark:border-stroke/20 text-content-muted dark:text-content-muted cursor-not-allowed"])}," Add "+c(p.value.type),11,Qo)])],32)])])):L("",!0)}}),Xo={class:"flex items-center justify-between mb-6"},Jo={class:"text-content-secondary dark:text-content-muted text-sm mt-1"},Zo={class:"text-primary font-mono"},er={for:"keyName",class:"block text-sm font-medium text-content-secondary dark:text-content-primary mb-2"},tr={class:"flex items-center gap-2"},or={key:0,class:"w-4 h-4 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},rr={key:1,class:"w-4 h-4 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},sr={class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4"},nr={class:"flex items-center gap-3 mb-2"},ar={class:"flex items-center gap-2"},lr={key:0,class:"w-5 h-5 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},dr={key:1,class:"w-5 h-5 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},ir={class:"text-content-secondary dark:text-content-muted text-sm"},cr={key:0,class:"space-y-4"},ur={key:0,class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4"},mr={class:"bg-background-mute dark:bg-black/20 border border-stroke-subtle dark:border-stroke/10 rounded-md p-3"},pr={class:"text-xs font-mono text-content-primary dark:text-content-primary/80 break-all"},vr={key:1,class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4"},xr={class:"flex items-center justify-between"},br={class:"text-sm text-content-secondary dark:text-content-muted"},kr={class:"text-xs text-content-muted dark:text-content-muted"},gr={class:"grid grid-cols-2 gap-3"},yr={class:"relative cursor-pointer group"},fr={class:"relative cursor-pointer group"},hr={class:"flex gap-3 pt-4"},wr=["disabled"],_r=G({__name:"EditKeyModal",props:{show:{type:Boolean},node:{}},emits:["close","save","request-delete"],setup(I,{emit:_}){const d=I,v=_,k=m(""),i=m("allow"),u=B(()=>k.value.startsWith("#")),s=B(()=>({type:u.value?"Region":"Private Key",description:u.value?"Regional organizational key":"Individual assigned key"}));te(()=>d.node,t=>{t?(k.value=t.name,i.value=t.floodPolicy):(k.value="",i.value="allow")},{immediate:!0});const p=B(()=>k.value.trim().length>0&&d.node),y=t=>{const A=new Date().getTime()-t.getTime(),b=Math.floor(A/(1e3*60)),x=Math.floor(A/(1e3*60*60)),g=Math.floor(A/(1e3*60*60*24)),z=Math.floor(g/365);return b<60?`${b}m ago`:x<24?`${x}h ago`:g<365?`${g}d ago`:`${z}y ago`},h=t=>{window.navigator?.clipboard&&window.navigator.clipboard.writeText(t)},N=()=>{!p.value||!d.node||(v("save",{id:d.node.id,name:k.value.trim(),floodPolicy:i.value}),$())},M=()=>{d.node&&(v("request-delete",d.node),$())},$=()=>{v("close")},n=t=>{t.target===t.currentTarget&&$()};return(t,a)=>t.show?(o(),r("div",{key:0,onClick:n,class:"fixed inset-0 bg-black/50 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-lg border border-stroke-subtle dark:border-white/10",onClick:a[4]||(a[4]=Y(()=>{},["stop"]))},[e("div",Xo,[e("div",null,[a[6]||(a[6]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"Edit Entry",-1)),e("p",Jo,[a[5]||(a[5]=U(" Modify ",-1)),e("span",Zo,c(t.node?.name),1)])]),e("button",{onClick:$,class:"text-white/60 hover:text-white transition-colors"},a[7]||(a[7]=[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),e("form",{onSubmit:Y(N,["prevent"]),class:"space-y-4"},[e("div",null,[e("label",er,[e("div",tr,[u.value?(o(),r("svg",or,a[8]||(a[8]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(o(),r("svg",rr,a[9]||(a[9]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z"},null,-1)]))),a[10]||(a[10]=U(" Region/Key Name ",-1))])]),S(e("input",{id:"keyName","onUpdate:modelValue":a[0]||(a[0]=A=>k.value=A),type:"text",placeholder:"Enter name (prefix with # for regions)",class:"w-full px-4 py-3 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/20 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors",autocomplete:"off"},null,512),[[V,k.value]])]),e("div",sr,[e("div",nr,[e("div",ar,[u.value?(o(),r("svg",lr,a[11]||(a[11]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(o(),r("svg",dr,a[12]||(a[12]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z"},null,-1)]))),e("span",{class:D([u.value?"text-secondary":"text-accent-green","font-medium"])},c(s.value.type),3)]),e("div",{class:D(["flex-1 h-px",u.value?"bg-secondary/20":"bg-accent-green/20"])},null,2)]),e("p",ir,c(s.value.description),1)]),t.node?(o(),r("div",cr,[t.node.transport_key?(o(),r("div",ur,[a[14]||(a[14]=Q('
Transport Key
',1)),e("div",mr,[e("div",pr,c(t.node.transport_key),1),e("button",{onClick:a[1]||(a[1]=A=>h(t.node.transport_key||"")),class:"mt-2 text-xs text-accent-green hover:text-accent-green/80 flex items-center gap-1",title:"Copy to clipboard"},a[13]||(a[13]=[e("svg",{class:"w-3 h-3",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"})],-1),U(" Copy Key ",-1)]))])])):L("",!0),t.node.last_used?(o(),r("div",vr,[a[15]||(a[15]=e("div",{class:"flex items-center gap-2 mb-3"},[e("svg",{class:"w-4 h-4 text-primary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"})]),e("span",{class:"text-sm font-medium text-content-primary dark:text-content-primary"},"Last Used")],-1)),e("div",xr,[e("div",br,c(t.node.last_used.toLocaleDateString())+" at "+c(t.node.last_used.toLocaleTimeString()),1),e("div",kr,c(y(t.node.last_used)),1)])])):L("",!0)])):L("",!0),e("div",null,[a[18]||(a[18]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-primary mb-3"},[e("div",{class:"flex items-center gap-2"},[e("svg",{class:"w-4 h-4 text-primary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"})]),U(" Flood Policy ")])],-1)),e("div",gr,[e("label",yr,[S(e("input",{type:"radio","onUpdate:modelValue":a[2]||(a[2]=A=>i.value=A),value:"allow",class:"sr-only"},null,512),[[ne,i.value]]),a[16]||(a[16]=Q('
Allow

Permit flooding

',1))]),e("label",fr,[S(e("input",{type:"radio","onUpdate:modelValue":a[3]||(a[3]=A=>i.value=A),value:"deny",class:"sr-only"},null,512),[[ne,i.value]]),a[17]||(a[17]=Q('
Deny

Block flooding

',1))])])]),e("div",hr,[e("button",{type:"button",onClick:M,class:"px-4 py-3 bg-accent-red/20 hover:bg-accent-red/30 border border-accent-red/50 text-accent-red rounded-lg transition-colors"}," Delete "),e("button",{type:"button",onClick:$,class:"flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),e("button",{type:"submit",disabled:!p.value,class:D(["flex-1 px-4 py-3 rounded-lg transition-colors font-medium",p.value?"bg-accent-green/20 hover:bg-accent-green/30 border border-accent-green/50 text-accent-green":"bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/20 text-content-muted dark:text-content-muted/70 cursor-not-allowed"])}," Save Changes ",10,wr)])],32)])])):L("",!0)}}),Cr={class:"flex items-center gap-3 mb-6"},$r={class:"text-content-secondary dark:text-content-muted text-sm mt-1"},Mr={class:"text-accent-red font-mono"},Ar={key:0,class:"bg-accent-red/10 border border-accent-red/30 rounded-lg p-4 mb-6"},Sr={class:"flex items-start gap-3"},jr={class:"flex-1"},Tr={class:"text-accent-red font-medium text-sm mb-2"},Nr={class:"space-y-1 max-h-32 overflow-y-auto"},Br={key:0,class:"w-3 h-3 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Er={key:1,class:"w-3 h-3 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Lr={class:"font-mono"},Fr={key:0,class:"text-content-secondary dark:text-content-muted text-xs"},Pr={key:1,class:"mb-6"},zr={class:"mb-3"},Vr={class:"relative"},Dr={class:"space-y-2 max-h-40 overflow-y-auto border border-stroke-subtle dark:border-stroke/20 rounded-lg p-3 bg-gray-50 dark:bg-white/5"},Ir={key:0,class:"text-center py-4 text-content-secondary dark:text-content-muted text-sm"},Hr={class:"relative"},Ur=["value"],Rr={class:"flex items-center gap-2 flex-1"},Kr={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Or={key:0,class:"ml-auto px-2 py-0.5 bg-background-mute dark:bg-stroke/10 text-content-secondary dark:text-content-muted text-xs rounded-full"},qr={class:"flex gap-3"},Wr=G({__name:"DeleteConfirmModal",props:{show:{type:Boolean},node:{},allNodes:{}},emits:["close","delete-all","move-children"],setup(I,{emit:_}){const d=I,v=_,k=m(null),i=m(""),u=n=>{const t=[],a=A=>{for(const b of A.children)t.push(b),a(b)};return a(n),t},s=B(()=>d.node?u(d.node):[]),p=B(()=>{if(!d.node)return[];const n=new Set([d.node.id,...s.value.map(a=>a.id)]),t=a=>{const A=[];for(const b of a)b.name.startsWith("#")&&!n.has(b.id)&&A.push(b),b.children.length>0&&A.push(...t(b.children));return A};return t(d.allNodes)}),y=B(()=>{if(!i.value.trim())return p.value;const n=i.value.toLowerCase();return p.value.filter(t=>t.name.toLowerCase().includes(n))}),h=()=>{d.node&&(v("delete-all",d.node.id),M())},N=()=>{!d.node||!k.value||(v("move-children",{nodeId:d.node.id,targetParentId:k.value}),M())},M=()=>{k.value=null,i.value="",v("close")},$=n=>{n.target===n.currentTarget&&M()};return(n,t)=>n.show&&n.node?(o(),r("div",{key:0,onClick:$,class:"fixed inset-0 bg-black/80 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-lg border border-stroke-subtle dark:border-white/10",onClick:t[2]||(t[2]=Y(()=>{},["stop"]))},[e("div",Cr,[t[6]||(t[6]=e("svg",{class:"w-6 h-6 text-accent-red",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"})],-1)),e("div",null,[t[4]||(t[4]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"Confirm Deletion",-1)),e("p",$r,[t[3]||(t[3]=U(" Deleting ",-1)),e("span",Mr,c(n.node?.name),1)])]),e("button",{onClick:M,class:"ml-auto text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},t[5]||(t[5]=[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),s.value.length>0?(o(),r("div",Ar,[e("div",Sr,[t[9]||(t[9]=e("svg",{class:"w-5 h-5 text-accent-red flex-shrink-0 mt-0.5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})],-1)),e("div",jr,[e("h4",Tr," This will affect "+c(s.value.length)+" child "+c(s.value.length===1?"entry":"entries")+": ",1),e("div",Nr,[(o(!0),r(K,null,Z(s.value.slice(0,10),a=>(o(),r("div",{key:a.id,class:"flex items-center gap-2 text-xs text-content-secondary dark:text-content-primary/80"},[a.name.startsWith("#")?(o(),r("svg",Br,t[7]||(t[7]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(o(),r("svg",Er,t[8]||(t[8]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z"},null,-1)]))),e("span",Lr,c(a.name),1),e("span",{class:D(["px-1 py-0.5 text-xs rounded",a.floodPolicy==="allow"?"bg-accent-green/20 text-accent-green":"bg-accent-red/20 text-accent-red"])},c(a.floodPolicy),3)]))),128)),s.value.length>10?(o(),r("div",Fr," ...and "+c(s.value.length-10)+" more ",1)):L("",!0)])])])])):L("",!0),s.value.length>0&&p.value.length>0?(o(),r("div",Pr,[t[13]||(t[13]=e("h4",{class:"text-content-primary dark:text-content-primary font-medium text-sm mb-3"},"Move children to another region:",-1)),e("div",zr,[e("div",Vr,[t[10]||(t[10]=e("svg",{class:"absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-content-muted dark:text-content-muted",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"})],-1)),S(e("input",{"onUpdate:modelValue":t[0]||(t[0]=a=>i.value=a),type:"text",placeholder:"Search regions...",class:"w-full pl-9 pr-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/20 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors text-sm"},null,512),[[V,i.value]])])]),e("div",Dr,[y.value.length===0?(o(),r("div",Ir,c(i.value?"No regions match your search":"No available regions"),1)):L("",!0),(o(!0),r(K,null,Z(y.value,a=>(o(),r("label",{key:a.id,class:"flex items-center gap-3 p-2 rounded cursor-pointer hover:bg-stroke-subtle dark:hover:bg-white/10 transition-colors group"},[e("div",Hr,[S(e("input",{type:"radio",value:a.id,"onUpdate:modelValue":t[1]||(t[1]=A=>k.value=A),class:"sr-only peer"},null,8,Ur),[[ne,k.value]]),t[11]||(t[11]=e("div",{class:"w-4 h-4 border-2 border-stroke dark:border-stroke/30 rounded-full group-hover:border-stroke dark:group-hover:border-stroke/50 peer-checked:border-primary peer-checked:bg-primary/20 transition-all"},[e("div",{class:"w-2 h-2 rounded-full bg-primary scale-0 peer-checked:scale-100 transition-transform absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"})],-1))]),e("div",Rr,[t[12]||(t[12]=e("svg",{class:"w-4 h-4 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"})],-1)),e("span",Kr,c(a.name),1),a.children.length>0?(o(),r("span",Or,c(a.children.length),1)):L("",!0)])]))),128))])])):L("",!0),e("div",qr,[e("button",{onClick:M,class:"flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),s.value.length>0&&k.value?(o(),r("button",{key:0,onClick:N,class:"flex-1 px-4 py-3 bg-primary/20 hover:bg-primary/30 border border-primary/50 text-primary rounded-lg transition-colors"}," Move & Delete ")):L("",!0),e("button",{onClick:h,class:"flex-1 px-4 py-3 bg-accent-red/20 hover:bg-accent-red/30 border border-accent-red/50 text-accent-red rounded-lg transition-colors font-medium"},c(s.value.length>0?"Delete All":"Delete"),1)])])])):L("",!0)}}),Gr={class:"space-y-4 sm:space-y-6"},Qr={class:"flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3"},Yr={class:"flex gap-2 flex-wrap"},Xr=["disabled"],Jr=["disabled"],Zr=["disabled"],es={class:"glass-card rounded-[15px] p-3 sm:p-4 border border-stroke-subtle dark:border-stroke/10 bg-background-mute dark:bg-white/5"},ts={class:"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"},os={class:"flex items-center gap-2 sm:gap-3"},rs={class:"flex bg-background-mute dark:bg-stroke/5 rounded-lg border border-stroke-subtle dark:border-stroke/20 p-0.5 sm:p-1"},ss={class:"glass-card rounded-[15px] p-3 sm:p-6 border border-stroke-subtle dark:border-stroke/10"},ns={key:0,class:"flex items-center justify-center py-8"},as={key:1,class:"text-center py-8"},ls={class:"text-content-secondary dark:text-content-muted text-sm"},ds={key:2,class:"text-center py-8"},is={key:3,class:"space-y-2"},cs=G({name:"TransportKeys",__name:"TransportKeys",setup(I){const _=ke(),d=m(!1),v=m(!1),k=m(!1),i=m(null),u=m(null),s=m("deny"),p=m([]),y=m(!1),h=m(null),N=w=>{const f=new Map,H=[];return w.forEach(R=>{const se={id:R.id,name:R.name,floodPolicy:R.flood_policy,transport_key:R.transport_key,last_used:R.last_used?new Date(R.last_used*1e3):void 0,parent_id:R.parent_id,children:[]};f.set(R.id,se)}),f.forEach(R=>{R.parent_id&&f.has(R.parent_id)?f.get(R.parent_id).children.push(R):H.push(R)}),H},M=async()=>{try{y.value=!0,h.value=null;const w=await q.getTransportKeys();w.success&&w.data?p.value=N(w.data):h.value=w.error||"Failed to load transport keys"}catch(w){h.value=w instanceof Error?w.message:"Unknown error occurred",console.error("Error loading transport keys:",w)}finally{y.value=!1}};ae(()=>{M()});function $(w,f){for(const H of w){if(H.id===f)return H;if(H.children){const R=$(H.children,f);if(R)return R}}return null}function n(){const w=_.selectedNodeId.value;return w?$(p.value,w)?.name:void 0}function t(w){s.value==="deny"&&_.setSelectedNode(w)}function a(){s.value==="deny"&&(d.value=!0)}function A(){if(s.value==="deny"&&_.selectedNodeId.value){const w=$(p.value,_.selectedNodeId.value);w&&(u.value=w,k.value=!0)}}function b(){if(s.value==="deny"&&_.selectedNodeId.value){const w=$(p.value,_.selectedNodeId.value);w&&(i.value=w,v.value=!0)}}const x=async w=>{try{const f=await q.createTransportKey(w.name,w.floodPolicy,void 0,w.parentId,void 0);f.success?await M():(console.error("Failed to add transport key:",f.error),h.value=f.error||"Failed to add transport key")}catch(f){console.error("Error adding transport key:",f),h.value=f instanceof Error?f.message:"Unknown error occurred"}finally{d.value=!1}};function g(){d.value=!1}async function z(w){try{const f=w==="allow",H=await q.updateGlobalFloodPolicy(f);H.success?s.value=w:(console.error("Failed to update global flood policy:",H.error),h.value=H.error||"Failed to update global flood policy")}catch(f){console.error("Error updating global flood policy:",f),h.value=f instanceof Error?f.message:"Failed to update global flood policy"}}function W(){v.value=!1,i.value=null}async function C(w){try{const f=await q.updateTransportKey(w.id,w.name,w.floodPolicy);f.success?await M():(console.error("Failed to update transport key:",f.error),h.value=f.error||"Failed to update transport key")}catch(f){console.error("Error updating transport key:",f),h.value=f instanceof Error?f.message:"Unknown error occurred"}finally{W()}}function j(w){v.value=!1,i.value=null,u.value=w,k.value=!0}function P(){k.value=!1,u.value=null}async function F(w){try{const f=await q.deleteTransportKey(w);f.success?(await M(),_.setSelectedNode(null)):(console.error("Failed to delete transport key:",f.error),h.value=f.error||"Failed to delete transport key")}catch(f){console.error("Error deleting transport key:",f),h.value=f instanceof Error?f.message:"Unknown error occurred"}finally{P()}}async function E(w){try{const f=await q.deleteTransportKey(w.nodeId);f.success?(await M(),_.setSelectedNode(null)):(console.error("Failed to delete transport key:",f.error),h.value=f.error||"Failed to delete transport key")}catch(f){console.error("Error deleting transport key:",f),h.value=f instanceof Error?f.message:"Unknown error occurred"}finally{P()}}return(w,f)=>(o(),r("div",Gr,[e("div",Qr,[f[3]||(f[3]=e("div",null,[e("h3",{class:"text-base sm:text-lg font-semibold text-content-primary dark:text-content-primary mb-1 sm:mb-2"},"Regions/Keys"),e("p",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Manage regional key hierarchy")],-1)),e("div",Yr,[e("button",{onClick:a,disabled:s.value==="allow",class:D(["flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-3 py-1.5 sm:py-2 rounded-lg border transition-colors text-xs sm:text-sm",s.value==="allow"?"bg-background-mute dark:bg-white/5 text-content-muted dark:text-content-muted/70 border-stroke-subtle dark:border-stroke/20 cursor-not-allowed":"bg-accent-green/10 hover:bg-accent-green/20 text-accent-green border-accent-green/30"])},f[2]||(f[2]=[e("svg",{class:"w-3.5 h-3.5 sm:w-4 sm:h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})],-1),U(" Add ",-1)]),10,Xr),e("button",{onClick:b,disabled:!J(_).selectedNodeId.value||s.value==="allow",class:D(["px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg border transition-colors text-xs sm:text-sm",!J(_).selectedNodeId.value||s.value==="allow"?"bg-background-mute dark:bg-stroke/10 text-content-muted dark:text-content-muted/70 border-stroke-subtle dark:border-stroke/20 cursor-not-allowed":"bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border-accent-green/50"])}," Edit ",10,Jr),e("button",{onClick:A,disabled:!J(_).selectedNodeId.value||s.value==="allow",class:D(["px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg border transition-colors text-xs sm:text-sm",!J(_).selectedNodeId.value||s.value==="allow"?"bg-background-mute dark:bg-stroke/10 text-content-muted dark:text-content-muted/70 border-stroke-subtle dark:border-stroke/20 cursor-not-allowed":"bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border-accent-red/50"])}," Delete ",10,Zr)])]),e("div",es,[e("div",ts,[f[4]||(f[4]=e("div",null,[e("h4",{class:"text-xs sm:text-sm font-medium text-content-primary dark:text-content-primary mb-1"},"Global Flood Policy (*)"),e("p",{class:"text-content-secondary dark:text-content-muted text-[10px] sm:text-xs"},"Master control for repeater flooding")],-1)),e("div",os,[e("div",rs,[e("button",{onClick:f[0]||(f[0]=H=>z("deny")),class:D(["px-2 sm:px-3 py-1 text-[10px] sm:text-xs font-medium rounded transition-colors",s.value==="deny"?"bg-accent-red/20 text-accent-red border border-accent-red/50":"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-secondary"])}," DENY ",2),e("button",{onClick:f[1]||(f[1]=H=>z("allow")),class:D(["px-2 sm:px-3 py-1 text-[10px] sm:text-xs font-medium rounded transition-colors",s.value==="allow"?"bg-accent-green/20 text-accent-green border border-accent-green/50":"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-secondary"])}," ALLOW ",2)])])])]),e("div",ss,[y.value?(o(),r("div",ns,f[5]||(f[5]=[e("div",{class:"animate-spin rounded-full h-8 w-8 border-b-2 border-accent-green"},null,-1),e("span",{class:"ml-2 text-content-secondary dark:text-content-muted"},"Loading transport keys...",-1)]))):h.value?(o(),r("div",as,[f[6]||(f[6]=e("div",{class:"text-accent-red mb-2"},"⚠️ Error loading transport keys",-1)),e("div",ls,c(h.value),1),e("button",{onClick:M,class:"mt-4 px-4 py-2 bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded-lg transition-colors"}," Retry ")])):p.value.length===0?(o(),r("div",ds,f[7]||(f[7]=[e("div",{class:"text-content-muted dark:text-content-muted mb-2"},"📝 No transport keys found",-1),e("div",{class:"text-content-muted dark:text-content-muted/60 text-sm"},"Add your first transport key to get started",-1)]))):(o(),r("div",is,[(o(!0),r(K,null,Z(p.value,H=>(o(),xe(jo,{key:H.id,node:H,"selected-node-id":J(_).selectedNodeId.value,level:0,disabled:s.value==="allow",onSelect:t},null,8,["node","selected-node-id","disabled"]))),128))]))]),O(Yo,{show:d.value,"selected-node-name":n(),"selected-node-id":J(_).selectedNodeId.value||void 0,onClose:g,onAdd:x},null,8,["show","selected-node-name","selected-node-id"]),O(_r,{show:v.value,node:i.value,onClose:W,onSave:C,onRequestDelete:j},null,8,["show","node"]),O(Wr,{show:k.value,node:u.value,"all-nodes":p.value,onClose:P,onDeleteAll:F,onMoveChildren:E},null,8,["show","node","all-nodes"])]))}}),us={class:"space-y-4 sm:space-y-6"},ms={class:"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"},ps={key:0,class:"bg-red-500/10 border border-red-500/30 rounded-lg p-4"},vs={class:"flex items-center gap-2 text-red-600 dark:text-red-400"},xs={key:1,class:"flex items-center justify-center py-12"},bs={key:2,class:"space-y-3"},ks={class:"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"},gs={class:"flex-1"},ys={class:"flex items-center gap-2 sm:gap-3"},fs={class:"min-w-0 flex-1"},hs={class:"text-content-primary dark:text-content-primary font-medium text-sm sm:text-base break-all"},ws={class:"flex flex-col sm:flex-row sm:items-center sm:gap-4 mt-1 text-xs text-content-secondary dark:text-content-muted"},_s={class:"truncate"},Cs={class:"truncate"},$s=["onClick","disabled"],Ms={key:3,class:"text-center py-12"},As={class:"bg-surface dark:bg-surface-elevated border border-stroke-subtle dark:border-stroke/20 rounded-[15px] p-6 max-w-md w-full shadow-2xl"},Ss={class:"space-y-4"},js={class:"flex justify-end gap-3 mt-6"},Ts=["disabled"],Ns=["disabled"],Bs={class:"bg-surface dark:bg-surface-elevated border border-stroke-subtle dark:border-stroke/20 rounded-[15px] p-6 max-w-lg w-full shadow-2xl"},Es={class:"space-y-4"},Ls={class:"flex gap-2"},Fs=["value"],Ps={class:"bg-blue-500/10 border border-blue-500/30 rounded-lg p-4"},zs={class:"block bg-blue-500/20 px-3 py-2 rounded text-xs text-blue-100 font-mono overflow-x-auto"},Vs=G({name:"APITokens",__name:"APITokens",setup(I){const _=m([]),d=m(!1),v=m(null),k=m(!1),i=m(""),u=m(null),s=m(!1),p=m(!1),y=m(null),h=async()=>{d.value=!0,v.value=null;try{const x=await q.get("/auth/tokens"),g=x.data||x;_.value=g.tokens||[]}catch(x){console.error("Failed to fetch API tokens:",x),v.value=x instanceof Error?x.message:"Failed to fetch tokens"}finally{d.value=!1}},N=async()=>{if(!i.value.trim()){v.value="Token name is required";return}d.value=!0,v.value=null;try{const x=await q.post("/auth/tokens",{name:i.value.trim()}),g=x.data||x;u.value=g.token||null,k.value=!1,s.value=!0,i.value="",await h()}catch(x){console.error("Failed to create API token:",x),v.value=x instanceof Error?x.message:"Failed to create token"}finally{d.value=!1}},M=(x,g)=>{y.value={id:x,name:g},p.value=!0},$=async()=>{if(y.value){d.value=!0,v.value=null;try{await q.delete(`/auth/tokens/${y.value.id}`),await h(),p.value=!1,y.value=null}catch(x){console.error("Failed to revoke API token:",x),v.value=x instanceof Error?x.message:"Failed to revoke token"}finally{d.value=!1}}},n=()=>{k.value=!1,i.value="",v.value=null},t=()=>{s.value=!1,u.value=null},a=()=>{u.value&&navigator.clipboard.writeText(u.value)},A=x=>x?new Date(x*1e3).toLocaleString():"Never",b=B(()=>`${window.location.origin}/api/stats`);return ae(()=>{h()}),(x,g)=>(o(),r(K,null,[e("div",us,[e("div",ms,[g[5]||(g[5]=e("div",null,[e("h2",{class:"text-lg sm:text-xl font-semibold text-content-primary dark:text-content-primary"},"API Tokens"),e("p",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm mt-1"},"Manage API tokens for machine-to-machine authentication")],-1)),e("button",{onClick:g[0]||(g[0]=z=>k.value=!0),class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors flex items-center justify-center gap-2 text-sm sm:text-base"},g[4]||(g[4]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})],-1),U(" Create Token ",-1)]))]),g[20]||(g[20]=Q('

API tokens are used for machine-to-machine authentication. Include the token in the X-API-Key header when making API requests.

Tokens are only shown once at creation. Store them securely.

',1)),v.value?(o(),r("div",ps,[e("div",vs,[g[6]||(g[6]=e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})],-1)),U(" "+c(v.value),1)])])):L("",!0),d.value&&_.value.length===0?(o(),r("div",xs,g[7]||(g[7]=[e("div",{class:"text-center"},[e("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-primary rounded-full mx-auto mb-4"}),e("div",{class:"text-content-secondary dark:text-content-muted"},"Loading tokens...")],-1)]))):_.value.length>0?(o(),r("div",bs,[(o(!0),r(K,null,Z(_.value,z=>(o(),r("div",{key:z.id,class:"bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-3 sm:p-4 hover:bg-stroke-subtle dark:hover:bg-white/10 transition-colors"},[e("div",ks,[e("div",gs,[e("div",ys,[g[8]||(g[8]=e("svg",{class:"w-4 h-4 sm:w-5 sm:h-5 text-primary flex-shrink-0",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"})],-1)),e("div",fs,[e("h3",hs,c(z.name),1),e("div",ws,[e("span",_s,"Created: "+c(A(z.created_at)),1),e("span",Cs,"Last used: "+c(A(z.last_used)),1)])])])]),e("button",{onClick:W=>M(z.id,z.name),disabled:d.value,class:"w-full sm:w-auto px-3 py-1.5 bg-red-100 dark:bg-red-500/20 hover:bg-red-500/30 text-red-600 dark:text-red-400 rounded-lg border border-red-500/50 transition-colors disabled:opacity-50 text-sm"}," Revoke ",8,$s)])]))),128))])):(o(),r("div",Ms,[g[9]||(g[9]=e("svg",{class:"w-16 h-16 text-content-muted dark:text-content-muted/40 mx-auto mb-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"})],-1)),g[10]||(g[10]=e("h3",{class:"text-content-primary dark:text-content-primary font-medium mb-2"},"No API Tokens",-1)),g[11]||(g[11]=e("p",{class:"text-content-secondary dark:text-content-muted text-sm mb-4"},"Create a token to enable API access",-1)),e("button",{onClick:g[1]||(g[1]=z=>k.value=!0),class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors"}," Create Your First Token ")])),k.value?(o(),r("div",{key:4,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm",onClick:Y(n,["self"])},[e("div",As,[g[14]||(g[14]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary mb-4"},"Create API Token",-1)),e("div",Ss,[e("div",null,[g[12]||(g[12]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-muted mb-2"},"Token Name",-1)),S(e("input",{"onUpdate:modelValue":g[2]||(g[2]=z=>i.value=z),type:"text",placeholder:"e.g., Production Server, CI/CD Pipeline",class:"w-full px-4 py-2 bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-400 dark:placeholder-white/40 focus:outline-none focus:border-primary transition-colors",onKeydown:$e(N,["enter"])},null,544),[[V,i.value]]),g[13]||(g[13]=e("p",{class:"text-xs text-content-muted dark:text-content-muted mt-1"},"Give your token a descriptive name to identify its purpose",-1))]),e("div",js,[e("button",{onClick:n,disabled:d.value,class:"px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/10 transition-colors disabled:opacity-50"}," Cancel ",8,Ts),e("button",{onClick:N,disabled:d.value||!i.value.trim(),class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors disabled:opacity-50"},c(d.value?"Creating...":"Create Token"),9,Ns)])])])])):L("",!0),s.value&&u.value?(o(),r("div",{key:5,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm",onClick:Y(t,["self"])},[e("div",Bs,[g[19]||(g[19]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary mb-4"},"Token Created Successfully",-1)),e("div",Es,[g[18]||(g[18]=Q('
Save this token now! For security reasons, it will not be shown again.
',1)),e("div",null,[g[16]||(g[16]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-muted mb-2"},"Your API Token",-1)),e("div",Ls,[e("input",{value:u.value,readonly:"",class:"flex-1 px-4 py-2 bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary font-mono text-sm"},null,8,Fs),e("button",{onClick:a,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors flex items-center gap-2",title:"Copy to clipboard"},g[15]||(g[15]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"})],-1),U(" Copy ",-1)]))])]),e("div",Ps,[g[17]||(g[17]=e("p",{class:"text-sm text-blue-200 mb-2"},[e("strong",null,"Usage Example:")],-1)),e("code",zs,' curl -H "X-API-Key: '+c(u.value)+'" '+c(b.value),1)]),e("div",{class:"flex justify-end mt-6"},[e("button",{onClick:t,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors"}," Done ")])])])])):L("",!0)]),O(Me,{show:p.value,title:"Revoke API Token",message:`Are you sure you want to revoke the token '${y.value?.name}'? This action cannot be undone.`,"confirm-text":"Revoke","cancel-text":"Cancel",variant:"danger",onConfirm:$,onClose:g[3]||(g[3]=z=>p.value=!1)},null,8,["show","message"])],64))}}),Ds={class:"space-y-6"},Is={class:"glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6"},Hs={class:"space-y-4"},Us={class:"flex items-center justify-between"},Rs=["disabled"],Ks={class:"glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6"},Os={class:"space-y-4"},qs={class:"space-y-3"},Ws=["checked","disabled"],Gs=["checked","disabled"],Qs={class:"flex items-start gap-3"},Ys={key:0,class:"w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},Xs={key:1,class:"w-5 h-5 text-accent-cyan flex-shrink-0 mt-0.5",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},Js={class:"flex-1"},Zs={class:"text-sm font-medium text-content-primary dark:text-content-primary"},en={key:0,class:"text-xs text-green-600 dark:text-green-400 mt-1"},tn={key:1,class:"p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg"},on={class:"flex items-start justify-between gap-3"},rn=["disabled"],sn={key:0,class:"animate-spin h-4 w-4",fill:"none",viewBox:"0 0 24 24"},nn={key:1,class:"w-4 h-4",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},an={class:"flex items-center space-x-2"},ln={key:0,class:"w-5 h-5 text-green-600 dark:text-green-400",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},dn={key:1,class:"w-5 h-5 text-red-600 dark:text-red-400",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},cn=G({name:"WebSettings",__name:"WebSettings",setup(I){const _=m(!1),d=m(""),v=m(!1),k=m(!1),i=m(!1),u=m(!1),s=m(!0),p=ce({cors_enabled:!1,use_default_frontend:!0}),y=B(()=>v.value?"bg-green-500/10 border-green-600/40 dark:border-green-500/30":"bg-red-500/10 border-red-500/30");async function h(){try{s.value=!0;const b=await q.get("/check_pymc_console");b.success&&b.data&&(u.value=b.data.exists,console.log("PyMC Console exists:",u.value))}catch(b){console.error("Failed to check PyMC Console:",b),u.value=!1}finally{s.value=!1}}async function N(){try{const b=await q.get("/stats");console.log("WebSettings: Full response:",b);let x=null;if(b.success&&b.data?x=b.data:b&&"version"in b&&(x=b),x){const g=x.config?.web||{};console.log("WebSettings: webConfig:",g),p.cors_enabled=g.cors_enabled===!0,console.log("WebSettings: Set cors_enabled to:",p.cors_enabled);const z=g.web_path;p.use_default_frontend=!z||z==="",console.log("WebSettings: Set use_default_frontend to:",p.use_default_frontend,"from web_path:",z)}}catch(b){console.error("Failed to load web settings:",b),a("Failed to load settings",!1)}}async function M(){_.value=!0,d.value="";try{const b={web:{cors_enabled:p.cors_enabled}};p.use_default_frontend?b.web.web_path=null:b.web.web_path="/opt/pymc_console/web/html";const x=await q.post("/update_web_config",b);x.success?(a("Settings saved successfully",!0),k.value=!0):a(x.error||"Failed to save settings",!1)}catch(b){console.error("Failed to save web settings:",b),a(b.message||"Failed to save settings",!1)}finally{_.value=!1}}async function $(){p.cors_enabled=!p.cors_enabled,await M()}async function n(){p.use_default_frontend=!0,await M()}async function t(){p.use_default_frontend=!1,await M()}function a(b,x){d.value=b,v.value=x,setTimeout(()=>{d.value=""},5e3)}async function A(){i.value=!0,d.value="";try{const b=await q.post("/restart_service",{});b.success?(a("Service restart initiated. Page will reload...",!0),k.value=!1,setTimeout(()=>{window.location.reload()},2e3)):a(b.error||"Failed to restart service",!1)}catch(b){b.code==="ERR_NETWORK"||b.message?.includes("Network error")?(a("Service restarting... Page will reload",!0),k.value=!1,setTimeout(()=>{window.location.reload()},3e3)):(console.error("Failed to restart service:",b),a(b.message||"Failed to restart service",!1))}finally{i.value=!1}}return ae(()=>{N(),h()}),(b,x)=>(o(),r("div",Ds,[e("div",Is,[x[1]||(x[1]=e("div",{class:"flex items-start justify-between mb-4"},[e("div",null,[e("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-1"},"CORS Settings"),e("p",{class:"text-sm text-content-secondary dark:text-content-muted"},"Control cross-origin resource sharing for API access")])],-1)),e("div",Hs,[e("div",Us,[x[0]||(x[0]=e("div",null,[e("label",{class:"text-sm font-medium text-content-primary dark:text-content-primary"},"Enable CORS"),e("p",{class:"text-xs text-content-secondary dark:text-content-muted mt-1"},"Allow web frontends from different origins to access the API")],-1)),e("button",{onClick:$,disabled:_.value,class:D(["relative inline-flex h-6 w-11 items-center rounded-full transition-colors border-2",p.cors_enabled?"bg-cyan-600 dark:bg-teal-500 border-cyan-600 dark:border-teal-500":"bg-gray-400 dark:bg-gray-600 border-gray-400 dark:border-gray-600",_.value?"opacity-50 cursor-not-allowed":"cursor-pointer"])},[e("span",{class:D(["inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow-lg",p.cors_enabled?"translate-x-5":"translate-x-0.5"])},null,2)],10,Rs)])])]),e("div",Ks,[x[11]||(x[11]=e("div",{class:"flex items-start justify-between mb-4"},[e("div",null,[e("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-1"},"Web Frontend"),e("p",{class:"text-sm text-content-secondary dark:text-content-muted"},"Choose which web interface to use")])],-1)),e("div",Os,[e("div",qs,[e("label",{class:D(["flex items-start space-x-3 p-4 bg-background-mute dark:bg-background/30 rounded-lg border-2 cursor-pointer transition-all",p.use_default_frontend?"border-accent-cyan bg-accent-cyan/10":"border-stroke-subtle dark:border-stroke/10 hover:border-accent-cyan/50"])},[e("input",{type:"radio",name:"frontend",checked:p.use_default_frontend,onChange:n,disabled:_.value,class:"mt-1 h-4 w-4 text-accent-cyan focus:ring-accent-cyan focus:ring-offset-background"},null,40,Ws),x[2]||(x[2]=e("div",{class:"flex-1"},[e("div",{class:"text-sm font-medium text-content-primary dark:text-content-primary"},"Default Frontend"),e("div",{class:"text-xs text-content-secondary dark:text-content-muted mt-1"},"Built-in pyMC Repeater web interface"),e("div",{class:"text-xs text-content-muted dark:text-content-muted/60 mt-1 font-mono"},"Built-in")],-1))],2),e("label",{class:D(["flex items-start space-x-3 p-4 bg-background-mute dark:bg-background/30 rounded-lg border-2 cursor-pointer transition-all",p.use_default_frontend?"border-stroke-subtle dark:border-stroke/10 hover:border-accent-cyan/50":"border-accent-cyan bg-accent-cyan/10"])},[e("input",{type:"radio",name:"frontend",checked:!p.use_default_frontend,onChange:t,disabled:_.value,class:"mt-1 h-4 w-4 text-accent-cyan focus:ring-accent-cyan focus:ring-offset-background"},null,40,Gs),x[3]||(x[3]=Q('
PyMC Console
@Treehouse⚡
Alternative web interface for pyMC Repeater
/opt/pymc_console/web/html
',1))],2)]),s.value?L("",!0):(o(),r("div",{key:0,class:D(["p-4 rounded-lg border",u.value?"bg-green-500/5 border-green-500/20":"bg-accent-cyan/5 border-accent-cyan/20"])},[e("div",Qs,[u.value?(o(),r("svg",Ys,x[4]||(x[4]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)]))):(o(),r("svg",Xs,x[5]||(x[5]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)]))),e("div",Js,[e("h4",Zs,c(u.value?"PyMC Console has been detected":"PyMC Console Not Installed"),1),u.value?(o(),r("p",en,x[6]||(x[6]=[U(" PyMC Console is installed at ",-1),e("code",{class:"text-green-700 dark:text-green-300"},"/opt/pymc_console/web/html",-1)]))):(o(),r(K,{key:1},[x[7]||(x[7]=Q('

PyMC Console must be installed at /opt/pymc_console/web/html before selecting this option.

PyMC Console Install Instructions ',2))],64))])])],2)),k.value?(o(),r("div",tn,[e("div",on,[x[10]||(x[10]=Q('

Service restart required

Web frontend changes will take effect after restarting the pymc-repeater service.

',1)),e("button",{onClick:A,disabled:i.value,class:"px-4 py-2 bg-amber-500 hover:bg-amber-600 disabled:bg-amber-500/50 text-white font-medium rounded-lg transition-colors disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"},[i.value?(o(),r("svg",sn,x[8]||(x[8]=[e("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),e("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"},null,-1)]))):(o(),r("svg",nn,x[9]||(x[9]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"},null,-1)]))),U(" "+c(i.value?"Restarting...":"Restart Now"),1)],8,rn)])])):L("",!0)])]),d.value?(o(),r("div",{key:0,class:D(["p-4 rounded-lg border",y.value])},[e("div",an,[v.value?(o(),r("svg",ln,x[12]||(x[12]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"},null,-1)]))):(o(),r("svg",dn,x[13]||(x[13]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"},null,-1)]))),e("span",{class:D(v.value?"text-green-600 dark:text-green-400":"text-red-600 dark:text-red-400")},c(d.value),3)])],2)):L("",!0)]))}}),un={class:"space-y-4"},mn={key:0,class:"bg-green-100 dark:bg-green-500/20 border border-green-500 dark:border-green-500/50 rounded-lg p-3 text-green-700 dark:text-green-400 text-sm"},pn={key:1,class:"bg-red-100 dark:bg-red-500/20 border border-red-500 dark:border-red-500/50 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm"},vn={class:"flex justify-between items-center"},xn={class:"flex gap-2"},bn=["disabled"],kn={class:"flex gap-2"},gn=["disabled"],yn=["disabled"],fn={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},hn={key:0,class:"flex items-center justify-center py-4"},wn={key:1,class:"text-center py-4"},_n={class:"grid grid-cols-2 sm:grid-cols-4 gap-3"},Cn={class:"text-center p-2 bg-white dark:bg-white/5 rounded-lg"},$n={class:"text-center p-2 bg-white dark:bg-white/5 rounded-lg"},Mn={class:"text-lg font-mono text-content-primary dark:text-content-primary"},An={class:"text-center p-2 bg-white dark:bg-white/5 rounded-lg"},Sn={class:"text-lg font-mono text-green-600 dark:text-green-400"},jn={class:"text-center p-2 bg-white dark:bg-white/5 rounded-lg"},Tn={class:"text-lg font-mono text-red-600 dark:text-red-400"},Nn={key:0,class:"mt-2 p-2 bg-red-50 dark:bg-red-500/10 rounded-lg border border-red-200 dark:border-red-500/30"},Bn={key:1,class:"mt-2 p-2 bg-orange-50 dark:bg-orange-500/10 rounded-lg border border-orange-200 dark:border-orange-500/30"},En={class:"font-medium"},Ln={class:"font-mono text-[10px] opacity-70"},Fn={class:"text-[10px]"},Pn={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},zn={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Vn={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Dn={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},In={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Hn={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Un={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Rn={key:1,class:"flex items-center gap-2"},Kn={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 gap-1"},On={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},qn={key:1,class:"flex items-center gap-2"},Wn={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},Gn={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Qn={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Yn={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Xn={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Jn={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Zn={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},ea={key:1,class:"flex items-center gap-2"},ta={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},oa={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},ra={key:1,class:"flex items-center gap-2"},sa={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 gap-1"},na={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},aa={key:1,class:"flex items-center gap-2"},la={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},da={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},ia={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},ca={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},ua={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},ma={key:1,class:"flex items-center gap-2"},pa={class:"py-2"},va={class:"grid grid-cols-3 gap-2 mt-2"},xa={class:"text-center p-2 bg-white dark:bg-white/5 rounded-lg"},ba={key:0,class:"font-mono text-sm text-content-primary dark:text-content-primary"},ka={class:"text-center p-2 bg-white dark:bg-white/5 rounded-lg"},ga={key:0,class:"font-mono text-sm text-content-primary dark:text-content-primary"},ya={class:"text-center p-2 bg-white dark:bg-white/5 rounded-lg"},fa={key:0,class:"font-mono text-sm text-content-primary dark:text-content-primary"},ha={class:"p-6 space-y-4"},wa={class:"flex justify-between items-start"},_a={class:"flex justify-end pt-4 border-t border-stroke-subtle dark:border-stroke/20"},Ca=G({__name:"AdvertSettings",setup(I){const _=re(),d=B(()=>_.stats?.config?.repeater||{}),v=B(()=>d.value.advert_rate_limit||{}),k=B(()=>d.value.advert_penalty_box||{}),i=B(()=>d.value.advert_adaptive||{}),u=B(()=>i.value.thresholds||{}),s=m(!1),p=m(!1),y=m(""),h=m(""),N=m(!1),M=m(!1),$=m(null),n=m(!0),t=m(2),a=m(1),A=m(10),b=m(60),x=m(!0),g=m(2),z=m(12),W=m(6),C=m(2),j=m(24),P=m(!0),F=m(.1),E=m(5),w=m(.05),f=m(.2),H=m(.5),R=async()=>{M.value=!0;try{const X=await de.get("/api/advert_rate_limit_stats");X.data?.success&&($.value=X.data.data)}catch(X){console.error("Failed to fetch rate limit stats:",X)}finally{M.value=!1}};te([v,k,i],()=>{console.log("[AdvertSettings] Watch triggered, isEditing:",s.value),s.value?console.log("[AdvertSettings] Watch skipped (editing mode)"):(console.log("[AdvertSettings] Watch loading values from store"),console.log("[AdvertSettings] rateLimitConfig:",v.value),console.log("[AdvertSettings] penaltyConfig:",k.value),console.log("[AdvertSettings] adaptiveConfig:",i.value),n.value=v.value.enabled??!1,t.value=v.value.bucket_capacity??2,a.value=v.value.refill_tokens??1,A.value=Math.round((v.value.refill_interval_seconds??36e3)/3600),b.value=Math.round((v.value.min_interval_seconds??0)/60),x.value=k.value.enabled??!1,g.value=k.value.violation_threshold??2,z.value=Math.round((k.value.violation_decay_seconds??43200)/3600),W.value=Math.round((k.value.base_penalty_seconds??21600)/3600),C.value=k.value.penalty_multiplier??2,j.value=Math.round((k.value.max_penalty_seconds??86400)/3600),P.value=i.value.enabled??!1,F.value=i.value.ewma_alpha??.1,E.value=Math.round((i.value.hysteresis_seconds??300)/60),w.value=u.value.quiet_max??.05,f.value=u.value.normal_max??.2,H.value=u.value.busy_max??.5,console.log("[AdvertSettings] Watch loaded values:"),console.log(" rateLimitEnabled:",n.value),console.log(" minIntervalMinutes:",b.value))},{immediate:!0}),ae(()=>{R()});const se=()=>{console.log("[AdvertSettings] reloadFormValues called"),console.log("[AdvertSettings] rateLimitConfig:",v.value),console.log("[AdvertSettings] penaltyConfig:",k.value),console.log("[AdvertSettings] adaptiveConfig:",i.value),n.value=v.value.enabled??!1,t.value=v.value.bucket_capacity??2,a.value=v.value.refill_tokens??1,A.value=Math.round((v.value.refill_interval_seconds??36e3)/3600),b.value=Math.round((v.value.min_interval_seconds??0)/60),x.value=k.value.enabled??!1,g.value=k.value.violation_threshold??2,z.value=Math.round((k.value.violation_decay_seconds??43200)/3600),W.value=Math.round((k.value.base_penalty_seconds??21600)/3600),C.value=k.value.penalty_multiplier??2,j.value=Math.round((k.value.max_penalty_seconds??86400)/3600),P.value=i.value.enabled??!1,F.value=i.value.ewma_alpha??.1,E.value=Math.round((i.value.hysteresis_seconds??300)/60),w.value=u.value.quiet_max??.05,f.value=u.value.normal_max??.2,H.value=u.value.busy_max??.5,console.log("[AdvertSettings] Form values after reload:"),console.log(" rateLimitEnabled:",n.value),console.log(" minIntervalMinutes:",b.value),console.log(" penaltyEnabled:",x.value),console.log(" adaptiveEnabled:",P.value)},ge=()=>{s.value=!0,y.value="",h.value=""},ye=()=>{s.value=!1,y.value="",h.value="",se()},fe=async()=>{p.value=!0,h.value="",y.value="";try{const X={rate_limit_enabled:n.value,bucket_capacity:t.value,refill_tokens:a.value,refill_interval_seconds:A.value*3600,min_interval_seconds:b.value*60,penalty_enabled:x.value,violation_threshold:g.value,violation_decay_seconds:z.value*3600,base_penalty_seconds:W.value*3600,penalty_multiplier:C.value,max_penalty_seconds:j.value*3600,adaptive_enabled:P.value,ewma_alpha:F.value,hysteresis_seconds:E.value*60,quiet_max:w.value,normal_max:f.value,busy_max:H.value};console.log("[AdvertSettings] Sending save request with payload:",X);const T=(await de.post("/api/update_advert_rate_limit_config",X)).data;console.log("[AdvertSettings] API response:",T),T.success?(y.value=T.data?.message||"Settings saved successfully",console.log("[AdvertSettings] Save successful, fetching updated config..."),await _.fetchStats(),console.log("[AdvertSettings] systemStore.fetchStats() complete"),console.log("[AdvertSettings] rateLimitConfig after fetchStats:",v.value),await R(),console.log("[AdvertSettings] fetchStats() complete"),await ie(),console.log("[AdvertSettings] nextTick() complete, calling reloadFormValues()"),se(),console.log("[AdvertSettings] reloadFormValues() complete, exiting edit mode"),s.value=!1,setTimeout(()=>{y.value=""},3e3)):(h.value=T.error||"Failed to save settings",console.error("[AdvertSettings] Save failed:",T.error))}catch(X){console.error("Failed to save advert settings:",X),h.value=X.response?.data?.error||"Failed to save settings"}finally{p.value=!1}},ue=B(()=>$.value?.adaptive?.current_tier||"unknown"),he=B(()=>{switch(ue.value){case"quiet":return"bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 border-green-500";case"normal":return"bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 border-blue-500";case"busy":return"bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 border-yellow-500";case"congested":return"bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 border-red-500";default:return"bg-gray-100 dark:bg-gray-500/20 text-gray-700 dark:text-gray-400 border-gray-500"}});return(X,l)=>(o(),r("div",un,[y.value?(o(),r("div",mn,c(y.value),1)):L("",!0),h.value?(o(),r("div",pn,c(h.value),1)):L("",!0),e("div",vn,[e("div",xn,[e("button",{onClick:R,disabled:M.value,class:"px-3 py-1.5 text-xs bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-secondary dark:text-content-muted rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors disabled:opacity-50"},c(M.value?"Loading...":"Refresh Stats"),9,bn),e("button",{onClick:l[0]||(l[0]=T=>N.value=!0),class:"px-3 py-1.5 text-xs bg-blue-100 dark:bg-blue-500/20 hover:bg-blue-200 dark:hover:bg-blue-500/30 text-blue-700 dark:text-blue-400 rounded-lg border border-blue-500/50 transition-colors",title:"How rate limiting works"},l[19]||(l[19]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})],-1)]))]),e("div",kn,[s.value?(o(),r(K,{key:1},[e("button",{onClick:ye,disabled:p.value,class:"px-3 sm:px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"}," Cancel ",8,gn),e("button",{onClick:fe,disabled:p.value,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"},c(p.value?"Saving...":"Save Changes"),9,yn)],64)):(o(),r("button",{key:0,onClick:ge,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Edit Settings "))])]),e("div",fn,[l[28]||(l[28]=e("h3",{class:"text-sm font-medium text-content-primary dark:text-content-primary"},"Current Status",-1)),M.value&&!$.value?(o(),r("div",hn,l[20]||(l[20]=[e("div",{class:"animate-spin w-5 h-5 border-2 border-stroke-subtle dark:border-stroke/20 border-t-cyan-500 dark:border-t-primary rounded-full"},null,-1),e("span",{class:"ml-2 text-sm text-content-muted"},"Loading stats...",-1)]))):$.value?(o(),r(K,{key:2},[e("div",_n,[e("div",Cn,[l[22]||(l[22]=e("div",{class:"text-xs text-content-muted dark:text-content-muted"},"Mesh Tier",-1)),e("div",{class:D(["mt-1 px-2 py-0.5 rounded border text-xs font-medium inline-block",he.value])},c(ue.value.toUpperCase()),3)]),e("div",$n,[l[23]||(l[23]=e("div",{class:"text-xs text-content-muted dark:text-content-muted"},"Adverts/min",-1)),e("div",Mn,c($.value.metrics?.adverts_per_min_ewma?.toFixed(2)||"0.00"),1)]),e("div",An,[l[24]||(l[24]=e("div",{class:"text-xs text-content-muted dark:text-content-muted"},"Allowed",-1)),e("div",Sn,c($.value.stats?.adverts_allowed||0),1)]),e("div",jn,[l[25]||(l[25]=e("div",{class:"text-xs text-content-muted dark:text-content-muted"},"Dropped",-1)),e("div",Tn,c($.value.stats?.adverts_dropped||0),1)])]),Object.keys($.value.active_penalties||{}).length>0?(o(),r("div",Nn,[l[26]||(l[26]=e("div",{class:"text-xs font-medium text-red-700 dark:text-red-400 mb-1"},"Active Penalties",-1)),(o(!0),r(K,null,Z($.value.active_penalties,(T,le)=>(o(),r("div",{key:le,class:"text-xs font-mono text-red-600 dark:text-red-400"},c(le)+"... - "+c(Math.round(T))+"s remaining ",1))),128))])):L("",!0),$.value.recent_drops&&$.value.recent_drops.length>0?(o(),r("div",Bn,[l[27]||(l[27]=e("div",{class:"text-xs font-medium text-orange-700 dark:text-orange-400 mb-1"},"Recently Dropped Adverts",-1)),(o(!0),r(K,null,Z($.value.recent_drops,(T,le)=>(o(),r("div",{key:le,class:"text-xs text-orange-600 dark:text-orange-400 py-0.5"},[e("span",En,c(T.name),1),e("span",Ln,"("+c(T.pubkey)+"...)",1),e("span",Fn," - "+c(T.reason)+" ("+c(T.seconds_ago)+"s ago)",1)]))),128))])):L("",!0)],64)):(o(),r("div",wn,l[21]||(l[21]=[e("p",{class:"text-xs text-content-muted dark:text-content-muted"},' Stats not available. Click "Refresh Stats" to load. ',-1)])))]),e("div",Pn,[l[36]||(l[36]=e("h3",{class:"text-sm font-medium text-content-primary dark:text-content-primary flex items-center gap-2"},[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"})]),U(" Token Bucket Rate Limiting ")],-1)),l[37]||(l[37]=e("p",{class:"text-xs text-content-muted dark:text-content-muted"},"Controls how many adverts each pubkey can send in a given time period.",-1)),e("div",zn,[l[30]||(l[30]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Rate Limiting",-1)),s.value?S((o(),r("select",{key:1,"onUpdate:modelValue":l[1]||(l[1]=T=>n.value=T),class:"w-full sm:w-32 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},l[29]||(l[29]=[e("option",{value:!0},"Enabled",-1),e("option",{value:!1},"Disabled",-1)]),512)),[[oe,n.value]]):(o(),r("div",Vn,c(n.value?"Enabled":"Disabled"),1))]),e("div",Dn,[l[31]||(l[31]=e("div",null,[e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Bucket Capacity"),e("p",{class:"text-xs text-content-muted dark:text-content-muted"},"Max burst size (adverts)")],-1)),s.value?S((o(),r("input",{key:1,"onUpdate:modelValue":l[2]||(l[2]=T=>t.value=T),type:"number",min:"1",max:"10",class:"w-full sm:w-24 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,t.value,void 0,{number:!0}]]):(o(),r("div",In,c(t.value),1))]),e("div",Hn,[l[33]||(l[33]=e("div",null,[e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Refill Interval"),e("p",{class:"text-xs text-content-muted dark:text-content-muted"},"Time between token refills")],-1)),s.value?(o(),r("div",Rn,[S(e("input",{"onUpdate:modelValue":l[3]||(l[3]=T=>A.value=T),type:"number",min:"1",max:"48",class:"w-20 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,A.value,void 0,{number:!0}]]),l[32]||(l[32]=e("span",{class:"text-content-muted text-sm"},"hours",-1))])):(o(),r("div",Un,c(A.value)+" hours",1))]),e("div",Kn,[l[35]||(l[35]=e("div",null,[e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Minimum Interval"),e("p",{class:"text-xs text-content-muted dark:text-content-muted"},"Hard minimum between adverts")],-1)),s.value?(o(),r("div",qn,[S(e("input",{"onUpdate:modelValue":l[4]||(l[4]=T=>b.value=T),type:"number",min:"0",max:"1440",class:"w-20 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,b.value,void 0,{number:!0}]]),l[34]||(l[34]=e("span",{class:"text-content-muted text-sm"},"min",-1))])):(o(),r("div",On,c(b.value)+" min",1))])]),e("div",Wn,[l[47]||(l[47]=e("h3",{class:"text-sm font-medium text-content-primary dark:text-content-primary flex items-center gap-2"},[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"})]),U(" Penalty Box (Repeat Offenders) ")],-1)),l[48]||(l[48]=e("p",{class:"text-xs text-content-muted dark:text-content-muted"},"Applies escalating cooldowns to pubkeys that repeatedly violate limits.",-1)),e("div",Gn,[l[39]||(l[39]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Penalty Box",-1)),s.value?S((o(),r("select",{key:1,"onUpdate:modelValue":l[5]||(l[5]=T=>x.value=T),class:"w-full sm:w-32 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},l[38]||(l[38]=[e("option",{value:!0},"Enabled",-1),e("option",{value:!1},"Disabled",-1)]),512)),[[oe,x.value]]):(o(),r("div",Qn,c(x.value?"Enabled":"Disabled"),1))]),e("div",Yn,[l[40]||(l[40]=e("div",null,[e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Violation Threshold"),e("p",{class:"text-xs text-content-muted dark:text-content-muted"},"Violations before penalty")],-1)),s.value?S((o(),r("input",{key:1,"onUpdate:modelValue":l[6]||(l[6]=T=>g.value=T),type:"number",min:"1",max:"10",class:"w-full sm:w-24 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,g.value,void 0,{number:!0}]]):(o(),r("div",Xn,c(g.value),1))]),e("div",Jn,[l[42]||(l[42]=e("div",null,[e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Base Penalty Duration"),e("p",{class:"text-xs text-content-muted dark:text-content-muted"},"First penalty duration")],-1)),s.value?(o(),r("div",ea,[S(e("input",{"onUpdate:modelValue":l[7]||(l[7]=T=>W.value=T),type:"number",min:"1",max:"48",class:"w-20 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,W.value,void 0,{number:!0}]]),l[41]||(l[41]=e("span",{class:"text-content-muted text-sm"},"hours",-1))])):(o(),r("div",Zn,c(W.value)+" hours",1))]),e("div",ta,[l[44]||(l[44]=e("div",null,[e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Penalty Multiplier"),e("p",{class:"text-xs text-content-muted dark:text-content-muted"},"Escalation factor")],-1)),s.value?(o(),r("div",ra,[S(e("input",{"onUpdate:modelValue":l[8]||(l[8]=T=>C.value=T),type:"number",min:"1",max:"5",step:"0.5",class:"w-20 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,C.value,void 0,{number:!0}]]),l[43]||(l[43]=e("span",{class:"text-content-muted text-sm"},"x",-1))])):(o(),r("div",oa,c(C.value)+"x",1))]),e("div",sa,[l[46]||(l[46]=e("div",null,[e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Max Penalty Duration"),e("p",{class:"text-xs text-content-muted dark:text-content-muted"},"Maximum cooldown cap")],-1)),s.value?(o(),r("div",aa,[S(e("input",{"onUpdate:modelValue":l[9]||(l[9]=T=>j.value=T),type:"number",min:"1",max:"168",class:"w-20 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,j.value,void 0,{number:!0}]]),l[45]||(l[45]=e("span",{class:"text-content-muted text-sm"},"hours",-1))])):(o(),r("div",na,c(j.value)+" hours",1))])]),e("div",la,[l[58]||(l[58]=Q('

Adaptive Rate Limiting

How the three systems work together: Each layer can be enabled/disabled independently and the others will still function.

  • Rate Limiting OFF: All limiting disabled — adverts pass through freely
  • Adaptive OFF: Token bucket uses fixed limits (no tier scaling), penalty box still works
  • Penalty Box OFF: Token bucket still applies, but no escalating cooldowns for repeat offenders

Decision flow when all enabled: Adaptive tier check → Penalty box check → Token bucket check → Violation recording (triggers penalty box)

Activity tiers:Quiet (bypass limiting) → Normal (lighter: 0.5x intervals) → Busy (base: 1.0x intervals) → Congested (stricter: 2.0x intervals)

Note: Adaptive mode scales refill/min-interval timing; bucket capacity stays at the configured base value.

',2)),e("div",da,[l[50]||(l[50]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Adaptive Mode",-1)),s.value?S((o(),r("select",{key:1,"onUpdate:modelValue":l[10]||(l[10]=T=>P.value=T),class:"w-full sm:w-32 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},l[49]||(l[49]=[e("option",{value:!0},"Enabled",-1),e("option",{value:!1},"Disabled",-1)]),512)),[[oe,P.value]]):(o(),r("div",ia,c(P.value?"Enabled":"Disabled"),1))]),e("div",ca,[l[52]||(l[52]=e("div",null,[e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Tier Change Delay"),e("p",{class:"text-xs text-content-muted dark:text-content-muted"},"Prevents tier flapping")],-1)),s.value?(o(),r("div",ma,[S(e("input",{"onUpdate:modelValue":l[11]||(l[11]=T=>E.value=T),type:"number",min:"0",max:"60",class:"w-20 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,E.value,void 0,{number:!0}]]),l[51]||(l[51]=e("span",{class:"text-content-muted text-sm"},"min",-1))])):(o(),r("div",ua,c(E.value)+" min",1))]),e("div",pa,[l[56]||(l[56]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm mb-2 block"},"Activity Tier Thresholds (adverts/min)",-1)),e("div",va,[e("div",xa,[l[53]||(l[53]=e("div",{class:"text-xs text-green-600 dark:text-green-400 mb-1"},"Quiet Max",-1)),s.value?S((o(),r("input",{key:1,"onUpdate:modelValue":l[12]||(l[12]=T=>w.value=T),type:"number",min:"0",max:"1",step:"0.01",class:"w-full px-2 py-1 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded text-content-primary dark:text-content-primary text-sm text-center focus:outline-none focus:border-primary"},null,512)),[[V,w.value,void 0,{number:!0}]]):(o(),r("div",ba,c(w.value),1))]),e("div",ka,[l[54]||(l[54]=e("div",{class:"text-xs text-blue-600 dark:text-blue-400 mb-1"},"Normal Max",-1)),s.value?S((o(),r("input",{key:1,"onUpdate:modelValue":l[13]||(l[13]=T=>f.value=T),type:"number",min:"0",max:"5",step:"0.01",class:"w-full px-2 py-1 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded text-content-primary dark:text-content-primary text-sm text-center focus:outline-none focus:border-primary"},null,512)),[[V,f.value,void 0,{number:!0}]]):(o(),r("div",ga,c(f.value),1))]),e("div",ya,[l[55]||(l[55]=e("div",{class:"text-xs text-yellow-600 dark:text-yellow-400 mb-1"},"Busy Max",-1)),s.value?S((o(),r("input",{key:1,"onUpdate:modelValue":l[14]||(l[14]=T=>H.value=T),type:"number",min:"0",max:"10",step:"0.01",class:"w-full px-2 py-1 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded text-content-primary dark:text-content-primary text-sm text-center focus:outline-none focus:border-primary"},null,512)),[[V,H.value,void 0,{number:!0}]]):(o(),r("div",fa,c(H.value),1))])]),l[57]||(l[57]=e("p",{class:"text-xs text-content-muted dark:text-content-muted mt-2"},"Above Busy Max = Congested tier (strictest limiting)",-1))])]),N.value?(o(),r("div",{key:2,class:"fixed inset-0 bg-black/50 flex items-start justify-center z-50 p-4 overflow-y-auto",onClick:l[18]||(l[18]=Y(T=>N.value=!1,["self"]))},[e("div",{class:"bg-background dark:bg-background-dark rounded-lg shadow-xl max-w-3xl w-full my-8",onClick:l[17]||(l[17]=Y(()=>{},["stop"]))},[e("div",ha,[e("div",wa,[l[60]||(l[60]=e("h2",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"How Advert Rate Limiting Works",-1)),e("button",{onClick:l[15]||(l[15]=T=>N.value=!1),class:"text-content-muted hover:text-content-primary dark:text-content-muted dark:hover:text-content-primary"},l[59]||(l[59]=[e("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),l[61]||(l[61]=Q('

Why you may see the same advert more than once

Mesh traffic can reach your repeater through different paths, so duplicate advert packets are expected.

  • First copy arrives and is forwarded
  • Second copy arrives through another repeater path
  • Later copies may be dropped once limits are hit

This is normal behavior and helps prevent repeated rebroadcasts from flooding the mesh.

Token Bucket Rate Limiting

Each sender has a token bucket. Every forwarded advert uses one token.

  • Bucket Capacity: How many adverts can pass in a burst.
  • Refill Rate: How quickly tokens come back over time.
  • Min Interval: Optional gap between adverts from the same sender (usually set to 0).
Example (capacity 2):
- Copy 1 forwarded (2 → 1 tokens)
- Copy 2 forwarded (1 → 0 tokens)
- Copy 3 dropped (no tokens left)

Penalty Box (Repeat Offenders)

If a sender keeps hitting the limit, it is temporarily blocked.

  • Violation Threshold: How many hits before penalty starts.
  • Base Penalty: First block duration.
  • Multiplier: Repeated penalties get longer.
  • Decay Time: Violations age out after stable behavior.

Adaptive Mesh Activity Tiers

Adaptive mode adjusts limits based on recent advert activity.

How Congestion is Measured:
  • What is counted: Advert packets only (not chat/data traffic)
  • Smoothing: 60-second EWMA to avoid reacting to short spikes
  • Score: Tier is based on adverts per minute
  • Hysteresis: Tier changes must hold for 5 minutes
QUIET
Activity < 0.05/min
No rate limiting
NORMAL
Activity 0.05-0.20/min
Light limiting (50%)
BUSY
Activity 0.20-0.50/min
Standard limiting (100%)
CONGESTED
Activity > 0.50/min
Aggressive (200%)
Quick examples:
- 0.02 adverts/min → QUIET (bypass)
- 0.35 adverts/min → BUSY (tighter limits)
- 0.68 adverts/min → CONGESTED (strict limits)

Recommended starting settings

  • Min Interval: 0 (disabled), let adaptive mode do the work
  • Bucket Capacity: 2-3 tokens for normal mesh propagation
  • Adaptive Mode: On
  • Penalty Box: On
',5)),e("div",_a,[e("button",{onClick:l[16]||(l[16]=T=>N.value=!1),class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors"}," Got it! ")])])])])):L("",!0)]))}}),$a={class:"p-3 sm:p-6 space-y-4 sm:space-y-6"},Ma={class:"glass-card rounded-[15px] z-10 p-3 sm:p-4 border border-cyan-400 dark:border-primary/30 bg-cyan-500/10 dark:bg-primary/10"},Aa={class:"text-cyan-700 dark:text-primary text-sm sm:text-base"},Sa={class:"mt-1 sm:mt-2 text-cyan-600 dark:text-primary/80"},ja={class:"glass-card rounded-[15px] p-3 sm:p-6"},Ta={class:"flex overflow-x-auto border-b border-stroke-subtle dark:border-stroke/10 mb-4 sm:mb-6 -mx-3 px-3 sm:mx-0 sm:px-0 scrollbar-hide"},Na=["onClick"],Ba={class:"flex items-center gap-1 sm:gap-2"},Ea={key:0,class:"w-3.5 h-3.5 sm:w-4 sm:h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},La={key:1,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Fa={key:2,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Pa={key:3,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},za={key:4,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Va={key:5,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Da={key:6,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Ia={key:7,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Ha={class:"min-h-[400px]"},Ua={key:0,class:"flex items-center justify-center py-12"},Ra={key:1,class:"flex items-center justify-center py-12"},Ka={class:"text-center"},Oa={class:"text-content-secondary dark:text-content-muted text-sm mb-4"},qa={key:2},Xa=G({name:"ConfigurationView",__name:"Configuration",setup(I){const _=re(),d=m(Ae("configuration_activeTab","radio")),v=m(!1);te(d,u=>Se("configuration_activeTab",u));const k=[{id:"radio",label:"Radio Settings",icon:"radio"},{id:"repeater",label:"Repeater Settings",icon:"repeater"},{id:"advert",label:"Advert Limits",icon:"advert"},{id:"duty",label:"Duty Cycle",icon:"duty"},{id:"delays",label:"TX Delays",icon:"delays"},{id:"transport",label:"Regions/Keys",icon:"keys"},{id:"api-tokens",label:"API Tokens",icon:"tokens"},{id:"web",label:"Web Options",icon:"web"}];ae(async()=>{try{await _.fetchStats(),v.value=!0}catch(u){console.error("Failed to load configuration data:",u),v.value=!0}});function i(u){d.value=u}return(u,s)=>{const p=be("router-link");return o(),r("div",$a,[s[14]||(s[14]=e("div",null,[e("h1",{class:"text-xl sm:text-2xl font-bold text-content-primary dark:text-content-primary"},"Configuration"),e("p",{class:"text-content-secondary dark:text-content-muted mt-1 sm:mt-2 text-sm sm:text-base"},"System configuration and settings")],-1)),e("div",Ma,[e("div",Aa,[s[3]||(s[3]=e("strong",null,"CAD Calibration Tool Available",-1)),e("p",Sa,[s[2]||(s[2]=U(" Optimize your Channel Activity Detection settings. ",-1)),O(p,{to:"/cad-calibration",class:"underline hover:text-cyan-800 dark:hover:text-primary transition-colors"},{default:ve(()=>s[1]||(s[1]=[U(" Launch CAD Calibration Tool → ",-1)])),_:1,__:[1]})])])]),e("div",ja,[e("div",Ta,[(o(),r(K,null,Z(k,y=>e("button",{key:y.id,onClick:h=>i(y.id),class:D(["px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium transition-colors duration-200 border-b-2 mr-3 sm:mr-6 whitespace-nowrap flex-shrink-0",d.value===y.id?"text-cyan-500 dark:text-primary border-cyan-500 dark:border-primary":"text-content-secondary dark:text-content-muted border-transparent hover:text-content-primary dark:hover:text-content-primary hover:border-stroke-subtle dark:hover:border-stroke/30"])},[e("div",Ba,[y.icon==="radio"?(o(),r("svg",Ea,s[4]||(s[4]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.822c5.716-5.716 14.976-5.716 20.692 0"},null,-1)]))):y.icon==="repeater"?(o(),r("svg",La,s[5]||(s[5]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 12h14M5 12l4-4m-4 4l4 4"},null,-1)]))):y.icon==="advert"?(o(),r("svg",Fa,s[6]||(s[6]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"},null,-1)]))):y.icon==="duty"?(o(),r("svg",Pa,s[7]||(s[7]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)]))):y.icon==="delays"?(o(),r("svg",za,s[8]||(s[8]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"},null,-1)]))):y.icon==="keys"?(o(),r("svg",Va,s[9]||(s[9]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"},null,-1)]))):y.icon==="tokens"?(o(),r("svg",Da,s[10]||(s[10]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"},null,-1)]))):y.icon==="web"?(o(),r("svg",Ia,s[11]||(s[11]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"},null,-1)]))):L("",!0),U(" "+c(y.label),1)])],10,Na)),64))]),e("div",Ha,[!v.value&&J(_).isLoading?(o(),r("div",Ua,s[12]||(s[12]=[e("div",{class:"text-center"},[e("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-cyan-500 dark:border-t-primary rounded-full mx-auto mb-4"}),e("div",{class:"text-content-secondary dark:text-content-muted"},"Loading configuration...")],-1)]))):J(_).error&&!v.value?(o(),r("div",Ra,[e("div",Ka,[s[13]||(s[13]=e("div",{class:"text-red-500 dark:text-red-400 mb-2"},"Failed to load configuration",-1)),e("div",Oa,c(J(_).error),1),e("button",{onClick:s[0]||(s[0]=y=>J(_).fetchStats()),class:"px-4 py-2 bg-cyan-500/20 dark:bg-primary/20 hover:bg-cyan-500/30 dark:hover:bg-primary/30 text-cyan-900 dark:text-white rounded-lg border border-cyan-500/50 dark:border-primary/50 transition-colors"}," Retry ")])])):(o(),r("div",qa,[S(e("div",null,[O(st,{key:"radio-settings"})],512),[[ee,d.value==="radio"]]),S(e("div",null,[O(zt,{key:"repeater-settings"})],512),[[ee,d.value==="repeater"]]),S(e("div",null,[O(Ca,{key:"advert-settings"})],512),[[ee,d.value==="advert"]]),S(e("div",null,[O(Qt,{key:"duty-cycle"})],512),[[ee,d.value==="duty"]]),S(e("div",null,[O(co,{key:"transmission-delays"})],512),[[ee,d.value==="delays"]]),S(e("div",null,[O(cs,{key:"transport-keys"})],512),[[ee,d.value==="transport"]]),S(e("div",null,[O(Vs,{key:"api-tokens"})],512),[[ee,d.value==="api-tokens"]]),S(e("div",null,[O(cn,{key:"web-settings"})],512),[[ee,d.value==="web"]])]))])])])}}});export{Xa as default}; diff --git a/repeater/web/html/assets/Configuration-DRrPdRyZ.js b/repeater/web/html/assets/Configuration-DRrPdRyZ.js deleted file mode 100644 index c7e14bb..0000000 --- a/repeater/web/html/assets/Configuration-DRrPdRyZ.js +++ /dev/null @@ -1,2 +0,0 @@ -const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/leaflet-src-BtisrQHC.js","assets/_commonjsHelpers-CqkleIqs.js"])))=>i.map(i=>d[i]); -import{a as q,M as ee,c as N,r as v,D as Q,b as s,g as S,e,t as b,F as K,w as E,v as V,h as Y,q as te,k as I,L as U,p as r,P as pe,s as G,E as ne,S as ae,x as le,f as R,y as de,d as be,U as re,j as P,l as ie,N as ce,V as ue,T as ve,i as O,W as Z,o as oe,u as W,X as xe,Q as J}from"./index-sHch0610.js";/* empty css */import{_ as ke}from"./ConfirmDialog.vue_vue_type_script_setup_true_lang-CT6z2S3q.js";import{g as ge,s as ye}from"./preferences-DtwbSSgO.js";const fe={class:"space-y-4"},he={key:0,class:"bg-green-100 dark:bg-green-500/20 border border-green-500/50 rounded-lg p-3"},we={class:"text-green-600 dark:text-green-400 text-sm"},_e={key:1,class:"bg-red-100 dark:bg-red-500/20 border border-red-500/50 rounded-lg p-3"},$e={class:"text-red-600 dark:text-red-400 text-sm"},Ce={class:"flex justify-end gap-2"},Me=["disabled"],Ae=["disabled"],je={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},Ne={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Se={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Te={key:1,class:"flex items-center gap-2"},Be={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Ee={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Fe={key:1},Le=["value"],Pe={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},ze={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Ie={key:1},De=["value"],He={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Ue={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Ve={key:1,class:"flex items-center gap-2"},Re={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},Ke={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},qe={key:1},We={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 gap-1"},Oe={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Ge={key:2,class:"bg-yellow-500/10 dark:bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3"},Ye=q({__name:"RadioSettings",setup(z){const f=ee(),l=N(()=>f.stats?.config?.radio||{}),m=v(!1),g=v(!1),d=v(null),i=v(null),o=v(0),c=v(0),y=v(0),h=v(0),A=v(0),$=v(0),C=[{value:7.8,label:"7.8 kHz"},{value:10.4,label:"10.4 kHz"},{value:15.6,label:"15.6 kHz"},{value:20.8,label:"20.8 kHz"},{value:31.25,label:"31.25 kHz"},{value:41.7,label:"41.7 kHz"},{value:62.5,label:"62.5 kHz"},{value:125,label:"125 kHz"},{value:250,label:"250 kHz"},{value:500,label:"500 kHz"}];Q(l,_=>{_&&!m.value&&(o.value=_.frequency?Number((_.frequency/1e6).toFixed(3)):0,c.value=_.spreading_factor??0,y.value=_.bandwidth?Number((_.bandwidth/1e3).toFixed(1)):0,h.value=_.tx_power??0,A.value=_.coding_rate??0,$.value=_.preamble_length??0)},{immediate:!0});const a=N(()=>{const _=l.value.frequency;return _?(_/1e6).toFixed(3)+" MHz":"Not set"}),t=N(()=>{const _=l.value.bandwidth;return _?(_/1e3).toFixed(1)+" kHz":"Not set"}),n=N(()=>{const _=l.value.tx_power;return _!==void 0?_+" dBm":"Not set"}),M=N(()=>{const _=l.value.coding_rate;return _?"4/"+_:"Not set"}),p=N(()=>{const _=l.value.preamble_length;return _?_+" symbols":"Not set"}),u=N(()=>l.value.spreading_factor??"Not set"),x=()=>{m.value=!0,d.value=null,i.value=null},L=()=>{m.value=!1,d.value=null;const _=l.value;o.value=_.frequency?Number((_.frequency/1e6).toFixed(3)):0,c.value=_.spreading_factor??0,y.value=_.bandwidth?Number((_.bandwidth/1e3).toFixed(1)):0,h.value=_.tx_power??0,A.value=_.coding_rate??0,$.value=_.preamble_length??0},X=async()=>{g.value=!0,d.value=null,i.value=null;try{const _={};o.value&&(_.frequency=o.value*1e6),c.value&&(_.spreading_factor=c.value),y.value&&(_.bandwidth=y.value*1e3),h.value&&(_.tx_power=h.value),A.value&&(_.coding_rate=A.value);const F=(await U.post("/update_radio_config",_)).data;F.message||F.persisted?(i.value=F.message||"Settings saved successfully",m.value=!1,await f.fetchStats(),setTimeout(()=>{i.value=null},3e3)):F.error?d.value=F.error:d.value="Unknown response from server"}catch(_){console.error("Failed to update radio settings:",_);const j=_;d.value=j.response?.data?.error||"Failed to update settings"}finally{g.value=!1}};return(_,j)=>(r(),s("div",fe,[i.value?(r(),s("div",he,[e("p",we,b(i.value),1)])):S("",!0),d.value?(r(),s("div",_e,[e("p",$e,b(d.value),1)])):S("",!0),e("div",Ce,[m.value?(r(),s(K,{key:1},[e("button",{onClick:L,disabled:g.value,class:"px-3 sm:px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"}," Cancel ",8,Me),e("button",{onClick:X,disabled:g.value,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"},b(g.value?"Saving...":"Save Changes"),9,Ae)],64)):(r(),s("button",{key:0,onClick:x,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Edit Settings "))]),e("div",je,[e("div",Ne,[j[6]||(j[6]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Frequency",-1)),m.value?(r(),s("div",Te,[E(e("input",{"onUpdate:modelValue":j[0]||(j[0]=F=>o.value=F),type:"number",step:"0.001",min:"100",max:"1000",class:"w-32 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,o.value,void 0,{number:!0}]]),j[5]||(j[5]=e("span",{class:"text-content-muted dark:text-content-muted text-sm"},"MHz",-1))])):(r(),s("div",Se,b(a.value),1))]),e("div",Be,[j[7]||(j[7]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Spreading Factor",-1)),m.value?(r(),s("div",Fe,[E(e("select",{"onUpdate:modelValue":j[1]||(j[1]=F=>c.value=F),class:"px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},[(r(),s(K,null,Y([5,6,7,8,9,10,11,12],F=>e("option",{key:F,value:F},b(F),9,Le)),64))],512),[[te,c.value,void 0,{number:!0}]])])):(r(),s("div",Ee,b(u.value),1))]),e("div",Pe,[j[8]||(j[8]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Bandwidth",-1)),m.value?(r(),s("div",Ie,[E(e("select",{"onUpdate:modelValue":j[2]||(j[2]=F=>y.value=F),class:"px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},[(r(),s(K,null,Y(C,F=>e("option",{key:F.value,value:F.value},b(F.label),9,De)),64))],512),[[te,y.value,void 0,{number:!0}]])])):(r(),s("div",ze,b(t.value),1))]),e("div",He,[j[10]||(j[10]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"TX Power",-1)),m.value?(r(),s("div",Ve,[E(e("input",{"onUpdate:modelValue":j[3]||(j[3]=F=>h.value=F),type:"number",min:"2",max:"30",class:"w-20 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,h.value,void 0,{number:!0}]]),j[9]||(j[9]=e("span",{class:"text-content-muted dark:text-content-muted text-sm"},"dBm",-1))])):(r(),s("div",Ue,b(n.value),1))]),e("div",Re,[j[12]||(j[12]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Coding Rate",-1)),m.value?(r(),s("div",qe,[E(e("select",{"onUpdate:modelValue":j[4]||(j[4]=F=>A.value=F),class:"px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},j[11]||(j[11]=[e("option",{value:5},"4/5",-1),e("option",{value:6},"4/6",-1),e("option",{value:7},"4/7",-1),e("option",{value:8},"4/8",-1)]),512),[[te,A.value,void 0,{number:!0}]])])):(r(),s("div",Ke,b(M.value),1))]),e("div",We,[j[13]||(j[13]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Preamble Length",-1)),e("span",Oe,b(p.value),1)])]),m.value?(r(),s("div",Ge,j[14]||(j[14]=[e("p",{class:"text-yellow-700 dark:text-yellow-400 text-xs"},[e("strong",null,"Note:"),I(" Radio hardware changes (frequency, bandwidth, spreading factor, coding rate) may require a service restart to apply. ")],-1)]))):S("",!0)]))}}),Xe={class:"glass-card border border-stroke-subtle dark:border-white/20 rounded-[15px] w-full max-w-3xl max-h-[90vh] flex flex-col shadow-2xl"},Je={class:"flex-1 relative min-h-[400px]"},Qe={class:"p-6 border-t border-stroke-subtle dark:border-stroke/10 space-y-4"},Ze={class:"grid grid-cols-2 gap-4"},et=q({__name:"LocationPicker",props:{isOpen:{type:Boolean},latitude:{},longitude:{}},emits:["close","select"],setup(z,{emit:f}){const l=z,m=f,g=v(null),d=v(l.latitude||0),i=v(l.longitude||0);let o=null,c=null;const y=async()=>{if(g.value){h();try{const a=(await ae(async()=>{const{default:p}=await import("./leaflet-src-BtisrQHC.js").then(u=>u.l);return{default:p}},__vite__mapDeps([0,1]))).default;delete a.Icon.Default.prototype._getIconUrl,a.Icon.Default.mergeOptions({iconRetinaUrl:"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",iconUrl:"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",shadowUrl:"https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png"}),await ne();const t=d.value||0,n=i.value||0,M=t===0&&n===0?2:13;o=a.map(g.value).setView([t,n],M);try{const p=a.tileLayer("https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png",{maxZoom:19,attribution:'© OpenStreetMap contributors © CARTO',errorTileUrl:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}),u=a.tileLayer("https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png",{maxZoom:19,attribution:"",errorTileUrl:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="});p.addTo(o),u.addTo(o)}catch(p){console.warn("Error loading tiles:",p)}(t!==0||n!==0)&&(c=a.marker([t,n]).addTo(o)),o.on("click",p=>{d.value=p.latlng.lat,i.value=p.latlng.lng,c?c.setLatLng(p.latlng):c=a.marker(p.latlng).addTo(o)}),setTimeout(()=>{o?.invalidateSize()},200)}catch(a){console.error("Failed to initialize map:",a)}}},h=()=>{o&&(o.remove(),o=null,c=null)};Q(()=>l.isOpen,async a=>{a?(await ne(),await y()):h()}),Q(()=>[l.latitude,l.longitude],([a,t])=>{d.value=a,i.value=t});const A=()=>{m("select",{latitude:d.value,longitude:i.value}),m("close")},$=()=>{m("close")},C=()=>{navigator.geolocation?navigator.geolocation.getCurrentPosition(async a=>{if(d.value=a.coords.latitude,i.value=a.coords.longitude,o){o.setView([d.value,i.value],13);const t=(await ae(async()=>{const{default:n}=await import("./leaflet-src-BtisrQHC.js").then(M=>M.l);return{default:n}},__vite__mapDeps([0,1]))).default;c?c.setLatLng([d.value,i.value]):c=t.marker([d.value,i.value]).addTo(o)}},a=>{console.error("Error getting location:",a),alert("Unable to get current location. Please check browser permissions.")}):alert("Geolocation is not supported by this browser.")};return pe(()=>{h()}),(a,t)=>a.isOpen?(r(),s("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm",onClick:G($,["self"])},[e("div",Xe,[e("div",{class:"flex items-center justify-between p-6 border-b border-stroke-subtle dark:border-stroke/10"},[t[3]||(t[3]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"Select Location",-1)),e("button",{onClick:$,class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},t[2]||(t[2]=[e("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),e("div",Je,[e("div",{ref_key:"mapContainer",ref:g,class:"absolute inset-0 rounded-b-[15px] overflow-hidden"},null,512)]),e("div",Qe,[e("div",Ze,[e("div",null,[t[4]||(t[4]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-muted mb-2"},"Latitude",-1)),E(e("input",{"onUpdate:modelValue":t[0]||(t[0]=n=>d.value=n),type:"number",step:"0.000001",class:"w-full px-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary focus:outline-none focus:border-primary",readonly:""},null,512),[[V,d.value,void 0,{number:!0}]])]),e("div",null,[t[5]||(t[5]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-muted mb-2"},"Longitude",-1)),E(e("input",{"onUpdate:modelValue":t[1]||(t[1]=n=>i.value=n),type:"number",step:"0.000001",class:"w-full px-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary focus:outline-none focus:border-primary",readonly:""},null,512),[[V,i.value,void 0,{number:!0}]])])]),e("div",{class:"flex gap-3"},[e("button",{onClick:C,class:"flex-1 px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm flex items-center justify-center gap-2"},t[6]||(t[6]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"}),e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 11a3 3 0 11-6 0 3 3 0 016 0z"})],-1),I(" Use Current Location ",-1)])),e("button",{onClick:$,class:"px-6 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm"}," Cancel "),e("button",{onClick:A,class:"px-6 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Select Location ")]),t[7]||(t[7]=e("p",{class:"text-content-muted dark:text-content-muted text-xs text-center"},"Click on the map to select a location",-1))])])])):S("",!0)}}),tt=le(et,[["__scopeId","data-v-186d3c86"]]),ot={class:"space-y-4"},rt={key:0,class:"bg-green-100 dark:bg-green-500/10 border border-green-300 dark:border-green-500/30 rounded-lg p-3"},st={class:"text-green-700 dark:text-green-400 text-sm"},nt={key:1,class:"bg-red-100 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded-lg p-3"},at={class:"text-red-700 dark:text-red-400 text-sm"},lt={class:"flex justify-end gap-2"},dt=["disabled"],it=["disabled"],ct={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},ut={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},mt={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm break-all"},pt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},bt={class:"text-content-primary dark:text-content-primary font-mono text-xs break-all"},vt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-start py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},xt={class:"text-content-primary dark:text-content-primary font-mono text-xs break-all sm:text-right sm:max-w-xs"},kt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},gt={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},yt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},ft={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},ht={key:0,class:"flex justify-end"},wt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},_t={class:"text-content-primary dark:text-content-primary font-mono text-sm"},$t={class:"flex flex-col py-2 gap-2"},Ct={class:"flex flex-col sm:flex-row sm:justify-between sm:items-start gap-1"},Mt={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm sm:ml-4"},At={key:1,class:"flex items-center gap-2"},jt=q({__name:"RepeaterSettings",setup(z){const f=ee(),l=N(()=>f.stats?.config||{}),m=N(()=>l.value.repeater||{}),g=N(()=>f.stats),d=v(!1),i=v(!1),o=v(null),c=v(null),y=v(!1),h=v(""),A=v(0),$=v(0),C=v(0);Q([l,m],()=>{d.value||(h.value=l.value.node_name||"",A.value=m.value.latitude||0,$.value=m.value.longitude||0,C.value=m.value.send_advert_interval_hours||0)},{immediate:!0});const a=N(()=>l.value.node_name||"Not set"),t=N(()=>g.value?.local_hash||"Not available"),n=N(()=>{const T=g.value?.public_key;return!T||T==="Not set"?"Not set":T}),M=N(()=>{const T=m.value.latitude;return T&&T!==0?T.toFixed(6):"Not set"}),p=N(()=>{const T=m.value.longitude;return T&&T!==0?T.toFixed(6):"Not set"}),u=N(()=>{const T=m.value.mode;return T?T.charAt(0).toUpperCase()+T.slice(1):"Not set"}),x=N(()=>{const T=m.value.send_advert_interval_hours;return T===void 0?"Not set":T===0?"Disabled":T+" hour"+(T!==1?"s":"")}),L=()=>{d.value=!0,o.value=null,c.value=null},X=()=>{d.value=!1,o.value=null,h.value=l.value.node_name||"",A.value=m.value.latitude||0,$.value=m.value.longitude||0,C.value=m.value.send_advert_interval_hours||0},_=async()=>{i.value=!0,o.value=null,c.value=null;try{const T={};h.value&&(T.node_name=h.value),T.latitude=A.value,T.longitude=$.value,T.flood_advert_interval_hours=C.value;const w=(await U.post("/update_radio_config",T)).data;w.message||w.persisted?(c.value=w.message||"Settings saved successfully",d.value=!1,await f.fetchStats(),setTimeout(()=>{c.value=null},3e3)):w.error?o.value=w.error:o.value="Unknown response from server"}catch(T){console.error("Failed to update repeater settings:",T);const B=T;o.value=B.response?.data?.error||"Failed to update settings"}finally{i.value=!1}},j=()=>{y.value=!0},F=T=>{A.value=T.latitude,$.value=T.longitude};return(T,B)=>(r(),s("div",ot,[c.value?(r(),s("div",rt,[e("p",st,b(c.value),1)])):S("",!0),o.value?(r(),s("div",nt,[e("p",at,b(o.value),1)])):S("",!0),e("div",lt,[d.value?(r(),s(K,{key:1},[e("button",{onClick:X,disabled:i.value,class:"px-3 sm:px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"}," Cancel ",8,dt),e("button",{onClick:_,disabled:i.value,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"},b(i.value?"Saving...":"Save Changes"),9,it)],64)):(r(),s("button",{key:0,onClick:L,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Edit Settings "))]),e("div",ct,[e("div",ut,[B[5]||(B[5]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Node Name",-1)),d.value?E((r(),s("input",{key:1,"onUpdate:modelValue":B[0]||(B[0]=w=>h.value=w),type:"text",maxlength:"50",class:"w-full sm:w-64 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary",placeholder:"Enter node name"},null,512)),[[V,h.value]]):(r(),s("div",mt,b(a.value),1))]),e("div",pt,[B[6]||(B[6]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Local Hash",-1)),e("span",bt,b(t.value),1)]),e("div",vt,[B[7]||(B[7]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm flex-shrink-0"},"Public Key",-1)),e("span",xt,b(n.value),1)]),e("div",kt,[B[8]||(B[8]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Latitude",-1)),d.value?E((r(),s("input",{key:1,"onUpdate:modelValue":B[1]||(B[1]=w=>A.value=w),type:"number",step:"0.000001",min:"-90",max:"90",class:"w-full sm:w-48 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,A.value,void 0,{number:!0}]]):(r(),s("div",gt,b(M.value),1))]),e("div",yt,[B[9]||(B[9]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Longitude",-1)),d.value?E((r(),s("input",{key:1,"onUpdate:modelValue":B[2]||(B[2]=w=>$.value=w),type:"number",step:"0.000001",min:"-180",max:"180",class:"w-full sm:w-48 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,$.value,void 0,{number:!0}]]):(r(),s("div",ft,b(p.value),1))]),d.value?(r(),s("div",ht,[e("button",{onClick:j,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm flex items-center gap-2",title:"Pick location on map"},B[10]||(B[10]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"}),e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 11a3 3 0 11-6 0 3 3 0 016 0z"})],-1),I(" Pick Location on Map ",-1)]))])):S("",!0),e("div",wt,[B[11]||(B[11]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Mode",-1)),e("span",_t,b(u.value),1)]),e("div",$t,[e("div",Ct,[B[13]||(B[13]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Periodic Advertisement Interval",-1)),d.value?(r(),s("div",At,[E(e("input",{"onUpdate:modelValue":B[3]||(B[3]=w=>C.value=w),type:"number",min:"0",max:"48",class:"w-20 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512),[[V,C.value,void 0,{number:!0}]]),B[12]||(B[12]=e("span",{class:"text-content-muted dark:text-content-muted text-sm"},"hours",-1))])):(r(),s("div",Mt,b(x.value),1))]),B[14]||(B[14]=e("span",{class:"text-content-muted dark:text-content-muted text-xs"},"How often the repeater sends an advertisement packet (0 = disabled, 3-48 hours)",-1))])]),R(tt,{"is-open":y.value,latitude:A.value,longitude:$.value,onClose:B[4]||(B[4]=w=>y.value=!1),onSelect:F},null,8,["is-open","latitude","longitude"])]))}}),Nt={class:"space-y-4"},St={key:0,class:"bg-green-100 dark:bg-green-500/20 border border-green-500 dark:border-green-500/50 rounded-lg p-3 text-green-700 dark:text-green-400 text-sm"},Tt={key:1,class:"bg-red-100 dark:bg-red-500/20 border border-red-500 dark:border-red-500/50 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm"},Bt={class:"flex justify-end gap-2"},Et=["disabled"],Ft=["disabled"],Lt={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},Pt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-1"},zt={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},It={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 gap-1"},Dt={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm"},Ht=q({__name:"DutyCycle",setup(z){const f=ee(),l=N(()=>f.stats?.config?.duty_cycle||{}),m=N(()=>{const a=l.value.max_airtime_percent;return typeof a=="number"?a.toFixed(1)+"%":a&&typeof a=="object"&&"parsedValue"in a?(a.parsedValue||0).toFixed(1)+"%":"Not set"}),g=N(()=>l.value.enforcement_enabled?"Enabled":"Disabled"),d=v(!1),i=v(!1),o=v(""),c=v(""),y=v(0),h=v(!0),A=()=>{const a=l.value.max_airtime_percent;typeof a=="number"?y.value=a:a&&typeof a=="object"&&"parsedValue"in a?y.value=a.parsedValue||0:y.value=6,h.value=l.value.enforcement_enabled!==!1,d.value=!0,o.value="",c.value=""},$=()=>{d.value=!1,o.value="",c.value=""},C=async()=>{i.value=!0,c.value="",o.value="";try{const t=(await de.post("/api/update_duty_cycle_config",{max_airtime_percent:y.value,enforcement_enabled:h.value})).data;t.message||t.persisted?(o.value=t.message||"Settings saved successfully",d.value=!1,await f.fetchStats(),setTimeout(()=>{o.value=""},3e3)):c.value="Failed to save settings"}catch(a){console.error("Failed to save duty cycle settings:",a),c.value=a.response?.data?.error||"Failed to save settings"}finally{i.value=!1}};return(a,t)=>(r(),s("div",Nt,[o.value?(r(),s("div",St,b(o.value),1)):S("",!0),c.value?(r(),s("div",Tt,b(c.value),1)):S("",!0),e("div",Bt,[d.value?(r(),s(K,{key:1},[e("button",{onClick:$,disabled:i.value,class:"px-3 sm:px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"}," Cancel ",8,Et),e("button",{onClick:C,disabled:i.value,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"},b(i.value?"Saving...":"Save Changes"),9,Ft)],64)):(r(),s("button",{key:0,onClick:A,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Edit Settings "))]),e("div",Lt,[e("div",Pt,[t[2]||(t[2]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Max Airtime %",-1)),d.value?E((r(),s("input",{key:1,"onUpdate:modelValue":t[0]||(t[0]=n=>y.value=n),type:"number",step:"0.1",min:"0.1",max:"100",class:"w-full sm:w-32 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,y.value,void 0,{number:!0}]]):(r(),s("div",zt,b(m.value),1))]),e("div",It,[t[4]||(t[4]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Enforcement",-1)),d.value?E((r(),s("select",{key:1,"onUpdate:modelValue":t[1]||(t[1]=n=>h.value=n),class:"w-full sm:w-32 px-3 py-1.5 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},t[3]||(t[3]=[e("option",{value:!0},"Enabled",-1),e("option",{value:!1},"Disabled",-1)]),512)),[[te,h.value]]):(r(),s("div",Dt,b(g.value),1))])])]))}}),Ut={class:"space-y-4"},Vt={key:0,class:"bg-green-100 dark:bg-green-500/20 border border-green-500 dark:border-green-500/50 rounded-lg p-3 text-green-700 dark:text-green-400 text-sm"},Rt={key:1,class:"bg-red-100 dark:bg-red-500/20 border border-red-500 dark:border-red-500/50 rounded-lg p-3 text-red-700 dark:text-red-400 text-sm"},Kt={class:"flex justify-end gap-2"},qt=["disabled"],Wt=["disabled"],Ot={class:"bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3"},Gt={class:"flex flex-col py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-2"},Yt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-start gap-1"},Xt={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm sm:ml-4"},Jt={class:"flex flex-col py-2 gap-2"},Qt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-start gap-1"},Zt={key:0,class:"text-content-primary dark:text-content-primary font-mono text-sm sm:ml-4"},eo=q({__name:"TransmissionDelays",setup(z){const f=ee(),l=N(()=>f.stats?.config?.delays||{}),m=N(()=>{const a=l.value.tx_delay_factor;if(a&&typeof a=="object"&&a!==null&&"parsedValue"in a){const t=a.parsedValue;if(typeof t=="number")return t.toFixed(2)+"x"}return"Not set"}),g=N(()=>{const a=l.value.direct_tx_delay_factor;return typeof a=="number"?a.toFixed(2)+"s":"Not set"}),d=v(!1),i=v(!1),o=v(""),c=v(""),y=v(0),h=v(0),A=()=>{const a=l.value.tx_delay_factor;a&&typeof a=="object"&&"parsedValue"in a?y.value=a.parsedValue||1:typeof a=="number"?y.value=a:y.value=1;const t=l.value.direct_tx_delay_factor;h.value=typeof t=="number"?t:.5,d.value=!0,o.value="",c.value=""},$=()=>{d.value=!1,o.value="",c.value=""},C=async()=>{i.value=!0,c.value="",o.value="";try{const t=(await de.post("/api/update_radio_config",{tx_delay_factor:y.value,direct_tx_delay_factor:h.value})).data;t.message||t.persisted?(o.value=t.message||"Settings saved successfully",d.value=!1,await f.fetchStats(),setTimeout(()=>{o.value=""},3e3)):c.value="Failed to save settings"}catch(a){console.error("Failed to save delay settings:",a),c.value=a.response?.data?.error||"Failed to save settings"}finally{i.value=!1}};return(a,t)=>(r(),s("div",Ut,[o.value?(r(),s("div",Vt,b(o.value),1)):S("",!0),c.value?(r(),s("div",Rt,b(c.value),1)):S("",!0),e("div",Kt,[d.value?(r(),s(K,{key:1},[e("button",{onClick:$,disabled:i.value,class:"px-3 sm:px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/20 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"}," Cancel ",8,qt),e("button",{onClick:C,disabled:i.value,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed"},b(i.value?"Saving...":"Save Changes"),9,Wt)],64)):(r(),s("button",{key:0,onClick:A,class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors text-sm"}," Edit Settings "))]),e("div",Ot,[e("div",Gt,[e("div",Yt,[t[2]||(t[2]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Flood TX Delay Factor",-1)),d.value?E((r(),s("input",{key:1,"onUpdate:modelValue":t[0]||(t[0]=n=>y.value=n),type:"number",step:"0.1",min:"0",max:"5",class:"w-full sm:w-32 px-3 py-1.5 bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,y.value,void 0,{number:!0}]]):(r(),s("div",Xt,b(m.value),1))]),t[3]||(t[3]=e("span",{class:"text-content-muted dark:text-content-muted text-xs"},"Multiplier for flood packet transmission delays (collision avoidance)",-1))]),e("div",Jt,[e("div",Qt,[t[4]||(t[4]=e("span",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Direct TX Delay Factor",-1)),d.value?E((r(),s("input",{key:1,"onUpdate:modelValue":t[1]||(t[1]=n=>h.value=n),type:"number",step:"0.1",min:"0",max:"5",class:"w-full sm:w-32 px-3 py-1.5 bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary"},null,512)),[[V,h.value,void 0,{number:!0}]]):(r(),s("div",Zt,b(g.value),1))]),t[5]||(t[5]=e("span",{class:"text-content-muted dark:text-content-muted text-xs"},"Base delay for direct-routed packet transmission (seconds)",-1))])])]))}}),me=be("treeState",()=>{const z=re(new Set),f=re({value:null}),l=o=>{z.add(o)},m=o=>{z.delete(o)};return{expandedNodes:z,selectedNodeId:f,addExpandedNode:l,removeExpandedNode:m,isNodeExpanded:o=>z.has(o),setSelectedNode:o=>{f.value=o},toggleExpanded:o=>{z.has(o)?m(o):l(o)}}}),to={class:"select-none"},oo={class:"flex-shrink-0"},ro={key:0,class:"w-3.5 h-3.5 sm:w-4 sm:h-4 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},so={key:1,class:"w-3.5 h-3.5 sm:w-4 sm:h-4 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},no={key:0,class:"hidden sm:flex items-center gap-1 ml-2"},ao={class:"relative group"},lo=["title"],io={key:0,class:"text-xs font-mono text-white/50 bg-white/5 px-1.5 py-0.5 rounded border border-white/10"},co={class:"flex justify-between items-start mb-4"},uo={class:"bg-black/20 border border-white/10 rounded-md p-4 mb-4"},mo={class:"text-sm font-mono text-white/80 break-all leading-relaxed"},po={class:"flex items-center gap-1 sm:gap-2 ml-auto flex-shrink-0"},bo={key:0,class:"hidden sm:flex items-center gap-1"},vo=["title"],xo={key:1,class:"hidden sm:flex items-center gap-1"},ko={key:2,class:"hidden sm:inline-block px-2 py-1 bg-white/10 text-white/60 text-xs rounded-full ml-1"},go={key:0,class:"space-y-1"},yo=q({__name:"TreeNode",props:{node:{},selectedNodeId:{},level:{},disabled:{type:Boolean}},emits:["select"],setup(z,{emit:f}){const l=z,m=f,g=me(),d=v(!1),i=N({get:()=>g.isNodeExpanded(l.node.id),set:t=>{t?g.addExpandedNode(l.node.id):g.removeExpandedNode(l.node.id)}}),o=N(()=>l.node.children.length>0);function c(t){if(!t)return"Never";const M=new Date().getTime()-t.getTime(),p=Math.floor(M/(1e3*60)),u=Math.floor(M/(1e3*60*60)),x=Math.floor(M/(1e3*60*60*24)),L=Math.floor(x/365);return p<60?`${p}m ago`:u<24?`${u}h ago`:x<365?`${x}d ago`:`${L}y ago`}function y(t){return t?t.length<=16?t:`${t.slice(0,8)}...${t.slice(-8)}`:"No key"}function h(){if(o.value){const t=!i.value;i.value=t}}function A(){m("select",l.node.id)}function $(t){m("select",t)}function C(t){t.stopPropagation(),d.value=!d.value}function a(t){t.stopPropagation(),l.node.transport_key&&window.navigator?.clipboard&&window.navigator.clipboard.writeText(l.node.transport_key)}return(t,n)=>{const M=ue("TreeNode",!0);return r(),s("div",to,[e("div",{class:P(["flex flex-wrap sm:flex-nowrap items-start sm:items-center gap-1 sm:gap-2 py-2 px-2 sm:px-3 rounded-lg cursor-pointer transition-all duration-200",l.disabled?"opacity-50 cursor-not-allowed":"hover:bg-white/5",t.selectedNodeId===t.node.id&&!l.disabled?"bg-primary/20 text-primary":"text-white/80 hover:text-white",`ml-${t.level*4}`]),onClick:n[3]||(n[3]=p=>!l.disabled&&A())},[e("div",{class:"flex-shrink-0 w-3 h-3 sm:w-4 sm:h-4 flex items-center justify-center",onClick:G(h,["stop"])},[o.value?(r(),s("svg",{key:0,class:P(["w-2.5 h-2.5 sm:w-3 sm:h-3 transition-transform duration-200",i.value?"rotate-90":"rotate-0"]),fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},n[4]||(n[4]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 5l7 7-7 7"},null,-1)]),2)):S("",!0)]),e("div",oo,[l.node.name.startsWith("#")?(r(),s("svg",ro,n[5]||(n[5]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(r(),s("svg",so,n[6]||(n[6]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"},null,-1)])))]),e("span",{class:P(["font-mono text-xs sm:text-sm transition-colors duration-200 break-all",t.selectedNodeId===t.node.id?"text-primary font-medium":""])},b(t.node.name),3),t.node.transport_key?(r(),s("div",no,[e("div",ao,[e("button",{onClick:C,class:"p-1 rounded hover:bg-white/10 transition-colors",title:d.value?"Hide full key":"Show full key"},n[7]||(n[7]=[e("svg",{class:"w-3 h-3 text-white/60 hover:text-white/80",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 12a3 3 0 11-6 0 3 3 0 016 0z"}),e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"})],-1)]),8,lo),d.value?S("",!0):(r(),s("span",io,b(y(t.node.transport_key)),1)),d.value?(r(),s("div",{key:1,class:"fixed inset-0 z-[9998] flex items-center justify-center bg-black/70 backdrop-blur-md",onClick:n[2]||(n[2]=p=>d.value=!1)},[e("div",{class:"bg-black/20 border border-white/20 rounded-lg shadow-lg p-6 max-w-2xl w-full mx-4",onClick:n[1]||(n[1]=G(()=>{},["stop"]))},[e("div",co,[n[9]||(n[9]=e("h3",{class:"text-lg font-semibold text-white"},"Transport Key",-1)),e("button",{onClick:n[0]||(n[0]=p=>d.value=!1),class:"text-white/60 hover:text-white transition-colors"},n[8]||(n[8]=[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),e("div",uo,[e("div",mo,b(t.node.transport_key),1)]),e("div",{class:"flex justify-end"},[e("button",{onClick:a,class:"px-4 py-2 bg-accent-green/20 hover:bg-accent-green/30 border border-accent-green/50 text-accent-green rounded-lg transition-colors flex items-center gap-2",title:"Copy to clipboard"},n[10]||(n[10]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"})],-1),I(" Copy Key ",-1)]))])])])):S("",!0)])])):S("",!0),e("div",po,[t.node.last_used?(r(),s("div",bo,[n[11]||(n[11]=e("svg",{class:"w-3 h-3 text-white/40",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"})],-1)),e("span",{class:"text-xs text-white/50",title:t.node.last_used.toLocaleString()},b(c(t.node.last_used)),9,vo)])):(r(),s("div",xo,n[12]||(n[12]=[e("svg",{class:"w-3 h-3 text-white/30",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"})],-1),e("span",{class:"text-xs text-white/30 italic"},"Never",-1)]))),e("span",{class:P(["px-1.5 sm:px-2 py-0.5 text-[10px] sm:text-xs font-medium rounded-md transition-colors",t.node.floodPolicy==="allow"?"bg-accent-green/10 text-accent-green/90 border border-accent-green/20":"bg-accent-red/10 text-accent-red/90 border border-accent-red/20"])},b(t.node.floodPolicy==="allow"?"ALLOW":"DENY"),3),o.value?(r(),s("span",ko," > "+b(t.node.children.length),1)):S("",!0)])],2),R(ve,{"enter-active-class":"transition-all duration-300 ease-out","enter-from-class":"opacity-0 max-h-0 overflow-hidden","enter-to-class":"opacity-100 max-h-screen overflow-visible","leave-active-class":"transition-all duration-300 ease-in","leave-from-class":"opacity-100 max-h-screen overflow-visible","leave-to-class":"opacity-0 max-h-0 overflow-hidden"},{default:ie(()=>[i.value&&t.node.children.length>0?(r(),s("div",go,[(r(!0),s(K,null,Y(t.node.children,p=>(r(),ce(M,{key:p.id,node:p,"selected-node-id":t.selectedNodeId,level:t.level+1,disabled:l.disabled,onSelect:$},null,8,["node","selected-node-id","level","disabled"]))),128))])):S("",!0)]),_:1})])}}}),fo=le(yo,[["__scopeId","data-v-59e9974c"]]),ho={class:"flex items-center justify-between mb-6"},wo={class:"text-content-secondary dark:text-content-muted text-sm mt-1"},_o={key:0},$o={class:"text-primary font-mono"},Co={key:1},Mo={for:"keyName",class:"block text-sm font-medium text-white mb-2"},Ao={class:"flex items-center gap-2"},jo={key:0,class:"w-4 h-4 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},No={key:1,class:"w-4 h-4 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},So={class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4"},To={class:"flex items-center gap-3 mb-2"},Bo={class:"flex items-center gap-2"},Eo={key:0,class:"w-5 h-5 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Fo={key:1,class:"w-5 h-5 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Lo={class:"text-content-secondary dark:text-content-muted text-sm"},Po={class:"grid grid-cols-2 gap-3"},zo={class:"relative cursor-pointer group"},Io={class:"relative cursor-pointer group"},Do={class:"flex gap-3 pt-4"},Ho=["disabled"],Uo=q({__name:"AddKeyModal",props:{show:{type:Boolean},selectedNodeName:{},selectedNodeId:{}},emits:["close","add"],setup(z,{emit:f}){const l=z,m=f,g=v(""),d=v(""),i=v("allow"),o=N(()=>g.value.startsWith("#")),c=N(()=>({type:o.value?"Region":"Private Key",description:o.value?"Regional organizational key":"Individual assigned key"}));Q(o,C=>{C?d.value="This will create a new region for organizing keys":d.value="This will create a new private key entry"},{immediate:!0});const y=N(()=>g.value.trim().length>0),h=()=>{y.value&&(m("add",{name:g.value.trim(),floodPolicy:i.value,parentId:l.selectedNodeId}),g.value="",d.value="",i.value="allow")},A=()=>{g.value="",d.value="",i.value="allow",m("close")},$=C=>{C.target===C.currentTarget&&A()};return(C,a)=>C.show?(r(),s("div",{key:0,onClick:$,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:a[3]||(a[3]=G(()=>{},["stop"]))},[e("div",ho,[e("div",null,[a[5]||(a[5]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"Add New Entry",-1)),e("p",wo,[l.selectedNodeName?(r(),s("span",_o,[a[4]||(a[4]=I(" Add to: ",-1)),e("span",$o,b(l.selectedNodeName),1)])):(r(),s("span",Co," Add to root level (#uk) "))])]),e("button",{onClick:A,class:"text-white/60 hover:text-white transition-colors"},a[6]||(a[6]=[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),e("form",{onSubmit:G(h,["prevent"]),class:"space-y-4"},[e("div",null,[e("label",Mo,[e("div",Ao,[o.value?(r(),s("svg",jo,a[7]||(a[7]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(r(),s("svg",No,a[8]||(a[8]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"},null,-1)]))),a[9]||(a[9]=I(" Region/Key Name ",-1))])]),E(e("input",{id:"keyName","onUpdate:modelValue":a[0]||(a[0]=t=>g.value=t),type:"text",placeholder:"Enter name (prefix with # for regions)",class:"w-full px-4 py-3 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/20 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors",autocomplete:"off"},null,512),[[V,g.value]])]),e("div",So,[e("div",To,[e("div",Bo,[o.value?(r(),s("svg",Eo,a[10]||(a[10]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(r(),s("svg",Fo,a[11]||(a[11]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1221 9z"},null,-1)]))),e("span",{class:P([o.value?"text-secondary":"text-accent-green","font-medium"])},b(c.value.type),3)]),e("div",{class:P(["flex-1 h-px",o.value?"bg-secondary/20":"bg-accent-green/20"])},null,2)]),e("p",Lo,b(c.value.description),1)]),e("div",null,[a[14]||(a[14]=e("label",{class:"block text-sm font-medium text-content-primary dark:text-content-primary mb-3"},[e("div",{class:"flex items-center gap-2"},[e("svg",{class:"w-4 h-4 text-primary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"})]),I(" Flood Policy ")])],-1)),e("div",Po,[e("label",zo,[E(e("input",{type:"radio","onUpdate:modelValue":a[1]||(a[1]=t=>i.value=t),value:"allow",class:"sr-only"},null,512),[[Z,i.value]]),a[12]||(a[12]=O('
Allow

Permit flooding

',1))]),e("label",Io,[E(e("input",{type:"radio","onUpdate:modelValue":a[2]||(a[2]=t=>i.value=t),value:"deny",class:"sr-only"},null,512),[[Z,i.value]]),a[13]||(a[13]=O('
Deny

Block flooding

',1))])])]),e("div",Do,[e("button",{type:"button",onClick:A,class:"flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),e("button",{type:"submit",disabled:!y.value,class:P(["flex-1 px-4 py-3 rounded-lg transition-colors font-medium",y.value?"bg-accent-green/20 hover:bg-accent-green/30 border border-accent-green/50 text-accent-green":"bg-background-mute dark:bg-stroke/5 border border-stroke-subtle dark:border-stroke/20 text-content-muted dark:text-content-muted cursor-not-allowed"])}," Add "+b(c.value.type),11,Ho)])],32)])])):S("",!0)}}),Vo={class:"flex items-center justify-between mb-6"},Ro={class:"text-content-secondary dark:text-content-muted text-sm mt-1"},Ko={class:"text-primary font-mono"},qo={for:"keyName",class:"block text-sm font-medium text-content-secondary dark:text-content-primary mb-2"},Wo={class:"flex items-center gap-2"},Oo={key:0,class:"w-4 h-4 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Go={key:1,class:"w-4 h-4 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Yo={class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4"},Xo={class:"flex items-center gap-3 mb-2"},Jo={class:"flex items-center gap-2"},Qo={key:0,class:"w-5 h-5 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Zo={key:1,class:"w-5 h-5 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},er={class:"text-content-secondary dark:text-content-muted text-sm"},tr={key:0,class:"space-y-4"},or={key:0,class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4"},rr={class:"bg-background-mute dark:bg-black/20 border border-stroke-subtle dark:border-stroke/10 rounded-md p-3"},sr={class:"text-xs font-mono text-content-primary dark:text-content-primary/80 break-all"},nr={key:1,class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4"},ar={class:"flex items-center justify-between"},lr={class:"text-sm text-content-secondary dark:text-content-muted"},dr={class:"text-xs text-content-muted dark:text-content-muted"},ir={class:"grid grid-cols-2 gap-3"},cr={class:"relative cursor-pointer group"},ur={class:"relative cursor-pointer group"},mr={class:"flex gap-3 pt-4"},pr=["disabled"],br=q({__name:"EditKeyModal",props:{show:{type:Boolean},node:{}},emits:["close","save","request-delete"],setup(z,{emit:f}){const l=z,m=f,g=v(""),d=v("allow"),i=N(()=>g.value.startsWith("#")),o=N(()=>({type:i.value?"Region":"Private Key",description:i.value?"Regional organizational key":"Individual assigned key"}));Q(()=>l.node,t=>{t?(g.value=t.name,d.value=t.floodPolicy):(g.value="",d.value="allow")},{immediate:!0});const c=N(()=>g.value.trim().length>0&&l.node),y=t=>{const M=new Date().getTime()-t.getTime(),p=Math.floor(M/(1e3*60)),u=Math.floor(M/(1e3*60*60)),x=Math.floor(M/(1e3*60*60*24)),L=Math.floor(x/365);return p<60?`${p}m ago`:u<24?`${u}h ago`:x<365?`${x}d ago`:`${L}y ago`},h=t=>{window.navigator?.clipboard&&window.navigator.clipboard.writeText(t)},A=()=>{!c.value||!l.node||(m("save",{id:l.node.id,name:g.value.trim(),floodPolicy:d.value}),C())},$=()=>{l.node&&(m("request-delete",l.node),C())},C=()=>{m("close")},a=t=>{t.target===t.currentTarget&&C()};return(t,n)=>t.show?(r(),s("div",{key:0,onClick:a,class:"fixed inset-0 bg-black/50 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-lg border border-stroke-subtle dark:border-white/10",onClick:n[4]||(n[4]=G(()=>{},["stop"]))},[e("div",Vo,[e("div",null,[n[6]||(n[6]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"Edit Entry",-1)),e("p",Ro,[n[5]||(n[5]=I(" Modify ",-1)),e("span",Ko,b(t.node?.name),1)])]),e("button",{onClick:C,class:"text-white/60 hover:text-white transition-colors"},n[7]||(n[7]=[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),e("form",{onSubmit:G(A,["prevent"]),class:"space-y-4"},[e("div",null,[e("label",qo,[e("div",Wo,[i.value?(r(),s("svg",Oo,n[8]||(n[8]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(r(),s("svg",Go,n[9]||(n[9]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z"},null,-1)]))),n[10]||(n[10]=I(" Region/Key Name ",-1))])]),E(e("input",{id:"keyName","onUpdate:modelValue":n[0]||(n[0]=M=>g.value=M),type:"text",placeholder:"Enter name (prefix with # for regions)",class:"w-full px-4 py-3 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/20 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors",autocomplete:"off"},null,512),[[V,g.value]])]),e("div",Yo,[e("div",Xo,[e("div",Jo,[i.value?(r(),s("svg",Qo,n[11]||(n[11]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(r(),s("svg",Zo,n[12]||(n[12]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z"},null,-1)]))),e("span",{class:P([i.value?"text-secondary":"text-accent-green","font-medium"])},b(o.value.type),3)]),e("div",{class:P(["flex-1 h-px",i.value?"bg-secondary/20":"bg-accent-green/20"])},null,2)]),e("p",er,b(o.value.description),1)]),t.node?(r(),s("div",tr,[t.node.transport_key?(r(),s("div",or,[n[14]||(n[14]=O('
Transport Key
',1)),e("div",rr,[e("div",sr,b(t.node.transport_key),1),e("button",{onClick:n[1]||(n[1]=M=>h(t.node.transport_key||"")),class:"mt-2 text-xs text-accent-green hover:text-accent-green/80 flex items-center gap-1",title:"Copy to clipboard"},n[13]||(n[13]=[e("svg",{class:"w-3 h-3",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"})],-1),I(" Copy Key ",-1)]))])])):S("",!0),t.node.last_used?(r(),s("div",nr,[n[15]||(n[15]=e("div",{class:"flex items-center gap-2 mb-3"},[e("svg",{class:"w-4 h-4 text-primary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"})]),e("span",{class:"text-sm font-medium text-content-primary dark:text-content-primary"},"Last Used")],-1)),e("div",ar,[e("div",lr,b(t.node.last_used.toLocaleDateString())+" at "+b(t.node.last_used.toLocaleTimeString()),1),e("div",dr,b(y(t.node.last_used)),1)])])):S("",!0)])):S("",!0),e("div",null,[n[18]||(n[18]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-primary mb-3"},[e("div",{class:"flex items-center gap-2"},[e("svg",{class:"w-4 h-4 text-primary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"})]),I(" Flood Policy ")])],-1)),e("div",ir,[e("label",cr,[E(e("input",{type:"radio","onUpdate:modelValue":n[2]||(n[2]=M=>d.value=M),value:"allow",class:"sr-only"},null,512),[[Z,d.value]]),n[16]||(n[16]=O('
Allow

Permit flooding

',1))]),e("label",ur,[E(e("input",{type:"radio","onUpdate:modelValue":n[3]||(n[3]=M=>d.value=M),value:"deny",class:"sr-only"},null,512),[[Z,d.value]]),n[17]||(n[17]=O('
Deny

Block flooding

',1))])])]),e("div",mr,[e("button",{type:"button",onClick:$,class:"px-4 py-3 bg-accent-red/20 hover:bg-accent-red/30 border border-accent-red/50 text-accent-red rounded-lg transition-colors"}," Delete "),e("button",{type:"button",onClick:C,class:"flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),e("button",{type:"submit",disabled:!c.value,class:P(["flex-1 px-4 py-3 rounded-lg transition-colors font-medium",c.value?"bg-accent-green/20 hover:bg-accent-green/30 border border-accent-green/50 text-accent-green":"bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/20 text-content-muted dark:text-content-muted/70 cursor-not-allowed"])}," Save Changes ",10,pr)])],32)])])):S("",!0)}}),vr={class:"flex items-center gap-3 mb-6"},xr={class:"text-content-secondary dark:text-content-muted text-sm mt-1"},kr={class:"text-accent-red font-mono"},gr={key:0,class:"bg-accent-red/10 border border-accent-red/30 rounded-lg p-4 mb-6"},yr={class:"flex items-start gap-3"},fr={class:"flex-1"},hr={class:"text-accent-red font-medium text-sm mb-2"},wr={class:"space-y-1 max-h-32 overflow-y-auto"},_r={key:0,class:"w-3 h-3 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},$r={key:1,class:"w-3 h-3 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Cr={class:"font-mono"},Mr={key:0,class:"text-content-secondary dark:text-content-muted text-xs"},Ar={key:1,class:"mb-6"},jr={class:"mb-3"},Nr={class:"relative"},Sr={class:"space-y-2 max-h-40 overflow-y-auto border border-stroke-subtle dark:border-stroke/20 rounded-lg p-3 bg-gray-50 dark:bg-white/5"},Tr={key:0,class:"text-center py-4 text-content-secondary dark:text-content-muted text-sm"},Br={class:"relative"},Er=["value"],Fr={class:"flex items-center gap-2 flex-1"},Lr={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Pr={key:0,class:"ml-auto px-2 py-0.5 bg-background-mute dark:bg-stroke/10 text-content-secondary dark:text-content-muted text-xs rounded-full"},zr={class:"flex gap-3"},Ir=q({__name:"DeleteConfirmModal",props:{show:{type:Boolean},node:{},allNodes:{}},emits:["close","delete-all","move-children"],setup(z,{emit:f}){const l=z,m=f,g=v(null),d=v(""),i=a=>{const t=[],n=M=>{for(const p of M.children)t.push(p),n(p)};return n(a),t},o=N(()=>l.node?i(l.node):[]),c=N(()=>{if(!l.node)return[];const a=new Set([l.node.id,...o.value.map(n=>n.id)]),t=n=>{const M=[];for(const p of n)p.name.startsWith("#")&&!a.has(p.id)&&M.push(p),p.children.length>0&&M.push(...t(p.children));return M};return t(l.allNodes)}),y=N(()=>{if(!d.value.trim())return c.value;const a=d.value.toLowerCase();return c.value.filter(t=>t.name.toLowerCase().includes(a))}),h=()=>{l.node&&(m("delete-all",l.node.id),$())},A=()=>{!l.node||!g.value||(m("move-children",{nodeId:l.node.id,targetParentId:g.value}),$())},$=()=>{g.value=null,d.value="",m("close")},C=a=>{a.target===a.currentTarget&&$()};return(a,t)=>a.show&&a.node?(r(),s("div",{key:0,onClick:C,class:"fixed inset-0 bg-black/80 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-lg border border-stroke-subtle dark:border-white/10",onClick:t[2]||(t[2]=G(()=>{},["stop"]))},[e("div",vr,[t[6]||(t[6]=e("svg",{class:"w-6 h-6 text-accent-red",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"})],-1)),e("div",null,[t[4]||(t[4]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"Confirm Deletion",-1)),e("p",xr,[t[3]||(t[3]=I(" Deleting ",-1)),e("span",kr,b(a.node?.name),1)])]),e("button",{onClick:$,class:"ml-auto text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},t[5]||(t[5]=[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),o.value.length>0?(r(),s("div",gr,[e("div",yr,[t[9]||(t[9]=e("svg",{class:"w-5 h-5 text-accent-red flex-shrink-0 mt-0.5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})],-1)),e("div",fr,[e("h4",hr," This will affect "+b(o.value.length)+" child "+b(o.value.length===1?"entry":"entries")+": ",1),e("div",wr,[(r(!0),s(K,null,Y(o.value.slice(0,10),n=>(r(),s("div",{key:n.id,class:"flex items-center gap-2 text-xs text-content-secondary dark:text-content-primary/80"},[n.name.startsWith("#")?(r(),s("svg",_r,t[7]||(t[7]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"},null,-1)]))):(r(),s("svg",$r,t[8]||(t[8]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1721 9z"},null,-1)]))),e("span",Cr,b(n.name),1),e("span",{class:P(["px-1 py-0.5 text-xs rounded",n.floodPolicy==="allow"?"bg-accent-green/20 text-accent-green":"bg-accent-red/20 text-accent-red"])},b(n.floodPolicy),3)]))),128)),o.value.length>10?(r(),s("div",Mr," ...and "+b(o.value.length-10)+" more ",1)):S("",!0)])])])])):S("",!0),o.value.length>0&&c.value.length>0?(r(),s("div",Ar,[t[13]||(t[13]=e("h4",{class:"text-content-primary dark:text-content-primary font-medium text-sm mb-3"},"Move children to another region:",-1)),e("div",jr,[e("div",Nr,[t[10]||(t[10]=e("svg",{class:"absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-content-muted dark:text-content-muted",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"})],-1)),E(e("input",{"onUpdate:modelValue":t[0]||(t[0]=n=>d.value=n),type:"text",placeholder:"Search regions...",class:"w-full pl-9 pr-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/20 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/50 focus:outline-none focus:border-primary focus:ring-2 focus:ring-primary/20 transition-colors text-sm"},null,512),[[V,d.value]])])]),e("div",Sr,[y.value.length===0?(r(),s("div",Tr,b(d.value?"No regions match your search":"No available regions"),1)):S("",!0),(r(!0),s(K,null,Y(y.value,n=>(r(),s("label",{key:n.id,class:"flex items-center gap-3 p-2 rounded cursor-pointer hover:bg-stroke-subtle dark:hover:bg-white/10 transition-colors group"},[e("div",Br,[E(e("input",{type:"radio",value:n.id,"onUpdate:modelValue":t[1]||(t[1]=M=>g.value=M),class:"sr-only peer"},null,8,Er),[[Z,g.value]]),t[11]||(t[11]=e("div",{class:"w-4 h-4 border-2 border-stroke dark:border-stroke/30 rounded-full group-hover:border-stroke dark:group-hover:border-stroke/50 peer-checked:border-primary peer-checked:bg-primary/20 transition-all"},[e("div",{class:"w-2 h-2 rounded-full bg-primary scale-0 peer-checked:scale-100 transition-transform absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"})],-1))]),e("div",Fr,[t[12]||(t[12]=e("svg",{class:"w-4 h-4 text-secondary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 20l4-16m2 16l4-16M6 9h14M4 15h14"})],-1)),e("span",Lr,b(n.name),1),n.children.length>0?(r(),s("span",Pr,b(n.children.length),1)):S("",!0)])]))),128))])])):S("",!0),e("div",zr,[e("button",{onClick:$,class:"flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),o.value.length>0&&g.value?(r(),s("button",{key:0,onClick:A,class:"flex-1 px-4 py-3 bg-primary/20 hover:bg-primary/30 border border-primary/50 text-primary rounded-lg transition-colors"}," Move & Delete ")):S("",!0),e("button",{onClick:h,class:"flex-1 px-4 py-3 bg-accent-red/20 hover:bg-accent-red/30 border border-accent-red/50 text-accent-red rounded-lg transition-colors font-medium"},b(o.value.length>0?"Delete All":"Delete"),1)])])])):S("",!0)}}),Dr={class:"space-y-4 sm:space-y-6"},Hr={class:"flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3"},Ur={class:"flex gap-2 flex-wrap"},Vr=["disabled"],Rr=["disabled"],Kr=["disabled"],qr={class:"glass-card rounded-[15px] p-3 sm:p-4 border border-stroke-subtle dark:border-stroke/10 bg-background-mute dark:bg-white/5"},Wr={class:"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"},Or={class:"flex items-center gap-2 sm:gap-3"},Gr={class:"flex bg-background-mute dark:bg-stroke/5 rounded-lg border border-stroke-subtle dark:border-stroke/20 p-0.5 sm:p-1"},Yr={class:"glass-card rounded-[15px] p-3 sm:p-6 border border-stroke-subtle dark:border-stroke/10"},Xr={key:0,class:"flex items-center justify-center py-8"},Jr={key:1,class:"text-center py-8"},Qr={class:"text-content-secondary dark:text-content-muted text-sm"},Zr={key:2,class:"text-center py-8"},es={key:3,class:"space-y-2"},ts=q({name:"TransportKeys",__name:"TransportKeys",setup(z){const f=me(),l=v(!1),m=v(!1),g=v(!1),d=v(null),i=v(null),o=v("deny"),c=v([]),y=v(!1),h=v(null),A=w=>{const k=new Map,H=[];return w.forEach(D=>{const se={id:D.id,name:D.name,floodPolicy:D.flood_policy,transport_key:D.transport_key,last_used:D.last_used?new Date(D.last_used*1e3):void 0,parent_id:D.parent_id,children:[]};k.set(D.id,se)}),k.forEach(D=>{D.parent_id&&k.has(D.parent_id)?k.get(D.parent_id).children.push(D):H.push(D)}),H},$=async()=>{try{y.value=!0,h.value=null;const w=await U.getTransportKeys();w.success&&w.data?c.value=A(w.data):h.value=w.error||"Failed to load transport keys"}catch(w){h.value=w instanceof Error?w.message:"Unknown error occurred",console.error("Error loading transport keys:",w)}finally{y.value=!1}};oe(()=>{$()});function C(w,k){for(const H of w){if(H.id===k)return H;if(H.children){const D=C(H.children,k);if(D)return D}}return null}function a(){const w=f.selectedNodeId.value;return w?C(c.value,w)?.name:void 0}function t(w){o.value==="deny"&&f.setSelectedNode(w)}function n(){o.value==="deny"&&(l.value=!0)}function M(){if(o.value==="deny"&&f.selectedNodeId.value){const w=C(c.value,f.selectedNodeId.value);w&&(i.value=w,g.value=!0)}}function p(){if(o.value==="deny"&&f.selectedNodeId.value){const w=C(c.value,f.selectedNodeId.value);w&&(d.value=w,m.value=!0)}}const u=async w=>{try{const k=await U.createTransportKey(w.name,w.floodPolicy,void 0,w.parentId,void 0);k.success?await $():(console.error("Failed to add transport key:",k.error),h.value=k.error||"Failed to add transport key")}catch(k){console.error("Error adding transport key:",k),h.value=k instanceof Error?k.message:"Unknown error occurred"}finally{l.value=!1}};function x(){l.value=!1}async function L(w){try{const k=w==="allow",H=await U.updateGlobalFloodPolicy(k);H.success?o.value=w:(console.error("Failed to update global flood policy:",H.error),h.value=H.error||"Failed to update global flood policy")}catch(k){console.error("Error updating global flood policy:",k),h.value=k instanceof Error?k.message:"Failed to update global flood policy"}}function X(){m.value=!1,d.value=null}async function _(w){try{const k=await U.updateTransportKey(w.id,w.name,w.floodPolicy);k.success?await $():(console.error("Failed to update transport key:",k.error),h.value=k.error||"Failed to update transport key")}catch(k){console.error("Error updating transport key:",k),h.value=k instanceof Error?k.message:"Unknown error occurred"}finally{X()}}function j(w){m.value=!1,d.value=null,i.value=w,g.value=!0}function F(){g.value=!1,i.value=null}async function T(w){try{const k=await U.deleteTransportKey(w);k.success?(await $(),f.setSelectedNode(null)):(console.error("Failed to delete transport key:",k.error),h.value=k.error||"Failed to delete transport key")}catch(k){console.error("Error deleting transport key:",k),h.value=k instanceof Error?k.message:"Unknown error occurred"}finally{F()}}async function B(w){try{const k=await U.deleteTransportKey(w.nodeId);k.success?(await $(),f.setSelectedNode(null)):(console.error("Failed to delete transport key:",k.error),h.value=k.error||"Failed to delete transport key")}catch(k){console.error("Error deleting transport key:",k),h.value=k instanceof Error?k.message:"Unknown error occurred"}finally{F()}}return(w,k)=>(r(),s("div",Dr,[e("div",Hr,[k[3]||(k[3]=e("div",null,[e("h3",{class:"text-base sm:text-lg font-semibold text-content-primary dark:text-content-primary mb-1 sm:mb-2"},"Regions/Keys"),e("p",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Manage regional key hierarchy")],-1)),e("div",Ur,[e("button",{onClick:n,disabled:o.value==="allow",class:P(["flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-3 py-1.5 sm:py-2 rounded-lg border transition-colors text-xs sm:text-sm",o.value==="allow"?"bg-background-mute dark:bg-white/5 text-content-muted dark:text-content-muted/70 border-stroke-subtle dark:border-stroke/20 cursor-not-allowed":"bg-accent-green/10 hover:bg-accent-green/20 text-accent-green border-accent-green/30"])},k[2]||(k[2]=[e("svg",{class:"w-3.5 h-3.5 sm:w-4 sm:h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})],-1),I(" Add ",-1)]),10,Vr),e("button",{onClick:p,disabled:!W(f).selectedNodeId.value||o.value==="allow",class:P(["px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg border transition-colors text-xs sm:text-sm",!W(f).selectedNodeId.value||o.value==="allow"?"bg-background-mute dark:bg-stroke/10 text-content-muted dark:text-content-muted/70 border-stroke-subtle dark:border-stroke/20 cursor-not-allowed":"bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border-accent-green/50"])}," Edit ",10,Rr),e("button",{onClick:M,disabled:!W(f).selectedNodeId.value||o.value==="allow",class:P(["px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg border transition-colors text-xs sm:text-sm",!W(f).selectedNodeId.value||o.value==="allow"?"bg-background-mute dark:bg-stroke/10 text-content-muted dark:text-content-muted/70 border-stroke-subtle dark:border-stroke/20 cursor-not-allowed":"bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border-accent-red/50"])}," Delete ",10,Kr)])]),e("div",qr,[e("div",Wr,[k[4]||(k[4]=e("div",null,[e("h4",{class:"text-xs sm:text-sm font-medium text-content-primary dark:text-content-primary mb-1"},"Global Flood Policy (*)"),e("p",{class:"text-content-secondary dark:text-content-muted text-[10px] sm:text-xs"},"Master control for repeater flooding")],-1)),e("div",Or,[e("div",Gr,[e("button",{onClick:k[0]||(k[0]=H=>L("deny")),class:P(["px-2 sm:px-3 py-1 text-[10px] sm:text-xs font-medium rounded transition-colors",o.value==="deny"?"bg-accent-red/20 text-accent-red border border-accent-red/50":"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-secondary"])}," DENY ",2),e("button",{onClick:k[1]||(k[1]=H=>L("allow")),class:P(["px-2 sm:px-3 py-1 text-[10px] sm:text-xs font-medium rounded transition-colors",o.value==="allow"?"bg-accent-green/20 text-accent-green border border-accent-green/50":"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-secondary"])}," ALLOW ",2)])])])]),e("div",Yr,[y.value?(r(),s("div",Xr,k[5]||(k[5]=[e("div",{class:"animate-spin rounded-full h-8 w-8 border-b-2 border-accent-green"},null,-1),e("span",{class:"ml-2 text-content-secondary dark:text-content-muted"},"Loading transport keys...",-1)]))):h.value?(r(),s("div",Jr,[k[6]||(k[6]=e("div",{class:"text-accent-red mb-2"},"⚠️ Error loading transport keys",-1)),e("div",Qr,b(h.value),1),e("button",{onClick:$,class:"mt-4 px-4 py-2 bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded-lg transition-colors"}," Retry ")])):c.value.length===0?(r(),s("div",Zr,k[7]||(k[7]=[e("div",{class:"text-content-muted dark:text-content-muted mb-2"},"📝 No transport keys found",-1),e("div",{class:"text-content-muted dark:text-content-muted/60 text-sm"},"Add your first transport key to get started",-1)]))):(r(),s("div",es,[(r(!0),s(K,null,Y(c.value,H=>(r(),ce(fo,{key:H.id,node:H,"selected-node-id":W(f).selectedNodeId.value,level:0,disabled:o.value==="allow",onSelect:t},null,8,["node","selected-node-id","disabled"]))),128))]))]),R(Uo,{show:l.value,"selected-node-name":a(),"selected-node-id":W(f).selectedNodeId.value||void 0,onClose:x,onAdd:u},null,8,["show","selected-node-name","selected-node-id"]),R(br,{show:m.value,node:d.value,onClose:X,onSave:_,onRequestDelete:j},null,8,["show","node"]),R(Ir,{show:g.value,node:i.value,"all-nodes":c.value,onClose:F,onDeleteAll:T,onMoveChildren:B},null,8,["show","node","all-nodes"])]))}}),os={class:"space-y-4 sm:space-y-6"},rs={class:"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"},ss={key:0,class:"bg-red-500/10 border border-red-500/30 rounded-lg p-4"},ns={class:"flex items-center gap-2 text-red-600 dark:text-red-400"},as={key:1,class:"flex items-center justify-center py-12"},ls={key:2,class:"space-y-3"},ds={class:"flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"},is={class:"flex-1"},cs={class:"flex items-center gap-2 sm:gap-3"},us={class:"min-w-0 flex-1"},ms={class:"text-content-primary dark:text-content-primary font-medium text-sm sm:text-base break-all"},ps={class:"flex flex-col sm:flex-row sm:items-center sm:gap-4 mt-1 text-xs text-content-secondary dark:text-content-muted"},bs={class:"truncate"},vs={class:"truncate"},xs=["onClick","disabled"],ks={key:3,class:"text-center py-12"},gs={class:"bg-surface dark:bg-surface-elevated border border-stroke-subtle dark:border-stroke/20 rounded-[15px] p-6 max-w-md w-full shadow-2xl"},ys={class:"space-y-4"},fs={class:"flex justify-end gap-3 mt-6"},hs=["disabled"],ws=["disabled"],_s={class:"bg-surface dark:bg-surface-elevated border border-stroke-subtle dark:border-stroke/20 rounded-[15px] p-6 max-w-lg w-full shadow-2xl"},$s={class:"space-y-4"},Cs={class:"flex gap-2"},Ms=["value"],As={class:"bg-blue-500/10 border border-blue-500/30 rounded-lg p-4"},js={class:"block bg-blue-500/20 px-3 py-2 rounded text-xs text-blue-100 font-mono overflow-x-auto"},Ns=q({name:"APITokens",__name:"APITokens",setup(z){const f=v([]),l=v(!1),m=v(null),g=v(!1),d=v(""),i=v(null),o=v(!1),c=v(!1),y=v(null),h=async()=>{l.value=!0,m.value=null;try{const u=await U.get("/auth/tokens"),x=u.data||u;f.value=x.tokens||[]}catch(u){console.error("Failed to fetch API tokens:",u),m.value=u instanceof Error?u.message:"Failed to fetch tokens"}finally{l.value=!1}},A=async()=>{if(!d.value.trim()){m.value="Token name is required";return}l.value=!0,m.value=null;try{const u=await U.post("/auth/tokens",{name:d.value.trim()}),x=u.data||u;i.value=x.token||null,g.value=!1,o.value=!0,d.value="",await h()}catch(u){console.error("Failed to create API token:",u),m.value=u instanceof Error?u.message:"Failed to create token"}finally{l.value=!1}},$=(u,x)=>{y.value={id:u,name:x},c.value=!0},C=async()=>{if(y.value){l.value=!0,m.value=null;try{await U.delete(`/auth/tokens/${y.value.id}`),await h(),c.value=!1,y.value=null}catch(u){console.error("Failed to revoke API token:",u),m.value=u instanceof Error?u.message:"Failed to revoke token"}finally{l.value=!1}}},a=()=>{g.value=!1,d.value="",m.value=null},t=()=>{o.value=!1,i.value=null},n=()=>{i.value&&navigator.clipboard.writeText(i.value)},M=u=>u?new Date(u*1e3).toLocaleString():"Never",p=N(()=>`${window.location.origin}/api/stats`);return oe(()=>{h()}),(u,x)=>(r(),s(K,null,[e("div",os,[e("div",rs,[x[5]||(x[5]=e("div",null,[e("h2",{class:"text-lg sm:text-xl font-semibold text-content-primary dark:text-content-primary"},"API Tokens"),e("p",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm mt-1"},"Manage API tokens for machine-to-machine authentication")],-1)),e("button",{onClick:x[0]||(x[0]=L=>g.value=!0),class:"px-3 sm:px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors flex items-center justify-center gap-2 text-sm sm:text-base"},x[4]||(x[4]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})],-1),I(" Create Token ",-1)]))]),x[20]||(x[20]=O('

API tokens are used for machine-to-machine authentication. Include the token in the X-API-Key header when making API requests.

Tokens are only shown once at creation. Store them securely.

',1)),m.value?(r(),s("div",ss,[e("div",ns,[x[6]||(x[6]=e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})],-1)),I(" "+b(m.value),1)])])):S("",!0),l.value&&f.value.length===0?(r(),s("div",as,x[7]||(x[7]=[e("div",{class:"text-center"},[e("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-primary rounded-full mx-auto mb-4"}),e("div",{class:"text-content-secondary dark:text-content-muted"},"Loading tokens...")],-1)]))):f.value.length>0?(r(),s("div",ls,[(r(!0),s(K,null,Y(f.value,L=>(r(),s("div",{key:L.id,class:"bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-3 sm:p-4 hover:bg-stroke-subtle dark:hover:bg-white/10 transition-colors"},[e("div",ds,[e("div",is,[e("div",cs,[x[8]||(x[8]=e("svg",{class:"w-4 h-4 sm:w-5 sm:h-5 text-primary flex-shrink-0",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"})],-1)),e("div",us,[e("h3",ms,b(L.name),1),e("div",ps,[e("span",bs,"Created: "+b(M(L.created_at)),1),e("span",vs,"Last used: "+b(M(L.last_used)),1)])])])]),e("button",{onClick:X=>$(L.id,L.name),disabled:l.value,class:"w-full sm:w-auto px-3 py-1.5 bg-red-100 dark:bg-red-500/20 hover:bg-red-500/30 text-red-600 dark:text-red-400 rounded-lg border border-red-500/50 transition-colors disabled:opacity-50 text-sm"}," Revoke ",8,xs)])]))),128))])):(r(),s("div",ks,[x[9]||(x[9]=e("svg",{class:"w-16 h-16 text-content-muted dark:text-content-muted/40 mx-auto mb-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"})],-1)),x[10]||(x[10]=e("h3",{class:"text-content-primary dark:text-content-primary font-medium mb-2"},"No API Tokens",-1)),x[11]||(x[11]=e("p",{class:"text-content-secondary dark:text-content-muted text-sm mb-4"},"Create a token to enable API access",-1)),e("button",{onClick:x[1]||(x[1]=L=>g.value=!0),class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors"}," Create Your First Token ")])),g.value?(r(),s("div",{key:4,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm",onClick:G(a,["self"])},[e("div",gs,[x[14]||(x[14]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary mb-4"},"Create API Token",-1)),e("div",ys,[e("div",null,[x[12]||(x[12]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-muted mb-2"},"Token Name",-1)),E(e("input",{"onUpdate:modelValue":x[2]||(x[2]=L=>d.value=L),type:"text",placeholder:"e.g., Production Server, CI/CD Pipeline",class:"w-full px-4 py-2 bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-400 dark:placeholder-white/40 focus:outline-none focus:border-primary transition-colors",onKeydown:xe(A,["enter"])},null,544),[[V,d.value]]),x[13]||(x[13]=e("p",{class:"text-xs text-content-muted dark:text-content-muted mt-1"},"Give your token a descriptive name to identify its purpose",-1))]),e("div",fs,[e("button",{onClick:a,disabled:l.value,class:"px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/10 transition-colors disabled:opacity-50"}," Cancel ",8,hs),e("button",{onClick:A,disabled:l.value||!d.value.trim(),class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors disabled:opacity-50"},b(l.value?"Creating...":"Create Token"),9,ws)])])])])):S("",!0),o.value&&i.value?(r(),s("div",{key:5,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm",onClick:G(t,["self"])},[e("div",_s,[x[19]||(x[19]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary mb-4"},"Token Created Successfully",-1)),e("div",$s,[x[18]||(x[18]=O('
Save this token now! For security reasons, it will not be shown again.
',1)),e("div",null,[x[16]||(x[16]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-muted mb-2"},"Your API Token",-1)),e("div",Cs,[e("input",{value:i.value,readonly:"",class:"flex-1 px-4 py-2 bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary font-mono text-sm"},null,8,Ms),e("button",{onClick:n,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors flex items-center gap-2",title:"Copy to clipboard"},x[15]||(x[15]=[e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"})],-1),I(" Copy ",-1)]))])]),e("div",As,[x[17]||(x[17]=e("p",{class:"text-sm text-blue-200 mb-2"},[e("strong",null,"Usage Example:")],-1)),e("code",js,' curl -H "X-API-Key: '+b(i.value)+'" '+b(p.value),1)]),e("div",{class:"flex justify-end mt-6"},[e("button",{onClick:t,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors"}," Done ")])])])])):S("",!0)]),R(ke,{show:c.value,title:"Revoke API Token",message:`Are you sure you want to revoke the token '${y.value?.name}'? This action cannot be undone.`,"confirm-text":"Revoke","cancel-text":"Cancel",variant:"danger",onConfirm:C,onClose:x[3]||(x[3]=L=>c.value=!1)},null,8,["show","message"])],64))}}),Ss={class:"space-y-6"},Ts={class:"glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6"},Bs={class:"space-y-4"},Es={class:"flex items-center justify-between"},Fs=["disabled"],Ls={class:"glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6"},Ps={class:"space-y-4"},zs={class:"space-y-3"},Is=["checked","disabled"],Ds=["checked","disabled"],Hs={class:"flex items-start gap-3"},Us={key:0,class:"w-5 h-5 text-green-600 dark:text-green-400 flex-shrink-0 mt-0.5",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},Vs={key:1,class:"w-5 h-5 text-accent-blue flex-shrink-0 mt-0.5",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},Rs={class:"flex-1"},Ks={class:"text-sm font-medium text-content-primary dark:text-content-primary"},qs={key:0,class:"text-xs text-green-600 dark:text-green-400 mt-1"},Ws={key:1,class:"p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg"},Os={class:"flex items-start justify-between gap-3"},Gs=["disabled"],Ys={key:0,class:"animate-spin h-4 w-4",fill:"none",viewBox:"0 0 24 24"},Xs={key:1,class:"w-4 h-4",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},Js={class:"flex items-center space-x-2"},Qs={key:0,class:"w-5 h-5 text-green-600 dark:text-green-400",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},Zs={key:1,class:"w-5 h-5 text-red-600 dark:text-red-400",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor"},en=q({name:"WebSettings",__name:"WebSettings",setup(z){const f=v(!1),l=v(""),m=v(!1),g=v(!1),d=v(!1),i=v(!1),o=v(!0),c=re({cors_enabled:!1,use_default_frontend:!0}),y=N(()=>m.value?"bg-green-500/10 border-green-600/40 dark:border-green-500/30":"bg-red-500/10 border-red-500/30");async function h(){try{o.value=!0;const p=await U.get("/check_pymc_console");p.success&&p.data&&(i.value=p.data.exists,console.log("PyMC Console exists:",i.value))}catch(p){console.error("Failed to check PyMC Console:",p),i.value=!1}finally{o.value=!1}}async function A(){try{const p=await U.get("/stats");console.log("WebSettings: Full response:",p);let u=null;if(p.success&&p.data?u=p.data:p&&"version"in p&&(u=p),u){const x=u.config?.web||{};console.log("WebSettings: webConfig:",x),c.cors_enabled=x.cors_enabled===!0,console.log("WebSettings: Set cors_enabled to:",c.cors_enabled);const L=x.web_path;c.use_default_frontend=!L||L==="",console.log("WebSettings: Set use_default_frontend to:",c.use_default_frontend,"from web_path:",L)}}catch(p){console.error("Failed to load web settings:",p),n("Failed to load settings",!1)}}async function $(){f.value=!0,l.value="";try{const p={web:{cors_enabled:c.cors_enabled}};c.use_default_frontend?p.web.web_path=null:p.web.web_path="/opt/pymc_console/web/html";const u=await U.post("/update_web_config",p);u.success?(n("Settings saved successfully",!0),g.value=!0):n(u.error||"Failed to save settings",!1)}catch(p){console.error("Failed to save web settings:",p),n(p.message||"Failed to save settings",!1)}finally{f.value=!1}}async function C(){c.cors_enabled=!c.cors_enabled,await $()}async function a(){c.use_default_frontend=!0,await $()}async function t(){c.use_default_frontend=!1,await $()}function n(p,u){l.value=p,m.value=u,setTimeout(()=>{l.value=""},5e3)}async function M(){d.value=!0,l.value="";try{const p=await U.post("/restart_service",{});p.success?(n("Service restart initiated. Page will reload...",!0),g.value=!1,setTimeout(()=>{window.location.reload()},2e3)):n(p.error||"Failed to restart service",!1)}catch(p){p.code==="ERR_NETWORK"||p.message?.includes("Network error")?(n("Service restarting... Page will reload",!0),g.value=!1,setTimeout(()=>{window.location.reload()},3e3)):(console.error("Failed to restart service:",p),n(p.message||"Failed to restart service",!1))}finally{d.value=!1}}return oe(()=>{A(),h()}),(p,u)=>(r(),s("div",Ss,[e("div",Ts,[u[1]||(u[1]=e("div",{class:"flex items-start justify-between mb-4"},[e("div",null,[e("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-1"},"CORS Settings"),e("p",{class:"text-sm text-content-secondary dark:text-content-muted"},"Control cross-origin resource sharing for API access")])],-1)),e("div",Bs,[e("div",Es,[u[0]||(u[0]=e("div",null,[e("label",{class:"text-sm font-medium text-content-primary dark:text-content-primary"},"Enable CORS"),e("p",{class:"text-xs text-content-secondary dark:text-content-muted mt-1"},"Allow web frontends from different origins to access the API")],-1)),e("button",{onClick:C,disabled:f.value,class:P(["relative inline-flex h-6 w-11 items-center rounded-full transition-colors border-2",c.cors_enabled?"bg-accent-blue border-accent-blue":"bg-gray-600 border-gray-600",f.value?"opacity-50 cursor-not-allowed":"cursor-pointer"])},[e("span",{class:P(["inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow-lg",c.cors_enabled?"translate-x-5":"translate-x-0.5"])},null,2)],10,Fs)])])]),e("div",Ls,[u[11]||(u[11]=e("div",{class:"flex items-start justify-between mb-4"},[e("div",null,[e("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-1"},"Web Frontend"),e("p",{class:"text-sm text-content-secondary dark:text-content-muted"},"Choose which web interface to use")])],-1)),e("div",Ps,[e("div",zs,[e("label",{class:P(["flex items-start space-x-3 p-4 bg-background-mute dark:bg-background/30 rounded-lg border-2 cursor-pointer transition-all",c.use_default_frontend?"border-accent-blue bg-accent-blue/10":"border-stroke-subtle dark:border-stroke/10 hover:border-accent-blue/50"])},[e("input",{type:"radio",name:"frontend",checked:c.use_default_frontend,onChange:a,disabled:f.value,class:"mt-1 h-4 w-4 text-accent-blue focus:ring-accent-blue focus:ring-offset-background"},null,40,Is),u[2]||(u[2]=e("div",{class:"flex-1"},[e("div",{class:"text-sm font-medium text-content-primary dark:text-content-primary"},"Default Frontend"),e("div",{class:"text-xs text-content-secondary dark:text-content-muted mt-1"},"Built-in pyMC Repeater web interface"),e("div",{class:"text-xs text-content-muted dark:text-content-muted/60 mt-1 font-mono"},"Built-in")],-1))],2),e("label",{class:P(["flex items-start space-x-3 p-4 bg-background-mute dark:bg-background/30 rounded-lg border-2 cursor-pointer transition-all",c.use_default_frontend?"border-stroke-subtle dark:border-stroke/10 hover:border-accent-blue/50":"border-accent-blue bg-accent-blue/10"])},[e("input",{type:"radio",name:"frontend",checked:!c.use_default_frontend,onChange:t,disabled:f.value,class:"mt-1 h-4 w-4 text-accent-blue focus:ring-accent-blue focus:ring-offset-background"},null,40,Ds),u[3]||(u[3]=O('
PyMC Console
@Treehouse⚡
Alternative web interface for pyMC Repeater
/opt/pymc_console/web/html
',1))],2)]),o.value?S("",!0):(r(),s("div",{key:0,class:P(["p-4 rounded-lg border",i.value?"bg-green-500/5 border-green-500/20":"bg-accent-blue/5 border-accent-blue/20"])},[e("div",Hs,[i.value?(r(),s("svg",Us,u[4]||(u[4]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)]))):(r(),s("svg",Vs,u[5]||(u[5]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)]))),e("div",Rs,[e("h4",Ks,b(i.value?"PyMC Console has been detected":"PyMC Console Not Installed"),1),i.value?(r(),s("p",qs,u[6]||(u[6]=[I(" PyMC Console is installed at ",-1),e("code",{class:"text-green-700 dark:text-green-300"},"/opt/pymc_console/web/html",-1)]))):(r(),s(K,{key:1},[u[7]||(u[7]=O('

PyMC Console must be installed at /opt/pymc_console/web/html before selecting this option.

PyMC Console Install Instructions ',2))],64))])])],2)),g.value?(r(),s("div",Ws,[e("div",Os,[u[10]||(u[10]=O('

Service restart required

Web frontend changes will take effect after restarting the pymc-repeater service.

',1)),e("button",{onClick:M,disabled:d.value,class:"px-4 py-2 bg-amber-500 hover:bg-amber-600 disabled:bg-amber-500/50 text-white font-medium rounded-lg transition-colors disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"},[d.value?(r(),s("svg",Ys,u[8]||(u[8]=[e("circle",{class:"opacity-25",cx:"12",cy:"12",r:"10",stroke:"currentColor","stroke-width":"4"},null,-1),e("path",{class:"opacity-75",fill:"currentColor",d:"M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"},null,-1)]))):(r(),s("svg",Xs,u[9]||(u[9]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"},null,-1)]))),I(" "+b(d.value?"Restarting...":"Restart Now"),1)],8,Gs)])])):S("",!0)])]),l.value?(r(),s("div",{key:0,class:P(["p-4 rounded-lg border",y.value])},[e("div",Js,[m.value?(r(),s("svg",Qs,u[12]||(u[12]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"},null,-1)]))):(r(),s("svg",Zs,u[13]||(u[13]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"},null,-1)]))),e("span",{class:P(m.value?"text-green-600 dark:text-green-400":"text-red-600 dark:text-red-400")},b(l.value),3)])],2)):S("",!0)]))}}),tn={class:"p-3 sm:p-6 space-y-4 sm:space-y-6"},on={class:"glass-card rounded-[15px] z-10 p-3 sm:p-4 border border-cyan-400 dark:border-primary/30 bg-cyan-500/10 dark:bg-primary/10"},rn={class:"text-cyan-700 dark:text-primary text-sm sm:text-base"},sn={class:"mt-1 sm:mt-2 text-cyan-600 dark:text-primary/80"},nn={class:"glass-card rounded-[15px] p-3 sm:p-6"},an={class:"flex overflow-x-auto border-b border-stroke-subtle dark:border-stroke/10 mb-4 sm:mb-6 -mx-3 px-3 sm:mx-0 sm:px-0 scrollbar-hide"},ln=["onClick"],dn={class:"flex items-center gap-1 sm:gap-2"},cn={key:0,class:"w-3.5 h-3.5 sm:w-4 sm:h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},un={key:1,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},mn={key:2,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},pn={key:3,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},bn={key:4,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},vn={key:5,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},xn={key:6,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},kn={class:"min-h-[400px]"},gn={key:0,class:"flex items-center justify-center py-12"},yn={key:1,class:"flex items-center justify-center py-12"},fn={class:"text-center"},hn={class:"text-content-secondary dark:text-content-muted text-sm mb-4"},wn={key:2},An=q({name:"ConfigurationView",__name:"Configuration",setup(z){const f=ee(),l=v(ge("configuration_activeTab","radio")),m=v(!1);Q(l,i=>ye("configuration_activeTab",i));const g=[{id:"radio",label:"Radio Settings",icon:"radio"},{id:"repeater",label:"Repeater Settings",icon:"repeater"},{id:"duty",label:"Duty Cycle",icon:"duty"},{id:"delays",label:"TX Delays",icon:"delays"},{id:"transport",label:"Regions/Keys",icon:"keys"},{id:"api-tokens",label:"API Tokens",icon:"tokens"},{id:"web",label:"Web Options",icon:"web"}];oe(async()=>{try{await f.fetchStats(),m.value=!0}catch(i){console.error("Failed to load configuration data:",i),m.value=!0}});function d(i){l.value=i}return(i,o)=>{const c=ue("router-link");return r(),s("div",tn,[o[13]||(o[13]=e("div",null,[e("h1",{class:"text-xl sm:text-2xl font-bold text-content-primary dark:text-content-primary"},"Configuration"),e("p",{class:"text-content-secondary dark:text-content-muted mt-1 sm:mt-2 text-sm sm:text-base"},"System configuration and settings")],-1)),e("div",on,[e("div",rn,[o[3]||(o[3]=e("strong",null,"CAD Calibration Tool Available",-1)),e("p",sn,[o[2]||(o[2]=I(" Optimize your Channel Activity Detection settings. ",-1)),R(c,{to:"/cad-calibration",class:"underline hover:text-cyan-800 dark:hover:text-primary transition-colors"},{default:ie(()=>o[1]||(o[1]=[I(" Launch CAD Calibration Tool → ",-1)])),_:1,__:[1]})])])]),e("div",nn,[e("div",an,[(r(),s(K,null,Y(g,y=>e("button",{key:y.id,onClick:h=>d(y.id),class:P(["px-3 sm:px-4 py-2 text-xs sm:text-sm font-medium transition-colors duration-200 border-b-2 mr-3 sm:mr-6 whitespace-nowrap flex-shrink-0",l.value===y.id?"text-cyan-500 dark:text-primary border-cyan-500 dark:border-primary":"text-content-secondary dark:text-content-muted border-transparent hover:text-content-primary dark:hover:text-content-primary hover:border-stroke-subtle dark:hover:border-stroke/30"])},[e("div",dn,[y.icon==="radio"?(r(),s("svg",cn,o[4]||(o[4]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.822c5.716-5.716 14.976-5.716 20.692 0"},null,-1)]))):y.icon==="repeater"?(r(),s("svg",un,o[5]||(o[5]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 12h14M5 12l4-4m-4 4l4 4"},null,-1)]))):y.icon==="duty"?(r(),s("svg",mn,o[6]||(o[6]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)]))):y.icon==="delays"?(r(),s("svg",pn,o[7]||(o[7]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"},null,-1)]))):y.icon==="keys"?(r(),s("svg",bn,o[8]||(o[8]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"},null,-1)]))):y.icon==="tokens"?(r(),s("svg",vn,o[9]||(o[9]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"},null,-1)]))):y.icon==="web"?(r(),s("svg",xn,o[10]||(o[10]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"},null,-1)]))):S("",!0),I(" "+b(y.label),1)])],10,ln)),64))]),e("div",kn,[!m.value&&W(f).isLoading?(r(),s("div",gn,o[11]||(o[11]=[e("div",{class:"text-center"},[e("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-cyan-500 dark:border-t-primary rounded-full mx-auto mb-4"}),e("div",{class:"text-content-secondary dark:text-content-muted"},"Loading configuration...")],-1)]))):W(f).error&&!m.value?(r(),s("div",yn,[e("div",fn,[o[12]||(o[12]=e("div",{class:"text-red-500 dark:text-red-400 mb-2"},"Failed to load configuration",-1)),e("div",hn,b(W(f).error),1),e("button",{onClick:o[0]||(o[0]=y=>W(f).fetchStats()),class:"px-4 py-2 bg-cyan-500/20 dark:bg-primary/20 hover:bg-cyan-500/30 dark:hover:bg-primary/30 text-cyan-900 dark:text-white rounded-lg border border-cyan-500/50 dark:border-primary/50 transition-colors"}," Retry ")])])):(r(),s("div",wn,[E(e("div",null,[R(Ye,{key:"radio-settings"})],512),[[J,l.value==="radio"]]),E(e("div",null,[R(jt,{key:"repeater-settings"})],512),[[J,l.value==="repeater"]]),E(e("div",null,[R(Ht,{key:"duty-cycle"})],512),[[J,l.value==="duty"]]),E(e("div",null,[R(eo,{key:"transmission-delays"})],512),[[J,l.value==="delays"]]),E(e("div",null,[R(ts,{key:"transport-keys"})],512),[[J,l.value==="transport"]]),E(e("div",null,[R(Ns,{key:"api-tokens"})],512),[[J,l.value==="api-tokens"]]),E(e("div",null,[R(en,{key:"web-settings"})],512),[[J,l.value==="web"]])]))])])])}}});export{An as default}; diff --git a/repeater/web/html/assets/ConfirmDialog.vue_vue_type_script_setup_true_lang-CT6z2S3q.js b/repeater/web/html/assets/ConfirmDialog.vue_vue_type_script_setup_true_lang-CVxh_fqf.js similarity index 98% rename from repeater/web/html/assets/ConfirmDialog.vue_vue_type_script_setup_true_lang-CT6z2S3q.js rename to repeater/web/html/assets/ConfirmDialog.vue_vue_type_script_setup_true_lang-CVxh_fqf.js index 5ce88e8..b79cc3b 100644 --- a/repeater/web/html/assets/ConfirmDialog.vue_vue_type_script_setup_true_lang-CT6z2S3q.js +++ b/repeater/web/html/assets/ConfirmDialog.vue_vue_type_script_setup_true_lang-CVxh_fqf.js @@ -1 +1 @@ -import{a as p,b as n,g as m,e as t,s as g,t as s,j as d,p as l}from"./index-sHch0610.js";const f={class:"flex items-center justify-between mb-4"},w={class:"text-xl font-semibold text-content-primary dark:text-content-primary"},v={class:"mb-6"},h={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},y={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},C={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},B={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},j={class:"flex gap-3"},_=p({__name:"ConfirmDialog",props:{show:{type:Boolean},title:{default:"Confirm Action"},message:{},confirmText:{default:"Confirm"},cancelText:{default:"Cancel"},variant:{default:"warning"}},emits:["close","confirm"],setup(c,{emit:b}){const o=c,r=b,u=i=>{i.target===i.currentTarget&&r("close")},k={danger:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",warning:"bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},x={danger:"bg-red-500 hover:bg-red-600",warning:"bg-yellow-500 hover:bg-yellow-600",info:"bg-blue-500 hover:bg-blue-600"};return(i,e)=>o.show?(l(),n("div",{key:0,onClick:u,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[3]||(e[3]=g(()=>{},["stop"]))},[t("div",f,[t("h3",w,s(o.title),1),t("button",{onClick:e[0]||(e[0]=a=>r("close")),class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},e[4]||(e[4]=[t("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",v,[t("div",{class:d(["inline-flex p-3 rounded-xl mb-4",k[o.variant]])},[o.variant==="danger"?(l(),n("svg",h,e[5]||(e[5]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):o.variant==="warning"?(l(),n("svg",y,e[6]||(e[6]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):(l(),n("svg",C,e[7]||(e[7]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),t("p",B,s(o.message),1)]),t("div",j,[t("button",{onClick:e[1]||(e[1]=a=>r("close")),class:"flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10"},s(o.cancelText),1),t("button",{onClick:e[2]||(e[2]=a=>r("confirm")),class:d(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",x[o.variant]])},s(o.confirmText),3)])])])):m("",!0)}});export{_}; +import{a as p,b as n,g as m,e as t,s as g,t as s,j as d,p as l}from"./index-DyUIpN7m.js";const f={class:"flex items-center justify-between mb-4"},w={class:"text-xl font-semibold text-content-primary dark:text-content-primary"},v={class:"mb-6"},h={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},y={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},C={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},B={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},j={class:"flex gap-3"},_=p({__name:"ConfirmDialog",props:{show:{type:Boolean},title:{default:"Confirm Action"},message:{},confirmText:{default:"Confirm"},cancelText:{default:"Cancel"},variant:{default:"warning"}},emits:["close","confirm"],setup(c,{emit:b}){const o=c,r=b,u=i=>{i.target===i.currentTarget&&r("close")},k={danger:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",warning:"bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},x={danger:"bg-red-500 hover:bg-red-600",warning:"bg-yellow-500 hover:bg-yellow-600",info:"bg-blue-500 hover:bg-blue-600"};return(i,e)=>o.show?(l(),n("div",{key:0,onClick:u,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[3]||(e[3]=g(()=>{},["stop"]))},[t("div",f,[t("h3",w,s(o.title),1),t("button",{onClick:e[0]||(e[0]=a=>r("close")),class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},e[4]||(e[4]=[t("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",v,[t("div",{class:d(["inline-flex p-3 rounded-xl mb-4",k[o.variant]])},[o.variant==="danger"?(l(),n("svg",h,e[5]||(e[5]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):o.variant==="warning"?(l(),n("svg",y,e[6]||(e[6]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):(l(),n("svg",C,e[7]||(e[7]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),t("p",B,s(o.message),1)]),t("div",j,[t("button",{onClick:e[1]||(e[1]=a=>r("close")),class:"flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10"},s(o.cancelText),1),t("button",{onClick:e[2]||(e[2]=a=>r("confirm")),class:d(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",x[o.variant]])},s(o.confirmText),3)])])])):m("",!0)}});export{_}; diff --git a/repeater/web/html/assets/Dashboard-BCvxH1o7.css b/repeater/web/html/assets/Dashboard-BCvxH1o7.css deleted file mode 100644 index 434faff..0000000 --- a/repeater/web/html/assets/Dashboard-BCvxH1o7.css +++ /dev/null @@ -1 +0,0 @@ -.sparkline-card[data-v-bcd5cf93]{background:#ffffffbf;border:1px solid rgba(0,0,0,.06);border-radius:12px;padding:12px 14px;-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px);overflow:hidden;transition:background .3s ease,border-color .3s ease,box-shadow .3s ease;box-shadow:0 4px 16px #0000000a,0 1px 3px #00000005}.dark .sparkline-card[data-v-bcd5cf93]{background:#0006;border:1px solid rgba(255,255,255,.05);box-shadow:0 4px 16px #0003}.card-header[data-v-bcd5cf93]{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:8px}.card-title[data-v-bcd5cf93]{color:#4b5563b3;font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:.05em;transition:color .3s ease}.dark .card-title[data-v-bcd5cf93]{color:#fff9}.card-value[data-v-bcd5cf93]{font-size:22px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}.card-chart[data-v-bcd5cf93]{width:100%;height:28px;overflow:hidden}.card-chart canvas[data-v-bcd5cf93]{width:100%!important;height:100%!important}@media (min-width: 1024px){.sparkline-card[data-v-bcd5cf93]{padding:14px 16px}.card-header[data-v-bcd5cf93]{margin-bottom:10px}.card-title[data-v-bcd5cf93]{font-size:12px}.card-value[data-v-bcd5cf93]{font-size:26px}.card-chart[data-v-bcd5cf93]{height:32px}}.stats-cards-container[data-v-b87cc7f8]{will-change:auto;contain:layout}.stat-card[data-v-b87cc7f8]{transition:opacity .3s ease-out}.stat-card[data-v-b87cc7f8] .text-lg,.stat-card[data-v-b87cc7f8] .text-\[30px\]{transition:color .2s ease-out}canvas[data-v-0aca4e12]{width:100%;height:100%}.modal-enter-active[data-v-7f139e4b]{transition:all .3s cubic-bezier(.4,0,.2,1)}.modal-leave-active[data-v-7f139e4b]{transition:all .2s ease-in}.modal-enter-from[data-v-7f139e4b]{opacity:0;transform:scale(.95) translateY(-10px)}.modal-leave-to[data-v-7f139e4b]{opacity:0;transform:scale(1.05)}.custom-scrollbar[data-v-7f139e4b]{scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.3) transparent}.custom-scrollbar[data-v-7f139e4b]::-webkit-scrollbar{width:6px}.custom-scrollbar[data-v-7f139e4b]::-webkit-scrollbar-track{background:#ffffff1a;border-radius:3px}.custom-scrollbar[data-v-7f139e4b]::-webkit-scrollbar-thumb{background:#ffffff4d;border-radius:3px}.custom-scrollbar[data-v-7f139e4b]::-webkit-scrollbar-thumb:hover{background:#fff6}.glass-card[data-v-7f139e4b]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px)}.fade-enter-active[data-v-ec32abd6],.fade-leave-active[data-v-ec32abd6]{transition:opacity .3s ease-out,transform .3s ease-out}.fade-enter-from[data-v-ec32abd6],.fade-leave-to[data-v-ec32abd6]{opacity:0;transform:translateY(-10px)}@keyframes spin-ec32abd6{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin[data-v-ec32abd6]{animation:spin-ec32abd6 .8s linear infinite}.packet-list-enter-active[data-v-ec32abd6],.packet-list-leave-active[data-v-ec32abd6],.packet-list-move[data-v-ec32abd6]{transition:all .4s ease-out}.packet-list-enter-from[data-v-ec32abd6]{opacity:0;transform:translateY(-30px) scale(.98)}.packet-list-enter-to[data-v-ec32abd6],.packet-list-leave-from[data-v-ec32abd6]{opacity:1;transform:translateY(0) scale(1)}.packet-list-leave-to[data-v-ec32abd6]{opacity:0;transform:translateY(-20px) scale(.95)}.packet-row[data-v-ec32abd6]{position:relative;transition:all .3s ease}.packet-list-enter-active .packet-row[data-v-ec32abd6]{background:linear-gradient(90deg,rgba(78,201,176,.1) 0%,rgba(78,201,176,.05) 50%,transparent 100%);box-shadow:0 0 20px #4ec9b033;border-left:3px solid rgba(78,201,176,.6);border-radius:8px;padding-left:12px}.packet-row[data-v-ec32abd6]:hover{background:#ffffff05;border-radius:8px;transition:background .2s ease}@media (max-width: 1023px){.filter-container[data-v-ec32abd6]{flex-direction:column;gap:1rem;align-items:stretch}.header-info[data-v-ec32abd6]{flex-direction:column;align-items:flex-start;gap:.5rem}.packet-count[data-v-ec32abd6]{order:1}.live-mode-badge[data-v-ec32abd6]{order:2;align-self:flex-start}.loading-indicator[data-v-ec32abd6],.error-indicator[data-v-ec32abd6]{order:3;align-self:flex-start}.filter-controls[data-v-ec32abd6]{display:grid!important;grid-template-columns:1fr 1fr;gap:.75rem;flex-direction:column}.filter-controls .flex.flex-col[data-v-ec32abd6]{flex-direction:column;align-items:stretch;gap:.25rem}.filter-controls .flex.flex-col label[data-v-ec32abd6]{margin-bottom:0;font-size:.75rem}.reset-container[data-v-ec32abd6]{grid-column:span 2!important;display:flex;justify-content:center;margin-top:.5rem}.pagination-container[data-v-ec32abd6]{flex-direction:column;gap:1rem;align-items:stretch}.pagination-info[data-v-ec32abd6]{justify-content:center;text-align:center;flex-direction:column;gap:.5rem}.load-more-section[data-v-ec32abd6]{justify-content:center}.load-more-count[data-v-ec32abd6]{display:none}.pagination-controls[data-v-ec32abd6]{justify-content:center}.page-numbers[data-v-ec32abd6]{max-width:200px;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none}.page-numbers[data-v-ec32abd6]::-webkit-scrollbar{display:none}.ellipsis[data-v-ec32abd6]{display:none}.page-number[data-v-ec32abd6]{min-width:40px;flex-shrink:0}}@media (max-width: 640px){.filter-controls[data-v-ec32abd6]{grid-template-columns:1fr!important;gap:.75rem}.reset-container[data-v-ec32abd6]{grid-column:span 1!important}.header-info h3[data-v-ec32abd6]{font-size:1.125rem}.packet-count[data-v-ec32abd6]{font-size:.75rem}.live-mode-badge[data-v-ec32abd6]{font-size:.75rem;padding:.25rem .5rem}.pagination-info span[data-v-ec32abd6]{font-size:.75rem}.prev-next-btn[data-v-ec32abd6]{min-width:40px;padding:.5rem}.page-numbers[data-v-ec32abd6]{max-width:150px;gap:.25rem}.page-number[data-v-ec32abd6]{min-width:36px;padding:.5rem .25rem;font-size:.75rem}.load-more-section button[data-v-ec32abd6]{font-size:.6rem;padding:.375rem .75rem}} diff --git a/repeater/web/html/assets/Dashboard-BqBlpKE8.js b/repeater/web/html/assets/Dashboard-BqBlpKE8.js new file mode 100644 index 0000000..e64abfc --- /dev/null +++ b/repeater/web/html/assets/Dashboard-BqBlpKE8.js @@ -0,0 +1,2 @@ +import{C as wt,a as Nt,L as Bt,P as Ft,b as Et,c as jt,i as It}from"./chart-B185MtDy.js";import{a as ct,r as B,c as Y,D as et,o as bt,E as dt,H as vt,b as l,e as t,t as n,g as k,n as ut,I as Lt,p as r,x as mt,J as gt,K as kt,f as st,F as H,h as Q,L as ft,M as Ut,i as Rt,u as pt,k as rt,N as Vt,T as Ht,l as zt,O as Xt,j as T,s as Gt,w as $t,q as Tt}from"./index-DyUIpN7m.js";import{u as Ot}from"./useSignalQuality-DR_wpBbb.js";import{g as Ct,s as St}from"./preferences-DtwbSSgO.js";const Wt={class:"sparkline-card"},Qt={class:"card-header"},qt={class:"card-title"},Kt={class:"card-values"},Jt={class:"card-chart"},Yt=ct({name:"ChartSparkline",__name:"ChartSparkline",props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},secondaryValue:{default:void 0},secondaryLabel:{default:""},secondaryColor:{default:""},secondaryData:{default:()=>[]}},setup(nt){wt.register(Nt,Bt,Ft,Et,jt,It);const _=nt,q=B(null),m=B(null),C=h=>{if(h.length<3)return h;const F=Math.min(15,Math.max(3,Math.floor(h.length*.2))),O=[];for(let S=0;SN+w,0)/g.length)}const G=Math.min(12,O.length),E=O.length/G,M=[];for(let S=0;S!_.data||_.data.length===0?[]:C(_.data)),A=Y(()=>!_.secondaryData||_.secondaryData.length===0?[]:C(_.secondaryData)),X=()=>{if(!q.value)return;const h=q.value.getContext("2d");if(!h)return;m.value&&(m.value.destroy(),m.value=null);const F=$.value;if(F.length<2)return;const O=[{data:F,borderColor:_.color,borderWidth:2.5,fill:!1,tension:.4,pointRadius:0,pointHoverRadius:0}],G=A.value;G.length>=2&&_.secondaryColor&&O.push({data:G,borderColor:_.secondaryColor,borderWidth:2,borderDash:[4,3],fill:!1,tension:.4,pointRadius:0,pointHoverRadius:0}),m.value=Lt(new wt(h,{type:"line",data:{labels:F.map((E,M)=>M.toString()),datasets:O},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:800,easing:"easeOutQuart"},plugins:{legend:{display:!1},tooltip:{enabled:!1}},scales:{x:{display:!1,grid:{display:!1}},y:{display:!1,grid:{display:!1},grace:"10%"}},elements:{line:{capBezierPoints:!0}}}}))},D=()=>{if(!m.value){X();return}const h=$.value;if(h.length<2)return;m.value.data.labels=h.map((O,G)=>G.toString()),m.value.data.datasets[0].data=h;const F=A.value;F.length>=2&&_.secondaryColor&&(m.value.data.datasets.length<2?m.value.data.datasets.push({data:F,borderColor:_.secondaryColor,borderWidth:2,borderDash:[4,3],fill:!1,tension:.4,pointRadius:0,pointHoverRadius:0}):m.value.data.datasets[1].data=F),m.value.update("default")};return et(()=>_.data,()=>{dt(()=>D())},{deep:!0}),et(()=>_.color,()=>{m.value&&(m.value.data.datasets[0].borderColor=_.color,m.value.update("none"))}),bt(()=>{dt(()=>X())}),vt(()=>{m.value&&(m.value.destroy(),m.value=null)}),(h,F)=>(r(),l("div",Wt,[t("div",Qt,[t("p",qt,n(h.title),1),t("div",Kt,[t("span",{class:"card-value",style:ut({color:h.color})},n(typeof h.value=="number"?h.value.toLocaleString():h.value),5),h.secondaryValue!==void 0?(r(),l("span",{key:0,class:"card-secondary-value",style:ut({color:h.secondaryColor})},n(h.secondaryLabel)+n(typeof h.secondaryValue=="number"?h.secondaryValue.toLocaleString():h.secondaryValue),5)):k("",!0)])]),t("div",Jt,[h.showChart?(r(),l("canvas",{key:0,ref_key:"canvasRef",ref:q},null,512)):k("",!0)])]))}}),xt=mt(Yt,[["__scopeId","data-v-814635af"]]),Zt={class:"grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3 lg:gap-4 mb-5 stats-cards-container"},te=ct({name:"StatsCards",__name:"StatsCards",setup(nt){const _=gt(),q=kt(),m=B(null),C=B(null),$=B(!1),A=Y(()=>{const h=_.packetStats,F=_.systemStats,O=S=>{const x=Math.floor(S/86400),d=Math.floor(S%86400/3600),v=Math.floor(S%3600/60);return x>0?`${x}d ${d}h`:d>0?`${d}h ${v}m`:`${v}m`},G=h?.total_packets||0,E=h?.dropped_packets||0,M=G>0?Math.round(E/G*100):0;return{packetsReceived:G,packetsForwarded:h?.transmitted_packets||0,uptimeFormatted:F?O(F.uptime_seconds||0):"0m",uptimeHours:F?Math.floor((F.uptime_seconds||0)/3600):0,droppedPackets:E,dropPercent:`${M}%`,signalQuality:Math.round((h?.avg_rssi||0)+120),crcErrorCount:_.crcErrorCount}}),X=Y(()=>_.sparklineData),D=async()=>{if(!$.value)try{$.value=!0,await Promise.all([_.fetchSystemStats(),_.fetchPacketStats({hours:24})]),await dt()}catch(h){console.error("Error fetching stats:",h)}finally{$.value=!1}};return bt(async()=>{await _.initializeSparklineHistory(),D(),q.isConnected||(m.value=window.setInterval(D,3e4)),C.value=window.setInterval(()=>{_.interpolateRates()},6e4)}),et(()=>q.isConnected,h=>{h?m.value&&(clearInterval(m.value),m.value=null):m.value||(m.value=window.setInterval(D,3e4))}),vt(()=>{m.value&&clearInterval(m.value),C.value&&clearInterval(C.value)}),(h,F)=>(r(),l("div",Zt,[st(xt,{title:"Up Time",value:A.value.uptimeFormatted,color:"#EBA0FC",data:[],showChart:!1,class:"stat-card"},null,8,["value"]),st(xt,{title:"RX Packets",value:A.value.packetsReceived,color:"#AAE8E8",data:X.value.totalPackets,class:"stat-card"},null,8,["value","data"]),st(xt,{title:"Forward",value:A.value.packetsForwarded,color:"#FFC246",data:X.value.transmittedPackets,class:"stat-card"},null,8,["value","data"]),st(xt,{title:"Dropped",value:A.value.droppedPackets,color:"#FB787B",data:X.value.droppedPackets,class:"stat-card"},null,8,["value","data"]),st(xt,{title:"CRC Errors",value:A.value.crcErrorCount,color:"#F59E0B",data:X.value.crcErrors,class:"stat-card"},null,8,["value","data"])]))}}),ee=mt(te,[["__scopeId","data-v-84cee3fb"]]),se={class:"glass-card rounded-[10px] p-4 lg:p-6"},ae={class:"h-48 lg:h-56 relative"},ne={key:0,class:"absolute inset-0 flex items-center justify-center"},oe={key:1,class:"absolute inset-0 flex items-center justify-center"},re={class:"text-red-600 dark:text-red-400 text-sm lg:text-base"},le={key:2,class:"absolute inset-0 flex items-center justify-center"},ie={key:3,class:"h-full flex flex-col"},de={key:0,class:"absolute top-2 left-1/2 -translate-x-1/2 bg-white/95 dark:bg-surface-elevated border border-stroke-subtle dark:border-stroke rounded-lg px-3 py-2 z-10 pointer-events-none min-w-48"},ce={class:"text-content-primary dark:text-content-primary text-sm font-medium mb-1"},ue={class:"text-content-primary dark:text-content-primary"},pe={class:"flex-1 flex items-end justify-evenly gap-4 px-4"},me=["onMouseenter"],xe={class:"text-content-primary dark:text-content-primary text-xs sm:text-sm font-semibold text-center w-full",style:{"padding-bottom":"5px"}},ye={class:"text-content-secondary dark:text-content-muted text-xs mt-2 text-center"},be={key:0,class:"mt-4 flex flex-wrap justify-center gap-3 sm:gap-4 px-2 sm:px-4 text-[10px] sm:text-xs text-content-secondary dark:text-content-muted"},ve={class:"truncate text-left"},ge={key:1,class:"mt-3 text-xs text-content-secondary dark:text-content-muted text-center"},he=ct({name:"PacketTypesChart",__name:"PacketTypesChart",setup(nt){const _=B([]),q=gt(),m=kt(),C=B(!0),$=B(null),A=B(null),X=[{name:"Payload",types:["Plain Text Message","Group Text Message","Group Datagram","Multi-part Packet"],subColors:["#3B82F6","#60A5FA","#93C5FD","#BFDBFE"]},{name:"Requests",types:["Request","Response","Anonymous Request"],subColors:["#10B981","#34D399","#6EE7B7"]},{name:"Control",types:["Node Advertisement","Acknowledgment","Returned Path"],subColors:["#F59E0B","#FBBF24","#FCD34D"]},{name:"Routing",types:["Trace"],subColors:["#8B5CF6"]},{name:"Reserved",types:["Reserved Type 11","Reserved Type 12","Reserved Type 13"],subColors:["#6B7280","#9CA3AF","#D1D5DB"]}],D=Y(()=>X.map(x=>{const d=_.value.filter(v=>x.types.some(g=>v.name.includes(g)||v.name===g)).sort((v,g)=>g.count-v.count).map((v,g)=>({...v,color:x.subColors[g%x.subColors.length]}));return{name:x.name,color:x.subColors[0],items:d,total:d.reduce((v,g)=>v+g.count,0)}}).filter(x=>x.total>0)),h=Y(()=>Math.max(...D.value.map(x=>x.total),1)),F=Y(()=>D.value.reduce((x,d)=>x+d.total,0)),O=async()=>{try{$.value=null;const x=await ft.get("/packet_type_graph_data");if(x?.success&&x?.data){const d=x.data;if(d?.series){const v=[];d.series.forEach((g,N)=>{let w=0;g.data&&Array.isArray(g.data)&&(w=g.data.reduce((s,e)=>s+(e[1]||0),0)),w>0&&v.push({name:g.name||`Type ${g.type}`,type:g.type,count:w,color:""})}),_.value=v,C.value=!1}else $.value="No series data in server response",C.value=!1}else $.value="Invalid response from server",C.value=!1}catch(x){$.value=x instanceof Error?x.message:"Failed to load data",C.value=!1}},G={0:"Request",1:"Response",2:"Plain Text Message",3:"Acknowledgment",4:"Node Advertisement",5:"Group Text Message",6:"Group Datagram",7:"Anonymous Request",8:"Returned Path",9:"Trace",10:"Multi-part Packet",15:"Custom Packet"},E=()=>{const x=q.packetTypeBreakdown;!x||x.length===0||(_.value=x.map(d=>({name:G[Number(d.type)]||`Type ${d.type}`,type:d.type,count:d.count,color:""})),C.value=!1,$.value=null)},M=x=>Math.max(x/h.value*90,2),S=(x,d)=>d===0?0:x/d*100;return bt(()=>{O()}),et(()=>q.packetTypeBreakdown,()=>E(),{deep:!0,immediate:!0}),et(()=>m.isConnected,x=>{x||O()},{immediate:!0}),(x,d)=>(r(),l("div",se,[d[3]||(d[3]=t("div",{class:"flex items-baseline justify-between mb-3 lg:mb-4"},[t("h3",{class:"text-content-primary dark:text-content-primary text-lg lg:text-xl font-semibold"},"Packet Types"),t("p",{class:"text-content-secondary dark:text-content-muted text-xs lg:text-sm uppercase"},"Distribution by Type")],-1)),t("div",ae,[C.value?(r(),l("div",ne,d[1]||(d[1]=[t("div",{class:"text-content-secondary dark:text-content-primary text-sm lg:text-base"},"Loading packet types...",-1)]))):$.value?(r(),l("div",oe,[t("div",re,n($.value),1)])):D.value.length===0?(r(),l("div",le,d[2]||(d[2]=[t("div",{class:"text-content-secondary dark:text-content-primary text-sm lg:text-base"},"No packet data available",-1)]))):(r(),l("div",ie,[A.value?(r(),l("div",de,[t("div",ce,n(A.value.name)+" · "+n(A.value.total.toLocaleString()),1),(r(!0),l(H,null,Q(A.value.items,v=>(r(),l("div",{key:v.type,class:"flex justify-between gap-4 text-xs text-content-secondary dark:text-content-muted"},[t("span",null,n(v.name),1),t("span",ue,n(v.count.toLocaleString()),1)]))),128))])):k("",!0),t("div",pe,[(r(!0),l(H,null,Q(D.value,v=>(r(),l("div",{key:v.name,class:"flex flex-col items-center flex-1 max-w-32 h-full justify-end cursor-pointer",onMouseenter:g=>A.value=v,onMouseleave:d[0]||(d[0]=g=>A.value=null)},[t("span",xe,n(v.total.toLocaleString()),1),t("div",{class:"w-full rounded-[5px] transition-all duration-300 ease-out hover:opacity-90 overflow-hidden flex flex-col-reverse",style:ut({height:M(v.total)+"%",minHeight:"8px"})},[(r(!0),l(H,null,Q(v.items,g=>(r(),l("div",{key:g.type,style:ut({height:S(g.count,v.total)+"%",backgroundColor:g.color})},null,4))),128))],4),t("span",ye,n(v.name),1)],40,me))),128))])]))]),D.value.length>0?(r(),l("div",be,[(r(!0),l(H,null,Q(D.value,v=>(r(),l("div",{key:"legend-"+v.name,class:"flex flex-col gap-0.5 min-w-[100px] max-w-[140px] flex-shrink-0"},[(r(!0),l(H,null,Q(v.items,g=>(r(),l("div",{key:g.type,class:"flex items-center gap-1.5"},[t("span",{class:"w-2 h-2 rounded-sm shrink-0",style:ut({backgroundColor:g.color})},null,4),t("span",ve,n(g.name),1)]))),128))]))),128))])):k("",!0),D.value.length>0?(r(),l("div",ge," Total: "+n(F.value.toLocaleString())+" packets ",1)):k("",!0)]))}}),fe=mt(he,[["__scopeId","data-v-0948a4bb"]]),ke={class:"glass-card rounded-[10px] p-4 lg:p-6"},_e={class:"relative h-40 lg:h-48"},we={class:"mt-3 lg:mt-4 grid grid-cols-2 gap-3 lg:gap-4"},$e={class:"text-center"},Te={class:"text-lg lg:text-2xl font-bold text-content-primary dark:text-content-primary"},Ce={class:"text-center"},Se={class:"text-lg lg:text-2xl font-bold text-content-primary dark:text-content-primary"},Re={class:"mt-2 lg:mt-3 grid grid-cols-3 gap-2 lg:gap-3 text-center"},Pe={class:"text-xs lg:text-sm font-semibold text-accent-purple flex items-center justify-center gap-1"},Ae={key:0,class:"inline-block w-1.5 h-1.5 rounded-full bg-secondary opacity-70",title:"Early data - limited uptime"},De={class:"text-xs text-content-secondary dark:text-content-muted"},Me={class:"text-xs lg:text-sm font-semibold text-accent-red flex items-center justify-center gap-1"},Ne={key:0,class:"inline-block w-1.5 h-1.5 rounded-full bg-secondary opacity-70",title:"Early data - limited uptime"},Be={class:"text-xs text-content-secondary dark:text-content-muted"},Fe={class:"text-xs lg:text-sm font-semibold text-white"},Ee=ct({name:"AirtimeUtilizationChart",__name:"AirtimeUtilizationChart",setup(nt){const _=gt(),q=Ut(),m=B(null),C=B([]),$=B(!0),A=B(null),X=B(30),D=B({totalReceived:0,totalTransmitted:0,dropped:0,firstPacketTime:0}),h=B({sf:9,bwHz:62500,cr:5,preamble:17}),F=x=>{const{sf:d,bwHz:v,cr:g,preamble:N}=h.value,w=1,s=0,e=d>=11&&v<=125e3?1:0,u=v/1e3,i=Math.pow(2,d)/u,o=(N+4.25)*i,y=Math.max(8*x-4*d+28+16*w-20*s,0),f=4*(d-2*e),j=(8+Math.ceil(y/f)*g)*i;return o+j},O=x=>{if(x.airtime_ms!==void 0&&x.airtime_ms>0)return x.airtime_ms;const d=x.length??x.payload_length??32;return F(d)},G=(x,d=60)=>{if(x.length===0)return[];const v=1-Math.pow(.5,1/d),g=Math.min(x.length,Math.max(10,Math.floor(d/3)));let N=0,w=0;for(let s=0;s(N=v*s.rxUtil+(1-v)*N,w=v*s.txUtil+(1-v)*w,{...s,rxUtil:N,txUtil:w}))},E=Y(()=>{const x=_.packetStats?.total_packets||0,d=_.packetStats?.transmitted_packets||0,v=q.stats?.uptime_seconds||0,g=x||D.value.totalReceived,N=d||D.value.totalTransmitted,w=D.value.firstPacketTime>0?Math.floor(Date.now()/1e3)-D.value.firstPacketTime:0,s=v||w,e=Math.max(s/3600,.1);if(e<1){const R=Math.max(s/60,1);return{rxRate:{value:Math.round(g/R*100)/100,label:e<.5?"RX/min (early)":"RX/min"},txRate:{value:Math.round(N/R*100)/100,label:e<.5?"TX/min (early)":"TX/min"},confidence:"low"}}const i=Math.round(g/e*100)/100,o=Math.round(N/e*100)/100;let y,f;return e<6?(y=`RX/hr (${Math.round(e)}h)`,f="medium"):e<24?(y=`RX/hr (${Math.round(e)}h)`,f="high"):(y="RX/hr",f="high"),{rxRate:{value:i,label:y},txRate:{value:o,label:y.replace("RX","TX")},confidence:f}}),M=async()=>{$.value=!0;try{const N=Math.floor(Date.now()/1e3),w=N-24*3600;let s=0;try{const b=await ft.get("/stats");if(b.success&&b.data){const P=b.data,z=P.config;if(z?.radio){const L=z.radio;h.value={sf:L.spreading_factor??9,bwHz:L.bandwidth??62500,cr:L.coding_rate??5,preamble:L.preamble_length??17}}s=P.dropped_count??0}}catch{}const e=await ft.get("/filtered_packets",{start_timestamp:w,end_timestamp:N,limit:5e4});if(!e.success){C.value=[],$.value=!1,dt(()=>S());return}const u=e.data||[],i=new Float64Array(8640),o=new Float64Array(8640);let y=0,f=0,R=1/0;for(const b of u){const P=Math.floor((b.timestamp-w)/10);if(P<0||P>=8640)continue;const z=O(b),L=b.packet_origin;b.timestamp[b.rxUtil,b.txUtil]))*1.05;X.value=Math.max(5,Math.ceil(V/5)*5),$.value=!1,dt(()=>S())}catch(x){console.error("Failed to fetch airtime data:",x),C.value=[],$.value=!1,dt(()=>S())}},S=()=>{if(!m.value)return;const x=m.value,d=x.getContext("2d");if(!d)return;const v=x.parentElement;if(!v)return;const g=v.getBoundingClientRect(),N=g.width,w=g.height;x.width=N*window.devicePixelRatio,x.height=w*window.devicePixelRatio,x.style.width=N+"px",x.style.height=w+"px",d.scale(window.devicePixelRatio,window.devicePixelRatio);const s=20,e=45;if(d.clearRect(0,0,N,w),$.value){d.fillStyle="#666",d.font="16px system-ui",d.textAlign="center",d.fillText("Loading chart data...",N/2,w/2);return}if(C.value.length===0){d.fillStyle="#666",d.font="16px system-ui",d.textAlign="center",d.fillText("No data available",N/2,w/2);return}const u=N-e-s,i=w-s*2,o=X.value,y=X.value;d.strokeStyle="rgba(255, 255, 255, 0.1)",d.lineWidth=1,d.font="10px system-ui",d.textAlign="right";for(let f=0;f<=5;f++){const R=s+i*f/5;d.beginPath(),d.moveTo(e,R),d.lineTo(N-s,R),d.stroke();const j=o-f/5*y;d.fillStyle="rgba(255, 255, 255, 0.5)",d.fillText(`${j.toFixed(0)}%`,e-5,R+3)}for(let f=0;f<=6;f++){const R=e+u*f/6;d.beginPath(),d.moveTo(R,s),d.lineTo(R,w-s),d.stroke()}C.value.length>1&&(d.strokeStyle="#EBA0FC",d.lineWidth=2,d.beginPath(),C.value.forEach((f,R)=>{const j=e+u*R/(C.value.length-1),U=w-s-Math.min(f.rxUtil,X.value)/y*i;R===0?d.moveTo(j,U):d.lineTo(j,U)}),d.stroke()),C.value.length>1&&(d.strokeStyle="#FB787B",d.lineWidth=2,d.beginPath(),C.value.forEach((f,R)=>{const j=e+u*R/(C.value.length-1),U=w-s-Math.min(f.txUtil,X.value)/y*i;R===0?d.moveTo(j,U):d.lineTo(j,U)}),d.stroke())};return bt(()=>{M(),A.value=window.setInterval(M,3e4),dt(()=>{S(),setTimeout(()=>S(),100)}),window.addEventListener("resize",S)}),vt(()=>{A.value&&clearInterval(A.value),window.removeEventListener("resize",S)}),(x,d)=>(r(),l("div",ke,[d[3]||(d[3]=Rt('

Airtime Utilization

Activity (Last 24 Hours)

Rx Util
Tx Util
',3)),t("div",_e,[t("canvas",{ref_key:"chartRef",ref:m,class:"absolute inset-0 w-full h-full"},null,512)]),t("div",we,[t("div",$e,[t("div",Te,n(pt(_).packetStats?.total_packets||D.value.totalReceived),1),d[0]||(d[0]=t("div",{class:"text-xs text-content-secondary dark:text-content-muted uppercase tracking-wide"},"Total Received",-1))]),t("div",Ce,[t("div",Se,n(pt(_).packetStats?.transmitted_packets||D.value.totalTransmitted),1),d[1]||(d[1]=t("div",{class:"text-xs text-content-secondary dark:text-content-muted uppercase tracking-wide"},"Total Transmitted",-1))])]),t("div",Re,[t("div",null,[t("div",Pe,[rt(n(E.value.rxRate.value)+" ",1),E.value.confidence==="low"?(r(),l("span",Ae)):k("",!0)]),t("div",De,n(E.value.rxRate.label),1)]),t("div",null,[t("div",Me,[rt(n(E.value.txRate.value)+" ",1),E.value.confidence==="low"?(r(),l("span",Ne)):k("",!0)]),t("div",Be,n(E.value.txRate.label),1)]),t("div",null,[t("div",Fe,n(pt(_).packetStats?.dropped_packets||D.value.dropped),1),d[2]||(d[2]=t("div",{class:"text-xs text-white/60"},"Dropped",-1))])])]))}}),je=mt(Ee,[["__scopeId","data-v-6bf3fe96"]]),Ie={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] shadow-2xl border border-stroke-subtle dark:border-white/20 flex flex-col h-full overflow-hidden"},Le={class:"flex items-center justify-between p-8 pb-4 flex-shrink-0"},Ue={class:"text-content-secondary dark:text-content-muted text-sm"},Ve={class:"flex items-center gap-2"},He=["title"],ze={class:"flex-1 overflow-y-auto custom-scrollbar px-8"},Xe={class:"mb-6"},Ge={class:"glass-card bg-white/5 rounded-[15px] p-4"},Oe={class:"grid grid-cols-1 md:grid-cols-2 gap-4"},We={class:"space-y-3"},Qe={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},qe={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Ke={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Je={class:"text-content-primary dark:text-content-primary font-mono text-xs break-all"},Ye={key:0,class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Ze={class:"text-content-primary dark:text-content-primary font-mono text-xs"},ts={class:"space-y-3"},es={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},ss={class:"text-content-primary dark:text-content-primary font-semibold"},as={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},ns={class:"text-content-primary dark:text-content-primary font-semibold"},os={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},rs={class:"mb-6"},ls={class:"bg-gray-50 dark:bg-white/5 rounded-[15px] p-4 border border-stroke-subtle dark:border-stroke/10"},is={class:"space-y-3"},ds={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},cs={class:"text-content-primary dark:text-content-primary"},us={key:0,class:"pt-2"},ps={class:"glass-card bg-background-mute dark:bg-black/30 rounded-[10px] p-4 mb-4"},ms={class:"w-full overflow-x-auto"},xs={class:"text-content-primary dark:text-content-primary/90 text-xs font-mono whitespace-pre leading-relaxed min-w-full"},ys={class:"flex items-center justify-between mb-3"},bs={class:"text-content-secondary dark:text-content-primary/80 text-sm font-semibold"},vs={class:"text-content-muted dark:text-content-muted text-xs"},gs={class:"bg-background-mute dark:bg-black/40 rounded-[8px] p-3 mb-3"},hs={class:"font-mono text-xs text-content-primary dark:text-content-primary break-all whitespace-pre-wrap leading-relaxed"},fs={class:"bg-gray-50 dark:bg-white/5 rounded-[10px] overflow-hidden"},ks={key:0,class:"min-w-0"},_s={class:"text-cyan-500 text-sm font-mono break-words min-w-0"},ws={class:"text-content-primary dark:text-content-primary text-sm break-words min-w-0"},$s={class:"text-content-primary dark:text-content-primary text-sm font-semibold break-all min-w-0 overflow-hidden"},Ts=["title"],Cs={key:0,class:"text-orange-500 text-xs font-mono break-all min-w-0 overflow-hidden"},Ss=["title"],Rs={class:"grid grid-cols-2 gap-2"},Ps={class:"text-cyan-500 text-sm font-mono break-words"},As={class:"text-content-primary dark:text-content-primary text-sm break-words"},Ds=["title"],Ms={key:0},Ns=["title"],Bs={key:0,class:"text-content-muted dark:text-content-muted text-xs italic mt-2 px-1"},Fs={key:1,class:"py-2"},Es={class:"mb-6"},js={class:"bg-gray-50 dark:bg-white/5 rounded-[15px] p-4 border border-stroke-subtle dark:border-stroke/10"},Is={class:"space-y-4"},Ls={class:"grid grid-cols-1 md:grid-cols-2 gap-4"},Us={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Vs={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Hs={key:0,class:"py-2"},zs={class:"bg-background-mute dark:bg-black/20 rounded-[10px] p-4"},Xs={class:"flex items-center flex-wrap gap-2"},Gs={class:"relative group"},Os={class:"relative px-3 py-2 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 border border-cyan-400/40 rounded-lg transform transition-all hover:scale-105"},Ws={class:"font-mono text-xs font-semibold text-content-primary dark:text-content-primary/90"},Qs={class:"absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-content-primary dark:bg-background/90 text-white dark:text-content-primary text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10"},qs={key:0,class:"mx-2 text-cyan-600 dark:text-cyan-400/60"},Ks={key:1,class:"py-2"},Js={class:"text-content-secondary dark:text-content-muted text-sm mb-2 flex items-center"},Ys={key:0,class:"w-4 h-4 ml-2 text-yellow-500",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Zs={key:1,class:"text-yellow-500 text-xs ml-1"},ta={class:"bg-background-mute dark:bg-black/20 rounded-[10px] p-4"},ea={class:"flex items-center flex-wrap gap-2"},sa={class:"relative group"},aa={key:0,class:"absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full animate-pulse"},na={class:"absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-content-primary dark:bg-background/90 text-white dark:text-content-primary text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10"},oa={key:0,class:"mx-1 text-orange-600 dark:text-orange-400/60"},ra={class:"mb-6"},la={class:"glass-card bg-gray-50 dark:bg-white/5 rounded-[15px] p-4"},ia={class:"grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"},da={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},ca={class:"text-lg font-bold text-content-primary dark:text-content-primary"},ua={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},pa={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},ma={class:"text-lg font-bold text-content-primary dark:text-content-primary"},xa={key:0,class:"mb-4"},ya={class:"flex items-center gap-3"},ba={class:"flex gap-1"},va={class:"text-content-secondary dark:text-content-primary/80 text-sm capitalize"},ga={key:1,class:"mb-4"},ha={key:2,class:"mb-4"},fa={class:"text-content-secondary dark:text-content-muted text-sm mb-3"},ka={class:"space-y-2"},_a={class:"flex items-center gap-3"},wa={class:"text-content-muted dark:text-content-muted text-sm"},$a={key:3,class:"mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke/10"},Ta={class:"grid grid-cols-1 md:grid-cols-3 gap-3 mb-4"},Ca={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},Sa={class:"text-2xl font-bold text-content-primary dark:text-content-primary"},Ra={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},Pa={class:"text-2xl font-bold text-content-primary dark:text-content-primary"},Aa={class:"text-content-muted dark:text-content-muted text-xs mt-1"},Da={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},Ma={class:"text-content-muted dark:text-content-muted text-xs mt-1"},Na={key:0,class:"glass-card bg-background-mute dark:bg-black/20 rounded-[10px] p-4"},Ba={class:"space-y-3"},Fa={class:"flex-shrink-0 w-16 text-right"},Ea={class:"text-content-secondary dark:text-content-muted text-xs"},ja={class:"flex-1 relative"},Ia={class:"h-8 rounded-lg overflow-hidden bg-background-mute dark:bg-stroke/5 relative"},La={class:"absolute inset-0 flex items-center px-3"},Ua={class:"text-content-primary dark:text-content-primary text-xs font-mono font-semibold"},Va={class:"flex-shrink-0 w-12 text-left"},Ha={class:"text-content-muted dark:text-content-muted text-xs"},za={class:"grid grid-cols-1 md:grid-cols-2 gap-4"},Xa={class:"space-y-2"},Ga={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Oa={class:"text-content-primary dark:text-content-primary"},Wa={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Qa={class:"space-y-2"},qa={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Ka={key:0,class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Ja={class:"text-red-600 dark:text-red-400 text-sm"},Ya={class:"p-8 pt-4 border-t border-stroke-subtle dark:border-stroke/10 flex justify-end flex-shrink-0"},Za=ct({name:"PacketDetailsModal",__name:"PacketDetailsModal",props:{packet:{},isOpen:{type:Boolean},localHash:{}},emits:["close"],setup(nt,{emit:_}){const{getSignalQuality:q}=Ot(),m=nt,C=_,$=B(!1),A=s=>new Date(s*1e3).toLocaleString(),X=s=>s.transmitted?s.is_duplicate?"text-amber-600 dark:text-amber-400":s.drop_reason?"text-red-600 dark:text-red-400":"text-green-600 dark:text-green-400":"text-red-600 dark:text-red-400",D=s=>s.transmitted?s.is_duplicate?"Duplicate":s.drop_reason?"Dropped":"Forwarded":"Dropped",h=s=>({0:"Request",1:"Response",2:"Plain Text Message",3:"Acknowledgment",4:"Node Advertisement",5:"Group Text Message",6:"Group Datagram",7:"Anonymous Request",8:"Returned Path",9:"Trace",10:"Multi-part Packet",15:"Custom Packet"})[s]||`Unknown Type (${s})`,F=s=>({0:"Transport Flood",1:"Flood",2:"Direct",3:"Transport Direct"})[s]||`Unknown Route (${s})`,O=s=>{if(!s)return"None";const u=s.replace(/\s+/g,"").toUpperCase().match(/.{2}/g)||[],i=[];for(let o=0;o{try{let i=0;const o=e.length/2;if(o>=100){if(e.length>=i+64){const y=e.slice(i,i+64);s.push({name:"Public Key",byteRange:`${(u+i)/2}-${(u+i+63)/2}`,hexData:y.match(/.{8}/g)?.join(" ")||y,description:"Ed25519 public key of the node (32 bytes)",fields:[{bits:"0-255",name:"Ed25519 Public Key",value:`${y.slice(0,16)}...${y.slice(-16)}`,binary:"32 bytes (256 bits)"}]}),i+=64}if(e.length>=i+8){const y=e.slice(i,i+8),f=parseInt(y,16),R=new Date(f*1e3);s.push({name:"Timestamp",byteRange:`${(u+i)/2}-${(u+i+7)/2}`,hexData:y.match(/.{2}/g)?.join(" ")||y,description:"Unix timestamp of advertisement",fields:[{bits:"0-31",name:"Unix Timestamp",value:`${f} (${R.toLocaleString()})`,binary:f.toString(2).padStart(32,"0")}]}),i+=8}if(e.length>=i+128){const y=e.slice(i,i+128);s.push({name:"Signature",byteRange:`${(u+i)/2}-${(u+i+127)/2}`,hexData:y.match(/.{8}/g)?.join(" ")||y,description:"Ed25519 signature of public key, timestamp, and appdata",fields:[{bits:"0-511",name:"Ed25519 Signature",value:`${y.slice(0,16)}...${y.slice(-16)}`,binary:"64 bytes (512 bits)"}]}),i+=128}if(e.length>i){const y=e.slice(i);E(s,y,u+i)}}else s.push({name:"ADVERT AppData (Partial)",byteRange:`${u/2}-${u/2+o-1}`,hexData:e.match(/.{2}/g)?.join(" ")||e,description:`Partial ADVERT data - appears to be just AppData portion (${o} bytes)`,fields:[{bits:`0-${o*8-1}`,name:"Partial Data",value:`${o} bytes - attempting to decode as AppData`,binary:`${o} bytes (${o*8} bits)`}]}),E(s,e,u)}catch(i){s.push({name:"ADVERT Parse Error",byteRange:"N/A",hexData:e.slice(0,32)+"...",description:"Failed to parse ADVERT payload structure",fields:[{bits:"N/A",name:"Error",value:`Parse error: ${i instanceof Error?i.message:"Unknown error"}`,binary:"Invalid"}]})}},E=(s,e,u)=>{try{const i=e.length/2;s.push({name:"AppData",byteRange:`${u/2}-${u/2+i-1}`,hexData:e.match(/.{2}/g)?.join(" ")||e,description:`Node advertisement application data (${i} bytes)`,fields:[{bits:`0-${i*8-1}`,name:"Application Data",value:`${i} bytes (contains flags, location, name, etc.)`,binary:`${i} bytes (${i*8} bits)`}]});let o=0;if(e.length>=2){const y=parseInt(e.slice(o,o+2),16),f=[],R=!!(y&16),j=!!(y&32),U=!!(y&64),at=!!(y&128);if(y&1&&f.push("is chat node"),y&2&&f.push("is repeater"),y&4&&f.push("is room server"),y&8&&f.push("is sensor"),R&&f.push("has location"),j&&f.push("has feature 1"),U&&f.push("has feature 2"),at&&f.push("has name"),s.push({name:"AppData Flags",byteRange:`${(u+o)/2}`,hexData:`0x${e.slice(o,o+2)}`,description:"Flags indicating which optional fields are present",fields:[{bits:"0-7",name:"Flags",value:f.join(", ")||"none",binary:y.toString(2).padStart(8,"0")}]}),o+=2,R&&e.length>=o+16){const I=e.slice(o,o+8),K=[];for(let p=6;p>=0;p-=2)K.push(I.slice(p,p+2));const V=parseInt(K.join(""),16),b=V>2147483647?V-4294967296:V,P=b/1e6,z=e.slice(o+8,o+16),L=[];for(let p=6;p>=0;p-=2)L.push(z.slice(p,p+2));const tt=parseInt(L.join(""),16),J=tt>2147483647?tt-4294967296:tt,lt=J/1e6;s.push({name:"Location Data",byteRange:`${(u+o)/2}-${(u+o+15)/2}`,hexData:`${I.match(/.{2}/g)?.join(" ")||I} ${z.match(/.{2}/g)?.join(" ")||z}`,description:"GPS coordinates (latitude and longitude)",fields:[{bits:"0-31",name:"Latitude",value:`${P.toFixed(6)}° (raw: ${b})`,binary:b.toString(2).padStart(32,"0")},{bits:"32-63",name:"Longitude",value:`${lt.toFixed(6)}° (raw: ${J})`,binary:J.toString(2).padStart(32,"0")}]}),o+=16}if(j&&e.length>=o+4){const I=e.slice(o,o+4),K=parseInt(I,16);s.push({name:"Feature 1",byteRange:`${(u+o)/2}-${(u+o+3)/2}`,hexData:I.match(/.{2}/g)?.join(" ")||I,description:"Reserved feature 1 (2 bytes)",fields:[{bits:"0-15",name:"Feature 1 Value",value:`${K}`,binary:K.toString(2).padStart(16,"0")}]}),o+=4}if(U&&e.length>=o+4){const I=e.slice(o,o+4),K=parseInt(I,16);s.push({name:"Feature 2",byteRange:`${(u+o)/2}-${(u+o+3)/2}`,hexData:I.match(/.{2}/g)?.join(" ")||I,description:"Reserved feature 2 (2 bytes)",fields:[{bits:"0-15",name:"Feature 2 Value",value:`${K}`,binary:K.toString(2).padStart(16,"0")}]}),o+=4}if(at&&e.length>o){const I=e.slice(o),K=I.match(/.{2}/g)||[],V=K.map(b=>{const P=parseInt(b,16);return P>=32&&P<=126?String.fromCharCode(P):"."}).join("").replace(/\.+$/,"");s.push({name:"Node Name",byteRange:`${(u+o)/2}-${(u+e.length-1)/2}`,hexData:I.match(/.{2}/g)?.join(" ")||I,description:`Node name string (${K.length} bytes)`,fields:[{bits:`0-${K.length*8-1}`,name:"Node Name",value:`"${V}"`,binary:`ASCII text (${K.length} bytes)`}]})}}}catch(i){s.push({name:"AppData Parse Error",byteRange:"N/A",hexData:e.slice(0,Math.min(32,e.length)),description:"Failed to parse AppData structure",fields:[{bits:"N/A",name:"Error",value:`Parse error: ${i instanceof Error?i.message:"Unknown error"}`,binary:"Invalid"}]})}},M=s=>{if(!s)return[];if(Array.isArray(s))return s;if(typeof s=="string")try{return JSON.parse(s)}catch{return[]}return[]},S=s=>{const e=[];if(!s)return e;try{const u=s.raw_packet;if(u){const i=u.replace(/\s+/g,"").toUpperCase();let o=0;if(i.length>=2){const y=i.slice(o,o+2),f=parseInt(y,16),R=f&3,j=(f&60)>>2,U=(f&192)>>6,at={0:"Transport Flood",1:"Flood",2:"Direct",3:"Transport Direct"},I={0:"REQ",1:"RESPONSE",2:"TXT_MSG",3:"ACK",4:"ADVERT",5:"GRP_TXT",6:"GRP_DATA",7:"ANON_REQ",8:"PATH",9:"TRACE",10:"MULTIPART",15:"RAW_CUSTOM"};if(e.push({name:"Header",byteRange:"0",hexData:`0x${y}`,description:"Contains routing type, payload type, and payload version",fields:[{bits:"0-1",name:"Route Type",value:at[R]||"Unknown",binary:R.toString(2).padStart(2,"0")},{bits:"2-5",name:"Payload Type",value:I[j]||"Unknown",binary:j.toString(2).padStart(4,"0")},{bits:"6-7",name:"Version",value:U.toString(),binary:U.toString(2).padStart(2,"0")}]}),o+=2,(R===0||R===3)&&i.length>=o+8){const V=i.slice(o,o+8),b=parseInt(V.slice(0,4),16),P=parseInt(V.slice(4,8),16);e.push({name:"Transport Codes",byteRange:"1-4",hexData:`${V.slice(0,4)} ${V.slice(4,8)}`,description:"2x 16-bit transport codes for routing optimization",fields:[{bits:"0-15",name:"Code 1",value:b.toString(),binary:b.toString(2).padStart(16,"0")},{bits:"16-31",name:"Code 2",value:P.toString(),binary:P.toString(2).padStart(16,"0")}]}),o+=8}if(i.length>=o+2){const V=i.slice(o,o+2),b=parseInt(V,16),P=(b>>6)+1,z=b&63,L=z*P;if(e.push({name:"Path Length",byteRange:`${o/2}`,hexData:`0x${V}`,description:`${z} hop${z!==1?"s":""}, ${P}-byte hash${P>1?"es":""} (${L} bytes)`,fields:[{bits:"6-7",name:"Hash Size",value:`${P}-byte`,binary:(b>>6&3).toString(2).padStart(2,"0")},{bits:"0-5",name:"Hop Count",value:`${z}`,binary:(b&63).toString(2).padStart(6,"0")}]}),o+=2,L>0&&i.length>=o+L*2){const tt=i.slice(o,o+L*2),J=new RegExp(`.{${P*2}}`,"g"),lt=tt.match(J)||[];e.push({name:"Path Data",byteRange:`${o/2}-${(o+L*2-2)/2}`,hexData:lt.join(" ")||tt,description:`${z} × ${P}-byte routing hash${z!==1?"es":""}`,fields:lt.map((p,c)=>({bits:`${c*P*8}-${(c+1)*P*8-1}`,name:`Hop ${c+1}`,value:p.toUpperCase(),binary:`${P} byte${P>1?"s":""}`}))}),o+=L*2}}if(i.length>o){const V=i.slice(o),b=V.length/2;j===4?G(e,V,o):e.push({name:"Payload Data",byteRange:`${o/2}-${o/2+b-1}`,hexData:V.match(/.{2}/g)?.join(" ")||V,description:"Application data content",fields:[{bits:`0-${b*8-1}`,name:"Application Data",value:`${b} bytes`,binary:`${b} bytes (${b*8} bits)`}]})}}}else{if(s.header){const i=s.header.replace(/0x/gi,"").replace(/\s+/g,"").toUpperCase(),o=parseInt(i,16),y=o&3,f=(o&60)>>2,R=(o&192)>>6,j={0:"Transport Flood",1:"Flood",2:"Direct",3:"Transport Direct"},U={0:"REQ",1:"RESPONSE",2:"TXT_MSG",3:"ACK",4:"ADVERT",5:"GRP_TXT",6:"GRP_DATA",7:"ANON_REQ",8:"PATH",9:"TRACE",10:"MULTIPART",15:"RAW_CUSTOM"};e.push({name:"Header",byteRange:"0",hexData:`0x${i}`,description:"Contains routing type, payload type, and payload version",fields:[{bits:"0-1",name:"Route Type",value:j[y]||"Unknown",binary:y.toString(2).padStart(2,"0")},{bits:"2-5",name:"Payload Type",value:U[f]||"Unknown",binary:f.toString(2).padStart(4,"0")},{bits:"6-7",name:"Version",value:R.toString(),binary:R.toString(2).padStart(2,"0")}]}),s.transport_codes&&e.push({name:"Transport Codes",byteRange:"1-4",hexData:s.transport_codes,description:"2x 16-bit transport codes for routing optimization",fields:[{bits:"0-31",name:"Transport Codes",value:s.transport_codes,binary:"Available in separate field"}]}),s.original_path&&s.original_path.length>0&&e.push({name:"Original Path",byteRange:"?",hexData:s.original_path.join(" "),description:`Original routing path (${s.original_path.length} nodes)`,fields:[{bits:"0-?",name:"Path Nodes",value:`${s.original_path.length} nodes`,binary:"Available as node list"}]}),s.forwarded_path&&s.forwarded_path.length>0&&e.push({name:"Forwarded Path",byteRange:"?",hexData:s.forwarded_path.join(" "),description:`Forwarded routing path (${s.forwarded_path.length} nodes)`,fields:[{bits:"0-?",name:"Path Nodes",value:`${s.forwarded_path.length} nodes`,binary:"Available as node list"}]})}if(s.payload){const i=s.payload.replace(/\s+/g,"").toUpperCase(),o=i.length/2;s.type===4?G(e,i,0):e.push({name:"Payload Data",byteRange:`0-${o-1}`,hexData:i.match(/.{2}/g)?.join(" ")||i,description:`Application data content (${o} bytes)`,fields:[{bits:`0-${o*8-1}`,name:"Application Data",value:`${o} bytes`,binary:`${o} bytes (${o*8} bits)`}]})}}}catch{e.push({name:"Parse Error",byteRange:"N/A",hexData:"Error",description:"Unable to parse packet structure",fields:[{bits:"N/A",name:"Error",value:"Parse failed",binary:"Invalid"}]})}return e},x=(s,e)=>s==null||e==null?"text-content-muted dark:text-content-muted":q(e).color,d=s=>{if(s==null)return{level:0,className:"signal-none"};const e=q(s);let u,i;return e.bars>=5?(u=4,i="signal-excellent"):e.bars>=4?(u=3,i="signal-good"):e.bars>=2?(u=2,i="signal-fair"):e.bars>=1?(u=1,i="signal-poor"):(u=0,i="signal-none"),{level:u,className:i}},v=s=>{if(!s)return[];try{const e=JSON.parse(s);return Array.isArray(e)?e:[]}catch{return[]}},g=s=>s>=1e3?`${(s/1e3).toFixed(2)}s`:`${Math.round(s)}ms`,N=s=>{s.key==="Escape"&&C("close")},w=s=>{s.target===s.currentTarget&&C("close")};return et(()=>m.isOpen,s=>{s?document.body.style.overflow="hidden":document.body.style.overflow=""},{immediate:!0}),(s,e)=>(r(),Vt(Xt,{to:"body"},[st(Ht,{name:"modal",appear:""},{default:zt(()=>[s.isOpen&&s.packet?(r(),l("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 overflow-hidden",onClick:w,onKeydown:N,tabindex:"0"},[e[51]||(e[51]=t("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-md pointer-events-none"},null,-1)),t("div",{class:"relative w-full max-w-4xl max-h-[90vh] flex flex-col",onClick:e[3]||(e[3]=Gt(()=>{},["stop"]))},[t("div",Ie,[t("div",Le,[t("div",null,[e[4]||(e[4]=t("h2",{class:"text-2xl font-bold text-content-primary dark:text-content-primary mb-1"},"Packet Details",-1)),t("p",Ue,n(h(s.packet.type))+" - "+n(F(s.packet.route)),1)]),t("div",Ve,[t("button",{onClick:e[0]||(e[0]=u=>$.value=!$.value),class:T(["flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all duration-200",$.value?"bg-cyan-500/20 border border-cyan-400/30 text-cyan-600 dark:text-cyan-400":"bg-background-mute dark:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-secondary dark:text-content-muted"]),title:$.value?"Hide binary values":"Show binary values"},e[5]||(e[5]=[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"})],-1),t("span",{class:"text-xs font-medium"},"Binary",-1)]),10,He),t("button",{onClick:e[1]||(e[1]=u=>C("close")),class:"w-8 h-8 flex items-center justify-center rounded-full bg-background-mute dark:bg-white/10 hover:bg-stroke-subtle dark:hover:bg-white/20 transition-colors duration-200 text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary"},e[6]||(e[6]=[t("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))])]),t("div",ze,[t("div",Xe,[e[13]||(e[13]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4 flex items-center"},[t("div",{class:"w-2 h-2 rounded-full bg-cyan-400 mr-3"}),rt(" Basic Information ")],-1)),t("div",Ge,[t("div",Oe,[t("div",We,[t("div",Qe,[e[7]||(e[7]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Timestamp",-1)),t("span",qe,n(A(s.packet.timestamp)),1)]),t("div",Ke,[e[8]||(e[8]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Packet Hash",-1)),t("span",Je,n(s.packet.packet_hash),1)]),s.packet.header?(r(),l("div",Ye,[e[9]||(e[9]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Header",-1)),t("span",Ze,n(s.packet.header),1)])):k("",!0)]),t("div",ts,[t("div",es,[e[10]||(e[10]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Type",-1)),t("span",ss,n(s.packet.type)+" ("+n(h(s.packet.type))+")",1)]),t("div",as,[e[11]||(e[11]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Route",-1)),t("span",ns,n(s.packet.route)+" ("+n(F(s.packet.route))+")",1)]),t("div",os,[e[12]||(e[12]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Status",-1)),t("span",{class:T(["font-semibold",X(s.packet)])},n(D(s.packet)),3)])])])])]),t("div",rs,[e[25]||(e[25]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4 flex items-center"},[t("div",{class:"w-2 h-2 rounded-full bg-orange-400 mr-3"}),rt(" Payload Data ")],-1)),t("div",ls,[t("div",is,[t("div",ds,[e[14]||(e[14]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Payload Length",-1)),t("span",cs,n(s.packet.payload_length||s.packet.length)+" bytes",1)]),s.packet.payload?(r(),l("div",us,[e[23]||(e[23]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-3"},"Payload Analysis",-1)),t("div",ps,[e[15]||(e[15]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-2 font-semibold"},"Raw Hex Data",-1)),t("div",ms,[t("pre",xs,n(O(s.packet.payload)),1)])]),(r(!0),l(H,null,Q(S(s.packet).filter(u=>!u.name.includes("Parse Error")),(u,i)=>(r(),l("div",{key:i,class:"mb-4"},[t("div",ys,[t("h4",bs,n(u.name),1),t("span",vs,"Bytes "+n(u.byteRange),1)]),t("div",gs,[t("div",hs,n(u.hexData),1)]),t("div",fs,[t("div",{class:T(["hidden md:grid gap-3 p-3 bg-background-mute dark:bg-white/10 text-content-secondary dark:text-content-muted text-xs font-semibold uppercase tracking-wide",$.value?"grid-cols-4":"grid-cols-3"])},[e[16]||(e[16]=t("div",{class:"min-w-0"},"Bits",-1)),e[17]||(e[17]=t("div",{class:"min-w-0"},"Field",-1)),e[18]||(e[18]=t("div",{class:"min-w-0"},"Value",-1)),$.value?(r(),l("div",ks,"Binary")):k("",!0)],2),(r(!0),l(H,null,Q(u.fields,(o,y)=>(r(),l("div",{key:y,class:T(["hidden md:grid gap-3 p-3 border-b border-stroke-subtle dark:border-stroke/5 last:border-b-0 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors",$.value?"grid-cols-4":"grid-cols-3"])},[t("div",_s,n(o.bits),1),t("div",ws,n(o.name),1),t("div",$s,[t("span",{class:"block",title:o.value},n(o.value),9,Ts)]),$.value?(r(),l("div",Cs,[t("span",{class:"block",title:o.binary},n(o.binary),9,Ss)])):k("",!0)],2))),128)),(r(!0),l(H,null,Q(u.fields,(o,y)=>(r(),l("div",{key:`mobile-${y}`,class:"md:hidden p-3 border-b border-stroke-subtle dark:border-stroke/5 last:border-b-0 space-y-2"},[t("div",Rs,[t("div",null,[e[19]||(e[19]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide"},"Bits:",-1)),t("div",Ps,n(o.bits),1)]),t("div",null,[e[20]||(e[20]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide"},"Field:",-1)),t("div",As,n(o.name),1)])]),t("div",null,[e[21]||(e[21]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide"},"Value:",-1)),t("div",{class:"text-content-primary dark:text-content-primary text-sm font-semibold break-all",title:o.value},n(o.value),9,Ds)]),$.value?(r(),l("div",Ms,[e[22]||(e[22]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide"},"Binary:",-1)),t("div",{class:"text-orange-500 text-xs font-mono break-all",title:o.binary},n(o.binary),9,Ns)])):k("",!0)]))),128))]),u.description?(r(),l("div",Bs,n(u.description),1)):k("",!0)]))),128))])):(r(),l("div",Fs,e[24]||(e[24]=[t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Payload:",-1),t("span",{class:"text-content-muted dark:text-content-muted ml-2"},"None",-1)])))])])]),t("div",Es,[e[33]||(e[33]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4 flex items-center"},[t("div",{class:"w-2 h-2 rounded-full bg-purple-400 mr-3"}),rt(" Path Information ")],-1)),t("div",js,[t("div",Is,[t("div",Ls,[t("div",Us,[e[26]||(e[26]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Source Hash",-1)),t("span",{class:T(["text-content-primary dark:text-content-primary font-mono text-xs",m.localHash&&s.packet.src_hash===m.localHash?"bg-cyan-400/20 text-cyan-600 dark:text-cyan-300 px-1 rounded":""])},n(s.packet.src_hash||"Unknown"),3)]),t("div",Vs,[e[27]||(e[27]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Destination Hash",-1)),t("span",{class:T(["text-content-primary dark:text-content-primary font-mono text-xs",m.localHash&&s.packet.dst_hash===m.localHash?"bg-cyan-400/20 text-cyan-600 dark:text-cyan-300 px-1 rounded":""])},n(s.packet.dst_hash||"Broadcast"),3)])]),M(s.packet.original_path).length>0?(r(),l("div",Hs,[e[29]||(e[29]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-2"},"Original Path",-1)),t("div",zs,[t("div",Xs,[(r(!0),l(H,null,Q(M(s.packet.original_path),(u,i)=>(r(),l("div",{key:i,class:"flex items-center"},[t("div",Gs,[t("div",Os,[t("div",Ws,n(u.toUpperCase()),1)]),t("div",Qs," Node: "+n(u.toUpperCase()),1)]),i0?(r(),l("div",Ks,[t("div",Js,[e[31]||(e[31]=rt(" Forwarded Path ",-1)),JSON.stringify(M(s.packet.original_path))!==JSON.stringify(M(s.packet.forwarded_path))?(r(),l("svg",Ys,e[30]||(e[30]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)]))):k("",!0),JSON.stringify(M(s.packet.original_path))!==JSON.stringify(M(s.packet.forwarded_path))?(r(),l("span",Zs,"(Modified)")):k("",!0)]),t("div",ta,[t("div",ea,[(r(!0),l(H,null,Q(M(s.packet.forwarded_path),(u,i)=>(r(),l("div",{key:i,class:"flex items-center"},[t("div",sa,[t("div",{class:T(["relative px-3 py-2 bg-gradient-to-br from-orange-500/20 to-yellow-500/20 border border-orange-500 dark:border-orange-400/40 rounded-lg transform transition-all hover:scale-105",m.localHash&&u===m.localHash?"bg-gradient-to-br from-yellow-400/30 to-orange-400/30 border-yellow-300 shadow-yellow-400/20 shadow-lg":"hover:border-orange-500 dark:border-orange-400/60"])},[t("div",{class:T(["font-mono text-xs font-semibold",m.localHash&&u===m.localHash?"text-yellow-200":"text-white/90"])},n(u.toUpperCase()),3),m.localHash&&u===m.localHash?(r(),l("div",aa)):k("",!0)],2),t("div",na,n(u),1)]),it("div",{key:u,class:T(["w-2 h-6 rounded-sm transition-all duration-300",u<=d(s.packet.rssi).level?{"signal-excellent":"bg-green-400","signal-good":"bg-cyan-400","signal-fair":"bg-yellow-400","signal-poor":"bg-red-400"}[d(s.packet.rssi).className]:"bg-stroke-subtle dark:bg-stroke/10"])},null,2)),64))]),t("span",va,n(d(s.packet.rssi).className.replace("signal-","")),1)])])):(r(),l("div",ga,e[40]||(e[40]=[t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-2"},"Signal Quality",-1),t("div",{class:"text-content-muted dark:text-content-muted text-sm italic"},"N/A (TX Packet)",-1)]))),s.packet.is_trace&&s.packet.path_snr_details&&s.packet.path_snr_details.length>0?(r(),l("div",ha,[t("div",fa,"Path SNR Details ("+n(s.packet.path_snr_details.length)+" hops)",1),t("div",ka,[(r(!0),l(H,null,Q(s.packet.path_snr_details,(u,i)=>(r(),l("div",{key:i,class:"flex items-center justify-between p-2 glass-card bg-background-mute dark:bg-black/20 rounded-[8px]"},[t("div",_a,[t("span",wa,n(i+1)+".",1),t("span",{class:T(["font-mono text-xs text-content-primary dark:text-content-primary",m.localHash&&u.hash===m.localHash?"bg-cyan-400/20 text-cyan-600 dark:text-cyan-300 px-1 rounded":""])},n(u.hash),3)]),t("span",{class:T(["text-sm font-bold",x(u.snr_db,null)])},n(u.snr_db.toFixed(1))+"dB ",3)]))),128))])])):k("",!0),s.packet.transmitted&&s.packet.lbt_attempts!==void 0?(r(),l("div",$a,[e[45]||(e[45]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-3 flex items-center"},[t("svg",{class:"w-4 h-4 mr-2",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"})]),rt(" Listen Before Talk (LBT) Metrics ")],-1)),t("div",Ta,[t("div",Ca,[e[41]||(e[41]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"CAD Attempts",-1)),t("div",Sa,n(s.packet.lbt_attempts),1)]),t("div",Ra,[e[42]||(e[42]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Total LBT Delay",-1)),t("div",Pa,n(g(v(s.packet.lbt_backoff_delays_ms).reduce((u,i)=>u+i,0))),1),t("div",Aa,n(v(s.packet.lbt_backoff_delays_ms).length)+" backoffs ",1)]),t("div",Da,[e[43]||(e[43]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Channel Status",-1)),t("div",{class:T(["text-lg font-bold",s.packet.lbt_channel_busy?"text-yellow-600 dark:text-yellow-400":"text-green-600 dark:text-green-400"])},n(s.packet.lbt_channel_busy?"BUSY":"CLEAR"),3),t("div",Ma,n(s.packet.lbt_channel_busy?"Waited for clear":"Immediate TX"),1)])]),v(s.packet.lbt_backoff_delays_ms).length>0?(r(),l("div",Na,[e[44]||(e[44]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-3 font-semibold"},"Backoff Pattern (Exponential with Jitter)",-1)),t("div",Ba,[(r(!0),l(H,null,Q(v(s.packet.lbt_backoff_delays_ms),(u,i)=>(r(),l("div",{key:i,class:"flex items-center gap-3"},[t("div",Fa,[t("span",Ea,"Attempt "+n(i+1),1)]),t("div",ja,[t("div",Ia,[t("div",{class:T(["h-full rounded-lg transition-all duration-300",[i===0?"bg-gradient-to-r from-cyan-500/50 to-cyan-600/50":i===1?"bg-gradient-to-r from-yellow-500/50 to-yellow-600/50":i===2?"bg-gradient-to-r from-orange-500/50 to-orange-600/50":"bg-gradient-to-r from-red-500/50 to-red-600/50"]]),style:ut({width:`${Math.min(100,u/Math.max(...v(s.packet.lbt_backoff_delays_ms))*100)}%`})},[t("div",La,[t("span",Ua,n(g(u)),1)])],6)])]),t("div",Va,[t("span",Ha,n(Math.round(u/v(s.packet.lbt_backoff_delays_ms).reduce((o,y)=>o+y,0)*100))+"% ",1)])]))),128))])])):k("",!0)])):k("",!0),t("div",za,[t("div",Xa,[t("div",Ga,[e[46]||(e[46]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"TX Delay",-1)),t("span",Oa,n(Number(s.packet.tx_delay_ms)>0?Number(s.packet.tx_delay_ms).toFixed(1)+"ms":"-"),1)]),t("div",Wa,[e[47]||(e[47]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Transmitted",-1)),t("span",{class:T(s.packet.transmitted?"text-green-600 dark:text-green-400":"text-red-600 dark:text-red-400")},n(s.packet.transmitted?"Yes":"No"),3)])]),t("div",Qa,[t("div",qa,[e[48]||(e[48]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Is Duplicate",-1)),t("span",{class:T(s.packet.is_duplicate?"text-amber-600 dark:text-amber-400":"text-content-muted dark:text-content-muted")},n(s.packet.is_duplicate?"Yes":"No"),3)]),s.packet.drop_reason?(r(),l("div",Ka,[e[49]||(e[49]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Drop Reason",-1)),t("span",Ja,n(s.packet.drop_reason),1)])):k("",!0)])])])])]),t("div",Ya,[t("button",{onClick:e[2]||(e[2]=u=>C("close")),class:"px-6 py-2 bg-gradient-to-r from-cyan-500/20 to-cyan-400/20 hover:from-cyan-500/30 hover:to-cyan-400/30 border border-cyan-400/30 rounded-[10px] text-content-primary dark:text-content-primary transition-all duration-200 backdrop-blur-sm"}," Close ")])])])],32)):k("",!0)]),_:1})]))}}),tn=mt(Za,[["__scopeId","data-v-86263c2b"]]),en={class:"glass-card rounded-[20px] p-6"},sn={class:"flex flex-col lg:flex-row lg:justify-between lg:items-center mb-6 gap-4 filter-container"},an={class:"flex items-center gap-2 header-info relative"},nn={class:"text-content-secondary dark:text-content-muted text-sm packet-count"},on=["title"],rn={class:"hidden sm:inline"},ln={key:1,class:"text-accent-red text-sm error-indicator"},dn={class:"flex items-center gap-3 lg:flex filter-controls"},cn={class:"flex flex-col"},un=["value"],pn={class:"flex flex-col"},mn=["value"],xn={class:"flex flex-col"},yn={class:"flex flex-col reset-container"},bn=["disabled"],vn={class:"space-y-4 overflow-hidden"},gn={class:"space-y-4"},hn=["onClick"],fn={class:"hidden lg:grid grid-cols-12 gap-2 items-center"},kn={class:"col-span-1 text-content-primary dark:text-content-primary text-sm"},_n={class:"col-span-1 flex items-center gap-2"},wn={class:"flex flex-col"},$n={class:"text-content-primary dark:text-content-primary text-xs"},Tn=["title"],Cn={class:"col-span-2"},Sn={class:"col-span-1 text-content-primary dark:text-content-primary text-xs"},Rn={class:"col-span-2"},Pn={class:"space-y-1"},An={key:0,class:"flex items-center gap-0.5 flex-wrap"},Dn={key:0,class:"w-2.5 h-2.5 text-content-muted dark:text-content-muted/60",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Mn={key:0,class:"text-[9px] text-content-muted dark:text-content-muted ml-1"},Nn={key:1,class:"flex items-center gap-1"},Bn={class:"inline-block px-2 py-0.5 rounded bg-badge-cyan-bg text-badge-cyan-text text-xs font-mono"},Fn={class:"col-span-1 text-content-primary dark:text-content-primary text-xs"},En={class:"col-span-1 text-content-primary dark:text-content-primary text-xs"},jn={class:"col-span-1 text-content-primary dark:text-content-primary text-xs"},In={class:"col-span-1 text-content-primary dark:text-content-primary text-xs"},Ln={key:0,class:"flex items-center gap-1"},Un={class:"col-span-1"},Vn={key:0,class:"text-accent-red text-[8px] italic truncate"},Hn={class:"lg:hidden space-y-2"},zn={class:"flex items-center justify-between"},Xn={class:"flex items-center gap-2"},Gn={class:"flex flex-col"},On={class:"text-content-primary dark:text-content-primary text-sm font-medium"},Wn=["title"],Qn={class:"flex items-center gap-2 text-right"},qn={class:"text-content-secondary dark:text-content-muted text-xs"},Kn={class:"flex items-center justify-between"},Jn={class:"flex items-center gap-1.5"},Yn={key:0,class:"flex items-center gap-0.5"},Zn={key:0,class:"w-2.5 h-2.5 text-content-muted dark:text-content-muted/60",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},to={key:0,class:"text-[9px] text-content-muted dark:text-content-muted ml-1"},eo={class:"flex items-center gap-1"},so={class:"inline-block px-2 py-0.5 rounded bg-badge-cyan-bg text-badge-cyan-text text-xs font-mono font-semibold"},ao={class:"flex items-center gap-0.5 text-content-muted dark:text-content-muted/60"},no={key:0,class:"text-[9px] font-medium",title:"Multi-hop path"},oo={class:"flex items-center gap-1"},ro={class:"flex items-center gap-2"},lo={class:"flex items-center gap-1"},io={key:0,class:"flex gap-0.5"},co={class:"text-content-primary dark:text-content-primary text-xs"},uo={class:"flex items-center justify-between text-content-secondary dark:text-content-muted text-xs"},po={class:"flex items-center gap-3"},mo={class:"flex items-center gap-2"},xo={key:0,class:"flex items-center gap-1"},yo={key:0,class:"text-accent-red text-xs italic"},bo={key:0,class:"flex justify-between items-center mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke pagination-container"},vo={class:"flex items-center gap-4 pagination-info"},go={class:"text-content-secondary dark:text-content-muted text-sm"},ho={key:0,class:"flex items-center gap-2 load-more-section"},fo=["disabled"],ko={class:"text-content-secondary dark:text-content-muted text-xs load-more-count"},_o={class:"flex items-center gap-2 pagination-controls"},wo=["disabled"],$o={class:"flex items-center gap-1 page-numbers"},To={key:1,class:"text-content-secondary dark:text-content-muted text-sm px-2 ellipsis"},Co=["onClick"],So={key:2,class:"text-content-secondary dark:text-content-muted text-sm px-2 ellipsis"},Ro=["disabled"],Po={key:1,class:"flex justify-center mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke"},Ao={class:"flex items-center gap-4"},Do={class:"text-content-secondary dark:text-content-muted text-sm"},Mo={class:"text-content-secondary dark:text-content-muted text-xs"},No={key:2,class:"flex justify-center mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke"},yt=10,it=1e3,Bo=ct({name:"PacketTable",__name:"PacketTable",setup(nt){const _=gt(),q=kt(),m=B(1),C=B(null),$=B(100),A=B(!1),X=B(!1);let D=null;et(()=>_.isLoading,p=>{p?(D&&(clearTimeout(D),D=null),X.value=!0):D=window.setTimeout(()=>{X.value=!1,D=null},600)});const h=B(null),F=B(!1),O=p=>{h.value=p,F.value=!0},G=()=>{F.value=!1,h.value=null},E=B(Ct("packetTable_selectedType","all")),M=B(Ct("packetTable_selectedRoute","all")),S=B(!1),x=B(null),d=["all","0","1","2","3","4","5","6","7","8","9","10","11"],v=["all","1","2"];et(E,p=>{St("packetTable_selectedType",p),m.value=1}),et(M,p=>{St("packetTable_selectedRoute",p),m.value=1}),et(S,()=>{m.value=1});const g=Y(()=>{let p=_.recentPackets;if(E.value!=="all"){const c=parseInt(E.value);p=p.filter(a=>a.type===c)}if(M.value!=="all"){const c=parseInt(M.value);p=p.filter(a=>a.route===c)}return S.value&&x.value!==null&&(p=p.filter(c=>c.timestamp>=x.value)),p}),N=Y(()=>{const p=(m.value-1)*yt,c=p+yt;return g.value.slice(p,c)}),w=Y(()=>Math.ceil(g.value.length/yt)),s=Y(()=>m.value===w.value),e=Y(()=>_.recentPackets.length>=$.value&&$.values.value&&e.value&&!A.value),i=p=>new Date(p*1e3).toLocaleTimeString(void 0,{hour12:!0}),o=p=>({0:"REQ",1:"RESPONSE",2:"TXT_MSG",3:"ACK",4:"ADVERT",5:"GRP_TXT",6:"GRP_DATA",7:"ANON_REQ",8:"PATH",9:"TRACE",10:"MULTI_PART",11:"CONTROL"})[p]||`TYPE_${p}`,y=p=>({0:"T-Flood",1:"Flood",2:"Direct",3:"T-Direct"})[p]||`Route ${p}`,f=p=>p.transmitted?"text-accent-green":"text-primary",R=p=>p.drop_reason?"Dropped":p.transmitted?"Forward":"Received",j=p=>p===1?"bg-badge-cyan-bg text-badge-cyan-text":"bg-badge-neutral-bg text-badge-neutral-text",U=p=>({0:"bg-primary",1:"bg-accent-green",2:"bg-secondary",3:"bg-accent-purple",4:"bg-accent-red",5:"bg-accent-cyan",6:"bg-primary",7:"bg-accent-purple",8:"bg-accent-green",9:"bg-secondary"})[p]||"bg-gray-500",at=p=>({0:"border-l-primary",1:"border-l-accent-green",2:"border-l-secondary",3:"border-l-accent-purple",4:"border-l-accent-red",5:"border-l-accent-cyan",6:"border-l-primary",7:"border-l-accent-purple",8:"border-l-accent-green",9:"border-l-secondary"})[p]||"border-l-gray-500",I=p=>!p.transmitted||!p.lbt_attempts||p.lbt_attempts===0?"bg-green-400":p.lbt_attempts===1?"bg-cyan-400":p.lbt_attempts===2?"bg-yellow-400":"bg-orange-400",K=p=>p>=1e3?(p/1e3).toFixed(2)+"s":p.toFixed(1)+"ms",V=p=>{if(!p)return[];if(Array.isArray(p))return p;if(typeof p=="string")try{const c=JSON.parse(p);return typeof c=="string"?JSON.parse(c):Array.isArray(c)?c:[]}catch{return[]}return[]},b=p=>{const c=V(p.original_path),a=V(p.forwarded_path),W=c.length>0?c:a;return W.length===0?null:{hops:W.length-1,nodes:W.map(ot=>ot.slice(-4).toUpperCase())}},P=p=>{if(p.type!==4||!p.payload)return null;try{const c=p.payload.replace(/\s+/g,"").toUpperCase();let a=c,W=0;if(c.length/2>=100)if(c.length>200)a=c.slice(200),W=0;else return null;if(a.length>=2){const Z=parseInt(a.slice(0,2),16);W+=2;const Pt=!!(Z&16),At=!!(Z&32),Dt=!!(Z&64);if(!!!(Z&128))return null;if(Pt&&a.length>=W+16&&(W+=16),At&&a.length>=W+4&&(W+=4),Dt&&a.length>=W+4&&(W+=4),a.length>W){const _t=(a.slice(W).match(/.{2}/g)||[]).map(Mt=>{const ht=parseInt(Mt,16);return ht>=32&&ht<=126?String.fromCharCode(ht):"."}).join("").replace(/\.*$/,"");return _t.length>0?_t:null}}}catch(c){console.error("Error parsing ADVERT node name:",c)}return null},z=()=>{E.value="all",M.value="all",S.value=!1,x.value=null,m.value=1},L=()=>{S.value?(S.value=!1,x.value=null):(S.value=!0,x.value=Date.now()/1e3),m.value=1},tt=Y(()=>x.value?new Date(x.value*1e3).toLocaleTimeString(void 0,{hour12:!0}):""),J=async p=>{try{const c=p||$.value;await _.fetchRecentPackets({limit:c})}catch(c){console.error("Error fetching packet data:",c)}},lt=async()=>{if(!(A.value||$.value>=it)){A.value=!0;try{const p=Math.min($.value+200,it);$.value=p,await J(p)}catch(p){console.error("Error loading more records:",p)}finally{A.value=!1}}};return bt(async()=>{await J(),q.isConnected||(C.value=window.setInterval(J,1e4))}),et(()=>q.isConnected,p=>{p?C.value&&(clearInterval(C.value),C.value=null):C.value||(C.value=window.setInterval(J,1e4))}),vt(()=>{C.value&&clearInterval(C.value),D&&clearTimeout(D)}),(p,c)=>(r(),l(H,null,[t("div",en,[t("div",sn,[t("div",an,[c[7]||(c[7]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold"},"Recent Packets",-1)),t("span",nn," ("+n(g.value.length)+" of "+n(pt(_).recentPackets.length)+") ",1),S.value?(r(),l("span",{key:0,class:"text-primary text-xs sm:text-sm bg-primary/10 px-2 py-1 rounded-md border border-primary/20 live-mode-badge whitespace-nowrap",title:`Filter activated at ${tt.value}`},[t("span",rn,"Live Mode (since "+n(tt.value)+")",1),c[6]||(c[6]=t("span",{class:"sm:hidden"},"Live",-1))],8,on)):k("",!0),pt(_).error?(r(),l("span",ln,n(pt(_).error),1)):k("",!0)]),t("div",dn,[t("div",cn,[c[8]||(c[8]=t("label",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Type",-1)),$t(t("select",{"onUpdate:modelValue":c[0]||(c[0]=a=>E.value=a),class:"glass-card border border-stroke-subtle dark:border-stroke rounded-[10px] px-3 py-2 text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all duration-200 min-w-[120px] cursor-pointer hover:border-primary/50"},[(r(),l(H,null,Q(d,a=>t("option",{key:a,value:a,class:"bg-surface dark:bg-surface-elevated text-content-primary dark:text-content-primary"},n(a==="all"?"All Types":`Type ${a} (${o(parseInt(a))})`),9,un)),64))],512),[[Tt,E.value]])]),t("div",pn,[c[9]||(c[9]=t("label",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Route",-1)),$t(t("select",{"onUpdate:modelValue":c[1]||(c[1]=a=>M.value=a),class:"glass-card border border-stroke-subtle dark:border-stroke rounded-[10px] px-3 py-2 text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all duration-200 min-w-[120px] cursor-pointer hover:border-primary/50"},[(r(),l(H,null,Q(v,a=>t("option",{key:a,value:a,class:"bg-surface dark:bg-surface-elevated text-content-primary dark:text-content-primary"},n(a==="all"?"All Routes":`Route ${a} (${y(parseInt(a))})`),9,mn)),64))],512),[[Tt,M.value]])]),t("div",xn,[c[10]||(c[10]=t("label",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Filter",-1)),t("button",{onClick:L,class:T(["glass-card border rounded-[10px] px-4 py-2 text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20 min-w-[120px]",{"border-primary bg-primary/10 text-primary":S.value,"border-stroke-subtle dark:border-stroke text-content-secondary dark:text-content-muted hover:border-primary hover:text-content-primary dark:hover:text-content-primary hover:bg-primary/5":!S.value}])},n(S.value?"New Only":"Show New"),3)]),t("div",yn,[c[11]||(c[11]=t("label",{class:"text-transparent text-xs mb-1"},".",-1)),t("button",{onClick:z,class:T(["glass-card border border-stroke-subtle dark:border-stroke hover:border-primary rounded-[10px] px-4 py-2 text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary text-sm transition-all duration-200 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20",{"opacity-50 cursor-not-allowed hover:border-stroke-subtle dark:hover:border-stroke hover:text-content-secondary dark:hover:text-content-muted":E.value==="all"&&M.value==="all"&&!S.value,"hover:bg-primary/10":E.value!=="all"||M.value!=="all"||S.value}]),disabled:E.value==="all"&&M.value==="all"&&!S.value}," Reset ",10,bn)])])]),c[25]||(c[25]=Rt('',1)),t("div",vn,[t("div",gn,[(r(!0),l(H,null,Q(N.value,(a,W)=>(r(),l("div",{key:`${a.packet_hash}_${a.timestamp}_${W}`,class:T(["packet-row border-b border-stroke-subtle dark:border-dark-border/50 pb-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors duration-150 cursor-pointer rounded-[10px] p-2 border-l-4",at(a.type)]),onClick:ot=>O(a)},[t("div",fn,[t("div",kn,n(i(a.timestamp)),1),t("div",_n,[t("div",{class:T(["w-2 h-2 rounded-full",U(a.type)])},null,2),t("div",wn,[t("span",$n,n(o(a.type)),1),a.type===4&&P(a)?(r(),l("span",{key:0,class:"text-accent-red/70 text-[10px] font-medium max-w-[80px] truncate",title:P(a)||void 0},n(P(a)),9,Tn)):k("",!0)])]),t("div",Cn,[t("span",{class:T(["inline-block px-2 py-1 rounded text-xs font-medium",j(a.route)])},n(y(a.route)),3)]),t("div",Sn,n(a.length)+"B",1),t("div",Rn,[t("div",Pn,[b(a)?(r(),l("div",An,[(r(!0),l(H,null,Q(b(a).nodes,(ot,Z)=>(r(),l(H,{key:Z},[t("span",{class:T(["inline-block px-1.5 py-0.5 rounded text-[10px] font-mono font-semibold",Z===0?"bg-badge-cyan-bg text-badge-cyan-text":"bg-gray-500/20 text-content-muted dark:text-content-muted"])},n(ot),3),Z0?(r(),l("span",Mn," ("+n(b(a).hops)+" hop"+n(b(a).hops>1?"s":"")+") ",1)):k("",!0)])):(r(),l("div",Nn,[t("span",Bn,n(a.src_hash?.slice(-4).toUpperCase()||"????"),1),c[13]||(c[13]=t("svg",{class:"w-3 h-3 text-content-muted dark:text-content-muted/60",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2.5",d:"M9 5l7 7-7 7"})],-1)),t("span",{class:T(["inline-block px-2 py-0.5 rounded text-xs font-mono",a.dst_hash?"bg-badge-cyan-bg text-badge-cyan-text":"bg-yellow-500/20 text-yellow-700 dark:text-yellow-300"])},n(a.dst_hash?a.dst_hash.slice(-4).toUpperCase():"BCAST"),3)]))])]),t("div",Fn,n(a.rssi!=null?a.rssi.toFixed(0):"N/A"),1),t("div",En,n(a.snr!=null?a.snr.toFixed(1)+"dB":"N/A"),1),t("div",jn,n(a.score!=null?a.score.toFixed(2):"N/A"),1),t("div",In,[Number(a.tx_delay_ms)>0?(r(),l("div",Ln,[a.transmitted?(r(),l("div",{key:0,class:T(["w-1.5 h-1.5 rounded-full flex-shrink-0",I(a)])},null,2)):k("",!0),t("span",null,n(K(Number(a.tx_delay_ms))),1)])):k("",!0)]),t("div",Un,[t("div",null,[t("span",{class:T(["text-xs font-medium",f(a)])},n(R(a)),3),a.drop_reason?(r(),l("p",Vn,n(a.drop_reason),1)):k("",!0)])])]),t("div",Hn,[t("div",zn,[t("div",Xn,[t("div",{class:T(["w-2 h-2 rounded-full flex-shrink-0",U(a.type)])},null,2),t("div",Gn,[t("span",On,n(o(a.type)),1),a.type===4&&P(a)?(r(),l("span",{key:0,class:"text-accent-red/70 text-[10px] font-medium leading-tight",title:P(a)||void 0},n(P(a)),9,Wn)):k("",!0)]),t("span",{class:T(["inline-block px-2 py-1 rounded text-xs font-medium ml-2",j(a.route)])},n(y(a.route)),3)]),t("div",Qn,[t("span",qn,n(i(a.timestamp)),1),t("span",{class:T(["text-xs font-medium",f(a)])},n(R(a)),3)])]),t("div",Kn,[t("div",Jn,[b(a)?(r(),l("div",Yn,[c[15]||(c[15]=t("span",{class:"text-content-muted dark:text-content-muted text-[10px] font-medium"},"PATH",-1)),(r(!0),l(H,null,Q(b(a).nodes,(ot,Z)=>(r(),l(H,{key:Z},[t("span",{class:T(["inline-block px-1.5 py-0.5 rounded text-[10px] font-mono font-semibold",Z===0?"bg-badge-cyan-bg text-badge-cyan-text":"bg-gray-500/20 text-content-muted dark:text-content-muted"])},n(ot),3),Z0?(r(),l("span",to," ("+n(b(a).hops)+" hop"+n(b(a).hops>1?"s":"")+") ",1)):k("",!0)])):(r(),l(H,{key:1},[t("div",eo,[c[16]||(c[16]=t("span",{class:"text-content-muted dark:text-content-muted text-[10px] font-medium"},"SRC",-1)),t("span",so,n(a.src_hash?.slice(-4)||"????"),1)]),t("div",ao,[c[18]||(c[18]=t("svg",{class:"w-3 h-3",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2.5",d:"M9 5l7 7-7 7"})],-1)),a.route===1?(r(),l("span",no,c[17]||(c[17]=[t("svg",{class:"w-2.5 h-2.5 inline",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 5l7 7-7 7M5 5l7 7-7 7"})],-1)]))):k("",!0)]),t("div",oo,[t("span",{class:T(["inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold",a.dst_hash?"bg-badge-cyan-bg text-badge-cyan-text":"bg-yellow-500/20 text-yellow-700 dark:text-yellow-300"])},n(a.dst_hash?a.dst_hash.slice(-4).toUpperCase():"BCAST"),3),c[19]||(c[19]=t("span",{class:"text-content-muted dark:text-content-muted text-[10px] font-medium"},"DST",-1))])],64))]),t("div",ro,[t("div",lo,[a.snr!=null?(r(),l("div",io,[t("div",{class:T(["w-1 h-3 rounded-sm",a.snr>=-10?"bg-green-400":"bg-white/20"])},null,2),t("div",{class:T(["w-1 h-4 rounded-sm",a.snr>=-5?"bg-green-400":"bg-white/20"])},null,2),t("div",{class:T(["w-1 h-5 rounded-sm",a.snr>=0?"bg-green-400":"bg-white/20"])},null,2),t("div",{class:T(["w-1 h-6 rounded-sm",a.snr>=10?"bg-green-400":"bg-white/20"])},null,2)])):k("",!0),t("span",co,n(a.rssi!=null?a.rssi.toFixed(0)+"dBm":"TX"),1)])])]),t("div",uo,[t("div",po,[t("span",null,n(a.length)+"B",1),t("span",null,"SNR: "+n(a.snr!=null?a.snr.toFixed(1)+"dB":"N/A"),1),t("span",null,"Score: "+n(a.score!=null?a.score.toFixed(2):"N/A"),1)]),t("div",mo,[Number(a.tx_delay_ms)>0?(r(),l("span",xo,[a.transmitted?(r(),l("div",{key:0,class:T(["w-1.5 h-1.5 rounded-full flex-shrink-0",I(a)])},null,2)):k("",!0),t("span",null,n(K(Number(a.tx_delay_ms))),1)])):k("",!0)])]),a.drop_reason?(r(),l("div",yo,n(a.drop_reason),1)):k("",!0)])],10,hn))),128))])]),w.value>1?(r(),l("div",bo,[t("div",vo,[t("span",go," Showing "+n((m.value-1)*yt+1)+" - "+n(Math.min(m.value*yt,g.value.length))+" of "+n(g.value.length)+" packets ",1),u.value?(r(),l("div",ho,[c[20]||(c[20]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs"},"•",-1)),t("button",{onClick:lt,disabled:A.value,class:T(["glass-card border border-primary rounded-[8px] px-3 py-1.5 text-xs transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20 hover:bg-primary/5",{"text-primary border-primary cursor-pointer":!A.value,"text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke cursor-not-allowed opacity-50":A.value}])},n(A.value?"Loading...":`Load ${Math.min(200,it-$.value)} more`),11,fo),t("span",ko,"("+n($.value)+"/"+n(it)+" max)",1)])):k("",!0)]),t("div",_o,[t("button",{onClick:c[2]||(c[2]=a=>m.value=m.value-1),disabled:m.value<=1,class:T(["glass-card border rounded-[10px] px-3 py-2 text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20 prev-next-btn",{"border-stroke-subtle dark:border-stroke text-content-muted dark:text-content-muted cursor-not-allowed opacity-50":m.value<=1,"border-stroke-subtle dark:border-stroke text-content-primary dark:text-content-primary hover:border-primary hover:text-primary hover:bg-primary/5":m.value>1}])},c[21]||(c[21]=[t("span",{class:"hidden sm:inline"},"Previous",-1),t("span",{class:"sm:hidden"},"‹",-1)]),10,wo),t("div",$o,[m.value>3?(r(),l("button",{key:0,onClick:c[3]||(c[3]=a=>m.value=1),class:"glass-card border border-stroke-subtle dark:border-stroke hover:border-primary rounded-[8px] px-3 py-2 text-sm text-content-primary dark:text-content-primary hover:text-primary hover:bg-primary/5 transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20"}," 1 ")):k("",!0),m.value>4?(r(),l("span",To,"...")):k("",!0),(r(!0),l(H,null,Q(Array.from({length:Math.min(5,w.value)},(a,W)=>Math.max(1,Math.min(m.value-2,w.value-4))+W).filter(a=>a<=w.value),a=>(r(),l("button",{key:a,onClick:W=>m.value=a,class:T(["glass-card border rounded-[8px] px-3 py-2 text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20 page-number",{"border-primary bg-primary/10 text-primary":m.value===a,"border-stroke-subtle dark:border-stroke text-content-primary dark:text-content-primary hover:border-primary hover:text-primary hover:bg-primary/5":m.value!==a}])},n(a),11,Co))),128)),m.valuem.value=w.value),class:"glass-card border border-stroke-subtle dark:border-stroke hover:border-primary rounded-[8px] px-3 py-2 text-sm text-content-primary dark:text-content-primary hover:text-primary hover:bg-primary/5 transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20"},n(w.value),1)):k("",!0)]),t("button",{onClick:c[5]||(c[5]=a=>m.value=m.value+1),disabled:m.value>=w.value,class:T(["glass-card border rounded-[10px] px-3 py-2 text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20 prev-next-btn",{"border-stroke-subtle dark:border-stroke text-content-muted dark:text-content-muted cursor-not-allowed opacity-50":m.value>=w.value,"border-stroke-subtle dark:border-stroke text-content-primary dark:text-content-primary hover:border-primary hover:text-primary hover:bg-primary/5":m.value(r(),l("div",null,[st(ee),t("div",Eo,[st(je),st(fe)]),st(Fo)]))}});export{Xo as default}; diff --git a/repeater/web/html/assets/Dashboard-CZYwlk3m.css b/repeater/web/html/assets/Dashboard-CZYwlk3m.css new file mode 100644 index 0000000..9410ec9 --- /dev/null +++ b/repeater/web/html/assets/Dashboard-CZYwlk3m.css @@ -0,0 +1 @@ +.sparkline-card[data-v-814635af]{background:#ffffffbf;border:1px solid rgba(0,0,0,.06);border-radius:12px;padding:12px 14px;-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px);overflow:hidden;transition:background .3s ease,border-color .3s ease,box-shadow .3s ease;box-shadow:0 4px 16px #0000000a,0 1px 3px #00000005}.dark .sparkline-card[data-v-814635af]{background:#0006;border:1px solid rgba(255,255,255,.05);box-shadow:0 4px 16px #0003}.card-header[data-v-814635af]{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:8px}.card-title[data-v-814635af]{color:#4b5563b3;font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:.05em;transition:color .3s ease}.dark .card-title[data-v-814635af]{color:#fff9}.card-value[data-v-814635af]{font-size:22px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}.card-values[data-v-814635af]{display:flex;align-items:baseline;gap:6px}.card-secondary-value[data-v-814635af]{font-size:13px;font-weight:600;line-height:1;font-variant-numeric:tabular-nums;opacity:.85}.card-chart[data-v-814635af]{width:100%;height:28px;overflow:hidden}.card-chart canvas[data-v-814635af]{width:100%!important;height:100%!important}@media (min-width: 1024px){.sparkline-card[data-v-814635af]{padding:14px 16px}.card-header[data-v-814635af]{margin-bottom:10px}.card-title[data-v-814635af]{font-size:12px}.card-value[data-v-814635af]{font-size:26px}.card-chart[data-v-814635af]{height:32px}}.stats-cards-container[data-v-84cee3fb]{will-change:auto;contain:layout}.stat-card[data-v-84cee3fb]{transition:opacity .3s ease-out}.stat-card[data-v-84cee3fb] .text-lg,.stat-card[data-v-84cee3fb] .text-\[30px\]{transition:color .2s ease-out}canvas[data-v-6bf3fe96]{width:100%;height:100%}.modal-enter-active[data-v-86263c2b]{transition:all .3s cubic-bezier(.4,0,.2,1)}.modal-leave-active[data-v-86263c2b]{transition:all .2s ease-in}.modal-enter-from[data-v-86263c2b]{opacity:0;transform:scale(.95) translateY(-10px)}.modal-leave-to[data-v-86263c2b]{opacity:0;transform:scale(1.05)}.custom-scrollbar[data-v-86263c2b]{scrollbar-width:thin;scrollbar-color:rgba(255,255,255,.3) transparent}.custom-scrollbar[data-v-86263c2b]::-webkit-scrollbar{width:6px}.custom-scrollbar[data-v-86263c2b]::-webkit-scrollbar-track{background:#ffffff1a;border-radius:3px}.custom-scrollbar[data-v-86263c2b]::-webkit-scrollbar-thumb{background:#ffffff4d;border-radius:3px}.custom-scrollbar[data-v-86263c2b]::-webkit-scrollbar-thumb:hover{background:#fff6}.glass-card[data-v-86263c2b]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px)}.fade-enter-active[data-v-ec32abd6],.fade-leave-active[data-v-ec32abd6]{transition:opacity .3s ease-out,transform .3s ease-out}.fade-enter-from[data-v-ec32abd6],.fade-leave-to[data-v-ec32abd6]{opacity:0;transform:translateY(-10px)}@keyframes spin-ec32abd6{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin[data-v-ec32abd6]{animation:spin-ec32abd6 .8s linear infinite}.packet-list-enter-active[data-v-ec32abd6],.packet-list-leave-active[data-v-ec32abd6],.packet-list-move[data-v-ec32abd6]{transition:all .4s ease-out}.packet-list-enter-from[data-v-ec32abd6]{opacity:0;transform:translateY(-30px) scale(.98)}.packet-list-enter-to[data-v-ec32abd6],.packet-list-leave-from[data-v-ec32abd6]{opacity:1;transform:translateY(0) scale(1)}.packet-list-leave-to[data-v-ec32abd6]{opacity:0;transform:translateY(-20px) scale(.95)}.packet-row[data-v-ec32abd6]{position:relative;transition:all .3s ease}.packet-list-enter-active .packet-row[data-v-ec32abd6]{background:linear-gradient(90deg,rgba(78,201,176,.1) 0%,rgba(78,201,176,.05) 50%,transparent 100%);box-shadow:0 0 20px #4ec9b033;border-left:3px solid rgba(78,201,176,.6);border-radius:8px;padding-left:12px}.packet-row[data-v-ec32abd6]:hover{background:#ffffff05;border-radius:8px;transition:background .2s ease}@media (max-width: 1023px){.filter-container[data-v-ec32abd6]{flex-direction:column;gap:1rem;align-items:stretch}.header-info[data-v-ec32abd6]{flex-direction:column;align-items:flex-start;gap:.5rem}.packet-count[data-v-ec32abd6]{order:1}.live-mode-badge[data-v-ec32abd6]{order:2;align-self:flex-start}.loading-indicator[data-v-ec32abd6],.error-indicator[data-v-ec32abd6]{order:3;align-self:flex-start}.filter-controls[data-v-ec32abd6]{display:grid!important;grid-template-columns:1fr 1fr;gap:.75rem;flex-direction:column}.filter-controls .flex.flex-col[data-v-ec32abd6]{flex-direction:column;align-items:stretch;gap:.25rem}.filter-controls .flex.flex-col label[data-v-ec32abd6]{margin-bottom:0;font-size:.75rem}.reset-container[data-v-ec32abd6]{grid-column:span 2!important;display:flex;justify-content:center;margin-top:.5rem}.pagination-container[data-v-ec32abd6]{flex-direction:column;gap:1rem;align-items:stretch}.pagination-info[data-v-ec32abd6]{justify-content:center;text-align:center;flex-direction:column;gap:.5rem}.load-more-section[data-v-ec32abd6]{justify-content:center}.load-more-count[data-v-ec32abd6]{display:none}.pagination-controls[data-v-ec32abd6]{justify-content:center}.page-numbers[data-v-ec32abd6]{max-width:200px;overflow-x:auto;scrollbar-width:none;-ms-overflow-style:none}.page-numbers[data-v-ec32abd6]::-webkit-scrollbar{display:none}.ellipsis[data-v-ec32abd6]{display:none}.page-number[data-v-ec32abd6]{min-width:40px;flex-shrink:0}}@media (max-width: 640px){.filter-controls[data-v-ec32abd6]{grid-template-columns:1fr!important;gap:.75rem}.reset-container[data-v-ec32abd6]{grid-column:span 1!important}.header-info h3[data-v-ec32abd6]{font-size:1.125rem}.packet-count[data-v-ec32abd6]{font-size:.75rem}.live-mode-badge[data-v-ec32abd6]{font-size:.75rem;padding:.25rem .5rem}.pagination-info span[data-v-ec32abd6]{font-size:.75rem}.prev-next-btn[data-v-ec32abd6]{min-width:40px;padding:.5rem}.page-numbers[data-v-ec32abd6]{max-width:150px;gap:.25rem}.page-number[data-v-ec32abd6]{min-width:36px;padding:.5rem .25rem;font-size:.75rem}.load-more-section button[data-v-ec32abd6]{font-size:.6rem;padding:.375rem .75rem}} diff --git a/repeater/web/html/assets/Dashboard-g_BZKKVl.js b/repeater/web/html/assets/Dashboard-g_BZKKVl.js deleted file mode 100644 index 2428270..0000000 --- a/repeater/web/html/assets/Dashboard-g_BZKKVl.js +++ /dev/null @@ -1,2 +0,0 @@ -import{C as wt,a as Nt,L as Bt,P as Ft,b as jt,c as Et,i as It}from"./chart-B185MtDy.js";import{a as dt,r as B,c as W,D as Z,o as xt,E as it,H as vt,b as i,e as t,t as n,n as mt,g as w,I as Ut,p as l,x as ut,J as gt,K as kt,f as st,F as U,h as X,L as ft,M as Lt,i as Pt,u as ct,k as rt,N as Vt,T as Ht,l as zt,O as Xt,j as R,s as Gt,w as $t,q as Tt}from"./index-sHch0610.js";import{u as Ot}from"./useSignalQuality-CGSFiHBW.js";import{g as St,s as Rt}from"./preferences-DtwbSSgO.js";const Qt={class:"sparkline-card"},qt={class:"card-header"},Wt={class:"card-title"},Kt={class:"card-chart"},Jt=dt({name:"ChartSparkline",__name:"ChartSparkline",props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0}},setup(at){wt.register(Nt,Bt,Ft,jt,Et,It);const C=at,G=B(null),m=B(null),P=v=>{if(v.length<3)return v;const D=Math.min(15,Math.max(3,Math.floor(v.length*.2))),j=[];for(let S=0;Sy+M,0)/b.length)}const q=Math.min(12,j.length),V=j.length/q,I=[];for(let S=0;S!C.data||C.data.length===0?[]:P(C.data)),A=()=>{if(!G.value)return;const v=G.value.getContext("2d");if(!v)return;m.value&&(m.value.destroy(),m.value=null);const D=T.value;D.length<2||(m.value=Ut(new wt(v,{type:"line",data:{labels:D.map((j,q)=>q.toString()),datasets:[{data:D,borderColor:C.color,borderWidth:2.5,fill:!1,tension:.4,pointRadius:0,pointHoverRadius:0}]},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:800,easing:"easeOutQuart"},plugins:{legend:{display:!1},tooltip:{enabled:!1}},scales:{x:{display:!1,grid:{display:!1}},y:{display:!1,grid:{display:!1},grace:"10%"}},elements:{line:{capBezierPoints:!0}}}})))},O=()=>{if(!m.value){A();return}const v=T.value;v.length<2||(m.value.data.labels=v.map((D,j)=>j.toString()),m.value.data.datasets[0].data=v,m.value.update("default"))};return Z(()=>C.data,()=>{it(()=>O())},{deep:!0}),Z(()=>C.color,()=>{m.value&&(m.value.data.datasets[0].borderColor=C.color,m.value.update("none"))}),xt(()=>{it(()=>A())}),vt(()=>{m.value&&(m.value.destroy(),m.value=null)}),(v,D)=>(l(),i("div",Qt,[t("div",qt,[t("p",Wt,n(v.title),1),t("span",{class:"card-value",style:mt({color:v.color})},n(typeof v.value=="number"?v.value.toLocaleString():v.value),5)]),t("div",Kt,[v.showChart?(l(),i("canvas",{key:0,ref_key:"canvasRef",ref:G},null,512)):w("",!0)])]))}}),bt=ut(Jt,[["__scopeId","data-v-bcd5cf93"]]),Yt={class:"grid grid-cols-2 lg:grid-cols-4 gap-3 lg:gap-4 mb-5 stats-cards-container"},Zt=dt({name:"StatsCards",__name:"StatsCards",setup(at){const C=gt(),G=kt(),m=B(null),P=B(null),T=B(!1),A=W(()=>{const D=C.packetStats,j=C.systemStats,q=k=>{const r=Math.floor(k/86400),h=Math.floor(k%86400/3600),b=Math.floor(k%3600/60);return r>0?`${r}d ${h}h`:h>0?`${h}h ${b}m`:`${b}m`},V=D?.total_packets||0,I=D?.dropped_packets||0,S=V>0?Math.round(I/V*100):0;return{packetsReceived:V,packetsForwarded:D?.transmitted_packets||0,uptimeFormatted:j?q(j.uptime_seconds||0):"0m",uptimeHours:j?Math.floor((j.uptime_seconds||0)/3600):0,droppedPackets:I,dropPercent:`${S}%`,signalQuality:Math.round((D?.avg_rssi||0)+120)}}),O=W(()=>C.sparklineData),v=async()=>{if(!T.value)try{T.value=!0,await Promise.all([C.fetchSystemStats(),C.fetchPacketStats({hours:24})]),await it()}catch(D){console.error("Error fetching stats:",D)}finally{T.value=!1}};return xt(async()=>{await C.initializeSparklineHistory(),v(),G.isConnected||(m.value=window.setInterval(v,3e4)),P.value=window.setInterval(()=>{C.interpolateRates()},6e4)}),Z(()=>G.isConnected,D=>{D?m.value&&(clearInterval(m.value),m.value=null):m.value||(m.value=window.setInterval(v,3e4))}),vt(()=>{m.value&&clearInterval(m.value),P.value&&clearInterval(P.value)}),(D,j)=>(l(),i("div",Yt,[st(bt,{title:"Up Time",value:A.value.uptimeFormatted,color:"#EBA0FC",data:[],showChart:!1,class:"stat-card"},null,8,["value"]),st(bt,{title:"RX Packets",value:A.value.packetsReceived,color:"#AAE8E8",data:O.value.totalPackets,class:"stat-card"},null,8,["value","data"]),st(bt,{title:"Forward",value:A.value.packetsForwarded,color:"#FFC246",data:O.value.transmittedPackets,class:"stat-card"},null,8,["value","data"]),st(bt,{title:"Dropped",value:A.value.droppedPackets,color:"#FB787B",data:O.value.droppedPackets,class:"stat-card"},null,8,["value","data"])]))}}),te=ut(Zt,[["__scopeId","data-v-b87cc7f8"]]),ee={class:"glass-card rounded-[10px] p-4 lg:p-6"},se={class:"h-48 lg:h-56 relative"},ae={key:0,class:"absolute inset-0 flex items-center justify-center"},ne={key:1,class:"absolute inset-0 flex items-center justify-center"},oe={class:"text-red-600 dark:text-red-400 text-sm lg:text-base"},re={key:2,class:"absolute inset-0 flex items-center justify-center"},le={key:3,class:"h-full flex flex-col"},ie={key:0,class:"absolute top-2 left-1/2 -translate-x-1/2 bg-white/95 dark:bg-surface-elevated border border-stroke-subtle dark:border-stroke rounded-lg px-3 py-2 z-10 pointer-events-none min-w-48"},de={class:"text-content-primary dark:text-content-primary text-sm font-medium mb-1"},ce={class:"text-content-primary dark:text-content-primary"},ue={class:"flex-1 flex items-end justify-evenly gap-4 px-4"},pe=["onMouseenter"],me={class:"text-content-primary dark:text-content-primary text-xs sm:text-sm font-semibold text-center w-full",style:{"padding-bottom":"5px"}},xe={class:"text-content-secondary dark:text-content-muted text-xs mt-2 text-center"},ye={key:0,class:"mt-4 flex flex-wrap justify-center gap-3 sm:gap-4 px-2 sm:px-4 text-[10px] sm:text-xs text-content-secondary dark:text-content-muted"},be={class:"truncate text-left"},ve={key:1,class:"mt-3 text-xs text-content-secondary dark:text-content-muted text-center"},ge=dt({name:"PacketTypesChart",__name:"PacketTypesChart",setup(at){const C=B([]),G=gt(),m=kt(),P=B(!0),T=B(null),A=B(null),O=[{name:"Payload",types:["Plain Text Message","Group Text Message","Group Datagram","Multi-part Packet"],subColors:["#3B82F6","#60A5FA","#93C5FD","#BFDBFE"]},{name:"Requests",types:["Request","Response","Anonymous Request"],subColors:["#10B981","#34D399","#6EE7B7"]},{name:"Control",types:["Node Advertisement","Acknowledgment","Returned Path"],subColors:["#F59E0B","#FBBF24","#FCD34D"]},{name:"Routing",types:["Trace"],subColors:["#8B5CF6"]},{name:"Reserved",types:["Reserved Type 11","Reserved Type 12","Reserved Type 13"],subColors:["#6B7280","#9CA3AF","#D1D5DB"]}],v=W(()=>O.map(r=>{const h=C.value.filter(b=>r.types.some(y=>b.name.includes(y)||b.name===y)).sort((b,y)=>y.count-b.count).map((b,y)=>({...b,color:r.subColors[y%r.subColors.length]}));return{name:r.name,color:r.subColors[0],items:h,total:h.reduce((b,y)=>b+y.count,0)}}).filter(r=>r.total>0)),D=W(()=>Math.max(...v.value.map(r=>r.total),1)),j=W(()=>v.value.reduce((r,h)=>r+h.total,0)),q=async()=>{try{T.value=null;const r=await ft.get("/packet_type_graph_data");if(r?.success&&r?.data){const h=r.data;if(h?.series){const b=[];h.series.forEach((y,M)=>{let $=0;y.data&&Array.isArray(y.data)&&($=y.data.reduce((s,e)=>s+(e[1]||0),0)),$>0&&b.push({name:y.name||`Type ${y.type}`,type:y.type,count:$,color:""})}),C.value=b,P.value=!1}else T.value="No series data in server response",P.value=!1}else T.value="Invalid response from server",P.value=!1}catch(r){T.value=r instanceof Error?r.message:"Failed to load data",P.value=!1}},V={0:"Request",1:"Response",2:"Plain Text Message",3:"Acknowledgment",4:"Node Advertisement",5:"Group Text Message",6:"Group Datagram",7:"Anonymous Request",8:"Returned Path",9:"Trace",10:"Multi-part Packet",15:"Custom Packet"},I=()=>{const r=G.packetTypeBreakdown;!r||r.length===0||(C.value=r.map(h=>({name:V[Number(h.type)]||`Type ${h.type}`,type:h.type,count:h.count,color:""})),P.value=!1,T.value=null)},S=r=>Math.max(r/D.value*90,2),k=(r,h)=>h===0?0:r/h*100;return xt(()=>{q()}),Z(()=>G.packetTypeBreakdown,()=>I(),{deep:!0,immediate:!0}),Z(()=>m.isConnected,r=>{r||q()},{immediate:!0}),(r,h)=>(l(),i("div",ee,[h[3]||(h[3]=t("div",{class:"flex items-baseline justify-between mb-3 lg:mb-4"},[t("h3",{class:"text-content-primary dark:text-content-primary text-lg lg:text-xl font-semibold"},"Packet Types"),t("p",{class:"text-content-secondary dark:text-content-muted text-xs lg:text-sm uppercase"},"Distribution by Type")],-1)),t("div",se,[P.value?(l(),i("div",ae,h[1]||(h[1]=[t("div",{class:"text-content-secondary dark:text-content-primary text-sm lg:text-base"},"Loading packet types...",-1)]))):T.value?(l(),i("div",ne,[t("div",oe,n(T.value),1)])):v.value.length===0?(l(),i("div",re,h[2]||(h[2]=[t("div",{class:"text-content-secondary dark:text-content-primary text-sm lg:text-base"},"No packet data available",-1)]))):(l(),i("div",le,[A.value?(l(),i("div",ie,[t("div",de,n(A.value.name)+" · "+n(A.value.total.toLocaleString()),1),(l(!0),i(U,null,X(A.value.items,b=>(l(),i("div",{key:b.type,class:"flex justify-between gap-4 text-xs text-content-secondary dark:text-content-muted"},[t("span",null,n(b.name),1),t("span",ce,n(b.count.toLocaleString()),1)]))),128))])):w("",!0),t("div",ue,[(l(!0),i(U,null,X(v.value,b=>(l(),i("div",{key:b.name,class:"flex flex-col items-center flex-1 max-w-32 h-full justify-end cursor-pointer",onMouseenter:y=>A.value=b,onMouseleave:h[0]||(h[0]=y=>A.value=null)},[t("span",me,n(b.total.toLocaleString()),1),t("div",{class:"w-full rounded-[5px] transition-all duration-300 ease-out hover:opacity-90 overflow-hidden flex flex-col-reverse",style:mt({height:S(b.total)+"%",minHeight:"8px"})},[(l(!0),i(U,null,X(b.items,y=>(l(),i("div",{key:y.type,style:mt({height:k(y.count,b.total)+"%",backgroundColor:y.color})},null,4))),128))],4),t("span",xe,n(b.name),1)],40,pe))),128))])]))]),v.value.length>0?(l(),i("div",ye,[(l(!0),i(U,null,X(v.value,b=>(l(),i("div",{key:"legend-"+b.name,class:"flex flex-col gap-0.5 min-w-[100px] max-w-[140px] flex-shrink-0"},[(l(!0),i(U,null,X(b.items,y=>(l(),i("div",{key:y.type,class:"flex items-center gap-1.5"},[t("span",{class:"w-2 h-2 rounded-sm shrink-0",style:mt({backgroundColor:y.color})},null,4),t("span",be,n(y.name),1)]))),128))]))),128))])):w("",!0),v.value.length>0?(l(),i("div",ve," Total: "+n(j.value.toLocaleString())+" packets ",1)):w("",!0)]))}}),he=ut(ge,[["__scopeId","data-v-0948a4bb"]]),fe={class:"glass-card rounded-[10px] p-4 lg:p-6"},ke={class:"relative h-40 lg:h-48"},_e={class:"mt-3 lg:mt-4 grid grid-cols-2 gap-3 lg:gap-4"},we={class:"text-center"},$e={class:"text-lg lg:text-2xl font-bold text-content-primary dark:text-content-primary"},Te={class:"text-center"},Se={class:"text-lg lg:text-2xl font-bold text-content-primary dark:text-content-primary"},Re={class:"mt-2 lg:mt-3 grid grid-cols-3 gap-2 lg:gap-3 text-center"},Pe={class:"text-xs lg:text-sm font-semibold text-accent-purple flex items-center justify-center gap-1"},Ce={key:0,class:"inline-block w-1.5 h-1.5 rounded-full bg-secondary opacity-70",title:"Early data - limited uptime"},Ae={class:"text-xs text-content-secondary dark:text-content-muted"},Me={class:"text-xs lg:text-sm font-semibold text-accent-red flex items-center justify-center gap-1"},De={key:0,class:"inline-block w-1.5 h-1.5 rounded-full bg-secondary opacity-70",title:"Early data - limited uptime"},Ne={class:"text-xs text-content-secondary dark:text-content-muted"},Be={class:"text-xs lg:text-sm font-semibold text-white"},Fe=dt({name:"AirtimeUtilizationChart",__name:"AirtimeUtilizationChart",setup(at){const C=gt(),G=Lt(),m=B(null),P=B([]),T=B(!0),A=B(null),O=B(30),v=B({totalReceived:0,totalTransmitted:0,dropped:0,firstPacketTime:0}),D=B({sf:9,bwHz:62500,cr:5,preamble:17}),j=k=>{const{sf:r,bwHz:h,cr:b,preamble:y}=D.value,M=1,$=0,s=r>=11&&h<=125e3?1:0,e=h/1e3,c=Math.pow(2,r)/e,d=(y+4.25)*c,o=Math.max(8*k-4*r+28+16*M-20*$,0),x=4*(r-2*s),N=(8+Math.ceil(o/x)*b)*c;return d+N},q=(k,r=60)=>{if(k.length===0)return[];const h=1-Math.pow(.5,1/r),b=Math.min(k.length,Math.max(10,Math.floor(r/3)));let y=0,M=0;for(let $=0;$(y=h*$.rxUtil+(1-h)*y,M=h*$.txUtil+(1-h)*M,{...$,rxUtil:y,txUtil:M}))},V=W(()=>{const k=C.packetStats?.total_packets||0,r=C.packetStats?.transmitted_packets||0,h=G.stats?.uptime_seconds||0,b=k||v.value.totalReceived,y=r||v.value.totalTransmitted,M=v.value.firstPacketTime>0?Math.floor(Date.now()/1e3)-v.value.firstPacketTime:0,$=h||M,s=Math.max($/3600,.1);if(s<1){const _=Math.max($/60,1);return{rxRate:{value:Math.round(b/_*100)/100,label:s<.5?"RX/min (early)":"RX/min"},txRate:{value:Math.round(y/_*100)/100,label:s<.5?"TX/min (early)":"TX/min"},confidence:"low"}}const c=Math.round(b/s*100)/100,d=Math.round(y/s*100)/100;let o,x;return s<6?(o=`RX/hr (${Math.round(s)}h)`,x="medium"):s<24?(o=`RX/hr (${Math.round(s)}h)`,x="high"):(o="RX/hr",x="high"),{rxRate:{value:c,label:o},txRate:{value:d,label:o.replace("RX","TX")},confidence:x}}),I=async()=>{T.value=!0;try{const y=Math.floor(Date.now()/1e3),M=y-24*3600;let $=0;try{const f=await ft.get("/stats");if(f.success&&f.data){const g=f.data,F=g.config;if(F?.radio){const Q=F.radio;D.value={sf:Q.spreading_factor??9,bwHz:Q.bandwidth??62500,cr:Q.coding_rate??5,preamble:Q.preamble_length??17}}$=g.dropped_count??0}}catch{}const s=await ft.get("/filtered_packets",{start_timestamp:M,end_timestamp:y,limit:5e4});if(!s.success){P.value=[],T.value=!1,it(()=>S());return}const e=s.data||[],c=new Float64Array(8640),d=new Float64Array(8640);let o=0,x=0,_=1/0;for(const f of e){const g=Math.floor((f.timestamp-M)/10);if(g<0||g>=8640)continue;const F=f.length??f.payload_length??32,Q=j(F),et=f.packet_origin;f.timestamp<_&&(_=f.timestamp),(et==="tx_local"||et==="tx_forward"||f.transmitted)&&(d[g]+=Q,x++),et!=="tx_local"&&(c[g]+=Q,o++)}v.value={totalReceived:o,totalTransmitted:x,dropped:$,firstPacketTime:_===1/0?y:_};const N=[];for(let f=0;f<8640;f++)N.push({timestamp:M+f*10,rxUtil:c[f]/1e4*100,txUtil:d[f]/1e4*100});const E=q(N,60),K=Math.max(1,Math.floor(E.length/400)),tt=[];for(let f=0;f[f.rxUtil,f.txUtil]))*1.05;O.value=Math.max(5,Math.ceil(H/5)*5),T.value=!1,it(()=>S())}catch(k){console.error("Failed to fetch airtime data:",k),P.value=[],T.value=!1,it(()=>S())}},S=()=>{if(!m.value)return;const k=m.value,r=k.getContext("2d");if(!r)return;const h=k.parentElement;if(!h)return;const b=h.getBoundingClientRect(),y=b.width,M=b.height;k.width=y*window.devicePixelRatio,k.height=M*window.devicePixelRatio,k.style.width=y+"px",k.style.height=M+"px",r.scale(window.devicePixelRatio,window.devicePixelRatio);const $=20,s=45;if(r.clearRect(0,0,y,M),T.value){r.fillStyle="#666",r.font="16px system-ui",r.textAlign="center",r.fillText("Loading chart data...",y/2,M/2);return}if(P.value.length===0){r.fillStyle="#666",r.font="16px system-ui",r.textAlign="center",r.fillText("No data available",y/2,M/2);return}const e=y-s-$,c=M-$*2,d=O.value,o=O.value;r.strokeStyle="rgba(255, 255, 255, 0.1)",r.lineWidth=1,r.font="10px system-ui",r.textAlign="right";for(let x=0;x<=5;x++){const _=$+c*x/5;r.beginPath(),r.moveTo(s,_),r.lineTo(y-$,_),r.stroke();const N=d-x/5*o;r.fillStyle="rgba(255, 255, 255, 0.5)",r.fillText(`${N.toFixed(0)}%`,s-5,_+3)}for(let x=0;x<=6;x++){const _=s+e*x/6;r.beginPath(),r.moveTo(_,$),r.lineTo(_,M-$),r.stroke()}P.value.length>1&&(r.strokeStyle="#EBA0FC",r.lineWidth=2,r.beginPath(),P.value.forEach((x,_)=>{const N=s+e*_/(P.value.length-1),E=M-$-Math.min(x.rxUtil,O.value)/o*c;_===0?r.moveTo(N,E):r.lineTo(N,E)}),r.stroke()),P.value.length>1&&(r.strokeStyle="#FB787B",r.lineWidth=2,r.beginPath(),P.value.forEach((x,_)=>{const N=s+e*_/(P.value.length-1),E=M-$-Math.min(x.txUtil,O.value)/o*c;_===0?r.moveTo(N,E):r.lineTo(N,E)}),r.stroke())};return xt(()=>{I(),A.value=window.setInterval(I,3e4),it(()=>{S(),setTimeout(()=>S(),100)}),window.addEventListener("resize",S)}),vt(()=>{A.value&&clearInterval(A.value),window.removeEventListener("resize",S)}),(k,r)=>(l(),i("div",fe,[r[3]||(r[3]=Pt('

Airtime Utilization

Activity (Last 24 Hours)

Rx Util
Tx Util
',3)),t("div",ke,[t("canvas",{ref_key:"chartRef",ref:m,class:"absolute inset-0 w-full h-full"},null,512)]),t("div",_e,[t("div",we,[t("div",$e,n(ct(C).packetStats?.total_packets||v.value.totalReceived),1),r[0]||(r[0]=t("div",{class:"text-xs text-content-secondary dark:text-content-muted uppercase tracking-wide"},"Total Received",-1))]),t("div",Te,[t("div",Se,n(ct(C).packetStats?.transmitted_packets||v.value.totalTransmitted),1),r[1]||(r[1]=t("div",{class:"text-xs text-content-secondary dark:text-content-muted uppercase tracking-wide"},"Total Transmitted",-1))])]),t("div",Re,[t("div",null,[t("div",Pe,[rt(n(V.value.rxRate.value)+" ",1),V.value.confidence==="low"?(l(),i("span",Ce)):w("",!0)]),t("div",Ae,n(V.value.rxRate.label),1)]),t("div",null,[t("div",Me,[rt(n(V.value.txRate.value)+" ",1),V.value.confidence==="low"?(l(),i("span",De)):w("",!0)]),t("div",Ne,n(V.value.txRate.label),1)]),t("div",null,[t("div",Be,n(ct(C).packetStats?.dropped_packets||v.value.dropped),1),r[2]||(r[2]=t("div",{class:"text-xs text-white/60"},"Dropped",-1))])])]))}}),je=ut(Fe,[["__scopeId","data-v-0aca4e12"]]),Ee={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] shadow-2xl border border-stroke-subtle dark:border-white/20 flex flex-col h-full overflow-hidden"},Ie={class:"flex items-center justify-between p-8 pb-4 flex-shrink-0"},Ue={class:"text-content-secondary dark:text-content-muted text-sm"},Le={class:"flex items-center gap-2"},Ve=["title"],He={class:"flex-1 overflow-y-auto custom-scrollbar px-8"},ze={class:"mb-6"},Xe={class:"glass-card bg-white/5 rounded-[15px] p-4"},Ge={class:"grid grid-cols-1 md:grid-cols-2 gap-4"},Oe={class:"space-y-3"},Qe={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},qe={class:"text-content-primary dark:text-content-primary font-mono text-sm"},We={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Ke={class:"text-content-primary dark:text-content-primary font-mono text-xs break-all"},Je={key:0,class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Ye={class:"text-content-primary dark:text-content-primary font-mono text-xs"},Ze={class:"space-y-3"},ts={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},es={class:"text-content-primary dark:text-content-primary font-semibold"},ss={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},as={class:"text-content-primary dark:text-content-primary font-semibold"},ns={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},os={class:"mb-6"},rs={class:"bg-gray-50 dark:bg-white/5 rounded-[15px] p-4 border border-stroke-subtle dark:border-stroke/10"},ls={class:"space-y-3"},is={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},ds={class:"text-content-primary dark:text-content-primary"},cs={key:0,class:"pt-2"},us={class:"glass-card bg-background-mute dark:bg-black/30 rounded-[10px] p-4 mb-4"},ps={class:"w-full overflow-x-auto"},ms={class:"text-content-primary dark:text-content-primary/90 text-xs font-mono whitespace-pre leading-relaxed min-w-full"},xs={class:"flex items-center justify-between mb-3"},ys={class:"text-content-secondary dark:text-content-primary/80 text-sm font-semibold"},bs={class:"text-content-muted dark:text-content-muted text-xs"},vs={class:"bg-background-mute dark:bg-black/40 rounded-[8px] p-3 mb-3"},gs={class:"font-mono text-xs text-content-primary dark:text-content-primary break-all whitespace-pre-wrap leading-relaxed"},hs={class:"bg-gray-50 dark:bg-white/5 rounded-[10px] overflow-hidden"},fs={key:0,class:"min-w-0"},ks={class:"text-cyan-500 text-sm font-mono break-words min-w-0"},_s={class:"text-content-primary dark:text-content-primary text-sm break-words min-w-0"},ws={class:"text-content-primary dark:text-content-primary text-sm font-semibold break-all min-w-0 overflow-hidden"},$s=["title"],Ts={key:0,class:"text-orange-500 text-xs font-mono break-all min-w-0 overflow-hidden"},Ss=["title"],Rs={class:"grid grid-cols-2 gap-2"},Ps={class:"text-cyan-500 text-sm font-mono break-words"},Cs={class:"text-content-primary dark:text-content-primary text-sm break-words"},As=["title"],Ms={key:0},Ds=["title"],Ns={key:0,class:"text-content-muted dark:text-content-muted text-xs italic mt-2 px-1"},Bs={key:1,class:"py-2"},Fs={class:"mb-6"},js={class:"bg-gray-50 dark:bg-white/5 rounded-[15px] p-4 border border-stroke-subtle dark:border-stroke/10"},Es={class:"space-y-4"},Is={class:"grid grid-cols-1 md:grid-cols-2 gap-4"},Us={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Ls={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Vs={key:0,class:"py-2"},Hs={class:"bg-background-mute dark:bg-black/20 rounded-[10px] p-4"},zs={class:"flex items-center flex-wrap gap-2"},Xs={class:"relative group"},Gs={class:"relative px-3 py-2 bg-gradient-to-br from-blue-500/20 to-cyan-500/20 border border-cyan-400/40 rounded-lg transform transition-all hover:scale-105"},Os={class:"font-mono text-xs font-semibold text-content-primary dark:text-content-primary/90"},Qs={class:"absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-content-primary dark:bg-background/90 text-white dark:text-content-primary text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10"},qs={key:0,class:"mx-2 text-cyan-600 dark:text-cyan-400/60"},Ws={key:1,class:"py-2"},Ks={class:"text-content-secondary dark:text-content-muted text-sm mb-2 flex items-center"},Js={key:0,class:"w-4 h-4 ml-2 text-yellow-500",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Ys={key:1,class:"text-yellow-500 text-xs ml-1"},Zs={class:"bg-background-mute dark:bg-black/20 rounded-[10px] p-4"},ta={class:"flex items-center flex-wrap gap-2"},ea={class:"relative group"},sa={key:0,class:"absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full animate-pulse"},aa={class:"absolute bottom-full left-1/2 transform -translate-x-1/2 mb-2 px-2 py-1 bg-content-primary dark:bg-background/90 text-white dark:text-content-primary text-xs rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap z-10"},na={key:0,class:"mx-1 text-orange-600 dark:text-orange-400/60"},oa={class:"mb-6"},ra={class:"glass-card bg-gray-50 dark:bg-white/5 rounded-[15px] p-4"},la={class:"grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"},ia={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},da={class:"text-lg font-bold text-content-primary dark:text-content-primary"},ca={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},ua={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},pa={class:"text-lg font-bold text-content-primary dark:text-content-primary"},ma={key:0,class:"mb-4"},xa={class:"flex items-center gap-3"},ya={class:"flex gap-1"},ba={class:"text-content-secondary dark:text-content-primary/80 text-sm capitalize"},va={key:1,class:"mb-4"},ga={key:2,class:"mb-4"},ha={class:"text-content-secondary dark:text-content-muted text-sm mb-3"},fa={class:"space-y-2"},ka={class:"flex items-center gap-3"},_a={class:"text-content-muted dark:text-content-muted text-sm"},wa={key:3,class:"mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke/10"},$a={class:"grid grid-cols-1 md:grid-cols-3 gap-3 mb-4"},Ta={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},Sa={class:"text-2xl font-bold text-content-primary dark:text-content-primary"},Ra={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},Pa={class:"text-2xl font-bold text-content-primary dark:text-content-primary"},Ca={class:"text-content-muted dark:text-content-muted text-xs mt-1"},Aa={class:"text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]"},Ma={class:"text-content-muted dark:text-content-muted text-xs mt-1"},Da={key:0,class:"glass-card bg-background-mute dark:bg-black/20 rounded-[10px] p-4"},Na={class:"space-y-3"},Ba={class:"flex-shrink-0 w-16 text-right"},Fa={class:"text-content-secondary dark:text-content-muted text-xs"},ja={class:"flex-1 relative"},Ea={class:"h-8 rounded-lg overflow-hidden bg-background-mute dark:bg-stroke/5 relative"},Ia={class:"absolute inset-0 flex items-center px-3"},Ua={class:"text-content-primary dark:text-content-primary text-xs font-mono font-semibold"},La={class:"flex-shrink-0 w-12 text-left"},Va={class:"text-content-muted dark:text-content-muted text-xs"},Ha={class:"grid grid-cols-1 md:grid-cols-2 gap-4"},za={class:"space-y-2"},Xa={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Ga={class:"text-content-primary dark:text-content-primary"},Oa={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Qa={class:"space-y-2"},qa={class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Wa={key:0,class:"flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10"},Ka={class:"text-red-600 dark:text-red-400 text-sm"},Ja={class:"p-8 pt-4 border-t border-stroke-subtle dark:border-stroke/10 flex justify-end flex-shrink-0"},Ya=dt({name:"PacketDetailsModal",__name:"PacketDetailsModal",props:{packet:{},isOpen:{type:Boolean},localHash:{}},emits:["close"],setup(at,{emit:C}){const{getSignalQuality:G}=Ot(),m=at,P=C,T=B(!1),A=s=>new Date(s*1e3).toLocaleString(),O=s=>s.transmitted?s.is_duplicate?"text-amber-600 dark:text-amber-400":s.drop_reason?"text-red-600 dark:text-red-400":"text-green-600 dark:text-green-400":"text-red-600 dark:text-red-400",v=s=>s.transmitted?s.is_duplicate?"Duplicate":s.drop_reason?"Dropped":"Forwarded":"Dropped",D=s=>({0:"Request",1:"Response",2:"Plain Text Message",3:"Acknowledgment",4:"Node Advertisement",5:"Group Text Message",6:"Group Datagram",7:"Anonymous Request",8:"Returned Path",9:"Trace",10:"Multi-part Packet",15:"Custom Packet"})[s]||`Unknown Type (${s})`,j=s=>({0:"Transport Flood",1:"Flood",2:"Direct",3:"Transport Direct"})[s]||`Unknown Route (${s})`,q=s=>{if(!s)return"None";const c=s.replace(/\s+/g,"").toUpperCase().match(/.{2}/g)||[],d=[];for(let o=0;o{try{let d=0;const o=e.length/2;if(o>=100){if(e.length>=d+64){const x=e.slice(d,d+64);s.push({name:"Public Key",byteRange:`${(c+d)/2}-${(c+d+63)/2}`,hexData:x.match(/.{8}/g)?.join(" ")||x,description:"Ed25519 public key of the node (32 bytes)",fields:[{bits:"0-255",name:"Ed25519 Public Key",value:`${x.slice(0,16)}...${x.slice(-16)}`,binary:"32 bytes (256 bits)"}]}),d+=64}if(e.length>=d+8){const x=e.slice(d,d+8),_=parseInt(x,16),N=new Date(_*1e3);s.push({name:"Timestamp",byteRange:`${(c+d)/2}-${(c+d+7)/2}`,hexData:x.match(/.{2}/g)?.join(" ")||x,description:"Unix timestamp of advertisement",fields:[{bits:"0-31",name:"Unix Timestamp",value:`${_} (${N.toLocaleString()})`,binary:_.toString(2).padStart(32,"0")}]}),d+=8}if(e.length>=d+128){const x=e.slice(d,d+128);s.push({name:"Signature",byteRange:`${(c+d)/2}-${(c+d+127)/2}`,hexData:x.match(/.{8}/g)?.join(" ")||x,description:"Ed25519 signature of public key, timestamp, and appdata",fields:[{bits:"0-511",name:"Ed25519 Signature",value:`${x.slice(0,16)}...${x.slice(-16)}`,binary:"64 bytes (512 bits)"}]}),d+=128}if(e.length>d){const x=e.slice(d);I(s,x,c+d)}}else s.push({name:"ADVERT AppData (Partial)",byteRange:`${c/2}-${c/2+o-1}`,hexData:e.match(/.{2}/g)?.join(" ")||e,description:`Partial ADVERT data - appears to be just AppData portion (${o} bytes)`,fields:[{bits:`0-${o*8-1}`,name:"Partial Data",value:`${o} bytes - attempting to decode as AppData`,binary:`${o} bytes (${o*8} bits)`}]}),I(s,e,c)}catch(d){s.push({name:"ADVERT Parse Error",byteRange:"N/A",hexData:e.slice(0,32)+"...",description:"Failed to parse ADVERT payload structure",fields:[{bits:"N/A",name:"Error",value:`Parse error: ${d instanceof Error?d.message:"Unknown error"}`,binary:"Invalid"}]})}},I=(s,e,c)=>{try{const d=e.length/2;s.push({name:"AppData",byteRange:`${c/2}-${c/2+d-1}`,hexData:e.match(/.{2}/g)?.join(" ")||e,description:`Node advertisement application data (${d} bytes)`,fields:[{bits:`0-${d*8-1}`,name:"Application Data",value:`${d} bytes (contains flags, location, name, etc.)`,binary:`${d} bytes (${d*8} bits)`}]});let o=0;if(e.length>=2){const x=parseInt(e.slice(o,o+2),16),_=[],N=!!(x&16),E=!!(x&32),K=!!(x&64),tt=!!(x&128);if(x&1&&_.push("is chat node"),x&2&&_.push("is repeater"),x&4&&_.push("is room server"),x&8&&_.push("is sensor"),N&&_.push("has location"),E&&_.push("has feature 1"),K&&_.push("has feature 2"),tt&&_.push("has name"),s.push({name:"AppData Flags",byteRange:`${(c+o)/2}`,hexData:`0x${e.slice(o,o+2)}`,description:"Flags indicating which optional fields are present",fields:[{bits:"0-7",name:"Flags",value:_.join(", ")||"none",binary:x.toString(2).padStart(8,"0")}]}),o+=2,N&&e.length>=o+16){const L=e.slice(o,o+8),H=[];for(let p=6;p>=0;p-=2)H.push(L.slice(p,p+2));const f=parseInt(H.join(""),16),g=f>2147483647?f-4294967296:f,F=g/1e6,Q=e.slice(o+8,o+16),et=[];for(let p=6;p>=0;p-=2)et.push(Q.slice(p,p+2));const Y=parseInt(et.join(""),16),nt=Y>2147483647?Y-4294967296:Y,yt=nt/1e6;s.push({name:"Location Data",byteRange:`${(c+o)/2}-${(c+o+15)/2}`,hexData:`${L.match(/.{2}/g)?.join(" ")||L} ${Q.match(/.{2}/g)?.join(" ")||Q}`,description:"GPS coordinates (latitude and longitude)",fields:[{bits:"0-31",name:"Latitude",value:`${F.toFixed(6)}° (raw: ${g})`,binary:g.toString(2).padStart(32,"0")},{bits:"32-63",name:"Longitude",value:`${yt.toFixed(6)}° (raw: ${nt})`,binary:nt.toString(2).padStart(32,"0")}]}),o+=16}if(E&&e.length>=o+4){const L=e.slice(o,o+4),H=parseInt(L,16);s.push({name:"Feature 1",byteRange:`${(c+o)/2}-${(c+o+3)/2}`,hexData:L.match(/.{2}/g)?.join(" ")||L,description:"Reserved feature 1 (2 bytes)",fields:[{bits:"0-15",name:"Feature 1 Value",value:`${H}`,binary:H.toString(2).padStart(16,"0")}]}),o+=4}if(K&&e.length>=o+4){const L=e.slice(o,o+4),H=parseInt(L,16);s.push({name:"Feature 2",byteRange:`${(c+o)/2}-${(c+o+3)/2}`,hexData:L.match(/.{2}/g)?.join(" ")||L,description:"Reserved feature 2 (2 bytes)",fields:[{bits:"0-15",name:"Feature 2 Value",value:`${H}`,binary:H.toString(2).padStart(16,"0")}]}),o+=4}if(tt&&e.length>o){const L=e.slice(o),H=L.match(/.{2}/g)||[],f=H.map(g=>{const F=parseInt(g,16);return F>=32&&F<=126?String.fromCharCode(F):"."}).join("").replace(/\.+$/,"");s.push({name:"Node Name",byteRange:`${(c+o)/2}-${(c+e.length-1)/2}`,hexData:L.match(/.{2}/g)?.join(" ")||L,description:`Node name string (${H.length} bytes)`,fields:[{bits:`0-${H.length*8-1}`,name:"Node Name",value:`"${f}"`,binary:`ASCII text (${H.length} bytes)`}]})}}}catch(d){s.push({name:"AppData Parse Error",byteRange:"N/A",hexData:e.slice(0,Math.min(32,e.length)),description:"Failed to parse AppData structure",fields:[{bits:"N/A",name:"Error",value:`Parse error: ${d instanceof Error?d.message:"Unknown error"}`,binary:"Invalid"}]})}},S=s=>{if(!s)return[];if(Array.isArray(s))return s;if(typeof s=="string")try{return JSON.parse(s)}catch{return[]}return[]},k=s=>{const e=[];if(!s)return e;try{const c=s.raw_packet;if(c){const d=c.replace(/\s+/g,"").toUpperCase();let o=0;if(d.length>=2){const x=d.slice(o,o+2),_=parseInt(x,16),N=_&3,E=(_&60)>>2,K=(_&192)>>6,tt={0:"Transport Flood",1:"Flood",2:"Direct",3:"Transport Direct"},L={0:"REQ",1:"RESPONSE",2:"TXT_MSG",3:"ACK",4:"ADVERT",5:"GRP_TXT",6:"GRP_DATA",7:"ANON_REQ",8:"PATH",9:"TRACE",10:"MULTIPART",15:"RAW_CUSTOM"};if(e.push({name:"Header",byteRange:"0",hexData:`0x${x}`,description:"Contains routing type, payload type, and payload version",fields:[{bits:"0-1",name:"Route Type",value:tt[N]||"Unknown",binary:N.toString(2).padStart(2,"0")},{bits:"2-5",name:"Payload Type",value:L[E]||"Unknown",binary:E.toString(2).padStart(4,"0")},{bits:"6-7",name:"Version",value:K.toString(),binary:K.toString(2).padStart(2,"0")}]}),o+=2,(N===0||N===3)&&d.length>=o+8){const f=d.slice(o,o+8),g=parseInt(f.slice(0,4),16),F=parseInt(f.slice(4,8),16);e.push({name:"Transport Codes",byteRange:"1-4",hexData:`${f.slice(0,4)} ${f.slice(4,8)}`,description:"2x 16-bit transport codes for routing optimization",fields:[{bits:"0-15",name:"Code 1",value:g.toString(),binary:g.toString(2).padStart(16,"0")},{bits:"16-31",name:"Code 2",value:F.toString(),binary:F.toString(2).padStart(16,"0")}]}),o+=8}if(d.length>=o+2){const f=d.slice(o,o+2),g=parseInt(f,16);if(e.push({name:"Path Length",byteRange:`${o/2}`,hexData:`0x${f}`,description:`${g} bytes of path data`,fields:[{bits:"0-7",name:"Path Length",value:`${g} bytes`,binary:g.toString(2).padStart(8,"0")}]}),o+=2,g>0&&d.length>=o+g*2){const F=d.slice(o,o+g*2);e.push({name:"Path Data",byteRange:`${o/2}-${(o+g*2-2)/2}`,hexData:F.match(/.{2}/g)?.join(" ")||F,description:"Routing path information",fields:[{bits:`0-${g*8-1}`,name:"Route Path",value:`${g} bytes of routing data`,binary:`${g} bytes (${g*8} bits)`}]}),o+=g*2}}if(d.length>o){const f=d.slice(o),g=f.length/2;E===4?V(e,f,o):e.push({name:"Payload Data",byteRange:`${o/2}-${o/2+g-1}`,hexData:f.match(/.{2}/g)?.join(" ")||f,description:"Application data content",fields:[{bits:`0-${g*8-1}`,name:"Application Data",value:`${g} bytes`,binary:`${g} bytes (${g*8} bits)`}]})}}}else{if(s.header){const d=s.header.replace(/0x/gi,"").replace(/\s+/g,"").toUpperCase(),o=parseInt(d,16),x=o&3,_=(o&60)>>2,N=(o&192)>>6,E={0:"Transport Flood",1:"Flood",2:"Direct",3:"Transport Direct"},K={0:"REQ",1:"RESPONSE",2:"TXT_MSG",3:"ACK",4:"ADVERT",5:"GRP_TXT",6:"GRP_DATA",7:"ANON_REQ",8:"PATH",9:"TRACE",10:"MULTIPART",15:"RAW_CUSTOM"};e.push({name:"Header",byteRange:"0",hexData:`0x${d}`,description:"Contains routing type, payload type, and payload version",fields:[{bits:"0-1",name:"Route Type",value:E[x]||"Unknown",binary:x.toString(2).padStart(2,"0")},{bits:"2-5",name:"Payload Type",value:K[_]||"Unknown",binary:_.toString(2).padStart(4,"0")},{bits:"6-7",name:"Version",value:N.toString(),binary:N.toString(2).padStart(2,"0")}]}),s.transport_codes&&e.push({name:"Transport Codes",byteRange:"1-4",hexData:s.transport_codes,description:"2x 16-bit transport codes for routing optimization",fields:[{bits:"0-31",name:"Transport Codes",value:s.transport_codes,binary:"Available in separate field"}]}),s.original_path&&s.original_path.length>0&&e.push({name:"Original Path",byteRange:"?",hexData:s.original_path.join(" "),description:`Original routing path (${s.original_path.length} nodes)`,fields:[{bits:"0-?",name:"Path Nodes",value:`${s.original_path.length} nodes`,binary:"Available as node list"}]}),s.forwarded_path&&s.forwarded_path.length>0&&e.push({name:"Forwarded Path",byteRange:"?",hexData:s.forwarded_path.join(" "),description:`Forwarded routing path (${s.forwarded_path.length} nodes)`,fields:[{bits:"0-?",name:"Path Nodes",value:`${s.forwarded_path.length} nodes`,binary:"Available as node list"}]})}if(s.payload){const d=s.payload.replace(/\s+/g,"").toUpperCase(),o=d.length/2;s.type===4?V(e,d,0):e.push({name:"Payload Data",byteRange:`0-${o-1}`,hexData:d.match(/.{2}/g)?.join(" ")||d,description:`Application data content (${o} bytes)`,fields:[{bits:`0-${o*8-1}`,name:"Application Data",value:`${o} bytes`,binary:`${o} bytes (${o*8} bits)`}]})}}}catch{e.push({name:"Parse Error",byteRange:"N/A",hexData:"Error",description:"Unable to parse packet structure",fields:[{bits:"N/A",name:"Error",value:"Parse failed",binary:"Invalid"}]})}return e},r=(s,e)=>s==null||e==null?"text-content-muted dark:text-content-muted":G(e).color,h=s=>{if(s==null)return{level:0,className:"signal-none"};const e=G(s);let c,d;return e.bars>=5?(c=4,d="signal-excellent"):e.bars>=4?(c=3,d="signal-good"):e.bars>=2?(c=2,d="signal-fair"):e.bars>=1?(c=1,d="signal-poor"):(c=0,d="signal-none"),{level:c,className:d}},b=s=>{if(!s)return[];try{const e=JSON.parse(s);return Array.isArray(e)?e:[]}catch{return[]}},y=s=>s>=1e3?`${(s/1e3).toFixed(2)}s`:`${Math.round(s)}ms`,M=s=>{s.key==="Escape"&&P("close")},$=s=>{s.target===s.currentTarget&&P("close")};return Z(()=>m.isOpen,s=>{s?document.body.style.overflow="hidden":document.body.style.overflow=""},{immediate:!0}),(s,e)=>(l(),Vt(Xt,{to:"body"},[st(Ht,{name:"modal",appear:""},{default:zt(()=>[s.isOpen&&s.packet?(l(),i("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 overflow-hidden",onClick:$,onKeydown:M,tabindex:"0"},[e[51]||(e[51]=t("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-md pointer-events-none"},null,-1)),t("div",{class:"relative w-full max-w-4xl max-h-[90vh] flex flex-col",onClick:e[3]||(e[3]=Gt(()=>{},["stop"]))},[t("div",Ee,[t("div",Ie,[t("div",null,[e[4]||(e[4]=t("h2",{class:"text-2xl font-bold text-content-primary dark:text-content-primary mb-1"},"Packet Details",-1)),t("p",Ue,n(D(s.packet.type))+" - "+n(j(s.packet.route)),1)]),t("div",Le,[t("button",{onClick:e[0]||(e[0]=c=>T.value=!T.value),class:R(["flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all duration-200",T.value?"bg-cyan-500/20 border border-cyan-400/30 text-cyan-600 dark:text-cyan-400":"bg-background-mute dark:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-secondary dark:text-content-muted"]),title:T.value?"Hide binary values":"Show binary values"},e[5]||(e[5]=[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"})],-1),t("span",{class:"text-xs font-medium"},"Binary",-1)]),10,Ve),t("button",{onClick:e[1]||(e[1]=c=>P("close")),class:"w-8 h-8 flex items-center justify-center rounded-full bg-background-mute dark:bg-white/10 hover:bg-stroke-subtle dark:hover:bg-white/20 transition-colors duration-200 text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary"},e[6]||(e[6]=[t("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))])]),t("div",He,[t("div",ze,[e[13]||(e[13]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4 flex items-center"},[t("div",{class:"w-2 h-2 rounded-full bg-cyan-400 mr-3"}),rt(" Basic Information ")],-1)),t("div",Xe,[t("div",Ge,[t("div",Oe,[t("div",Qe,[e[7]||(e[7]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Timestamp",-1)),t("span",qe,n(A(s.packet.timestamp)),1)]),t("div",We,[e[8]||(e[8]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Packet Hash",-1)),t("span",Ke,n(s.packet.packet_hash),1)]),s.packet.header?(l(),i("div",Je,[e[9]||(e[9]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Header",-1)),t("span",Ye,n(s.packet.header),1)])):w("",!0)]),t("div",Ze,[t("div",ts,[e[10]||(e[10]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Type",-1)),t("span",es,n(s.packet.type)+" ("+n(D(s.packet.type))+")",1)]),t("div",ss,[e[11]||(e[11]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Route",-1)),t("span",as,n(s.packet.route)+" ("+n(j(s.packet.route))+")",1)]),t("div",ns,[e[12]||(e[12]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Status",-1)),t("span",{class:R(["font-semibold",O(s.packet)])},n(v(s.packet)),3)])])])])]),t("div",os,[e[25]||(e[25]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4 flex items-center"},[t("div",{class:"w-2 h-2 rounded-full bg-orange-400 mr-3"}),rt(" Payload Data ")],-1)),t("div",rs,[t("div",ls,[t("div",is,[e[14]||(e[14]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Payload Length",-1)),t("span",ds,n(s.packet.payload_length||s.packet.length)+" bytes",1)]),s.packet.payload?(l(),i("div",cs,[e[23]||(e[23]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-3"},"Payload Analysis",-1)),t("div",us,[e[15]||(e[15]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-2 font-semibold"},"Raw Hex Data",-1)),t("div",ps,[t("pre",ms,n(q(s.packet.payload)),1)])]),(l(!0),i(U,null,X(k(s.packet).filter(c=>!c.name.includes("Parse Error")),(c,d)=>(l(),i("div",{key:d,class:"mb-4"},[t("div",xs,[t("h4",ys,n(c.name),1),t("span",bs,"Bytes "+n(c.byteRange),1)]),t("div",vs,[t("div",gs,n(c.hexData),1)]),t("div",hs,[t("div",{class:R(["hidden md:grid gap-3 p-3 bg-background-mute dark:bg-white/10 text-content-secondary dark:text-content-muted text-xs font-semibold uppercase tracking-wide",T.value?"grid-cols-4":"grid-cols-3"])},[e[16]||(e[16]=t("div",{class:"min-w-0"},"Bits",-1)),e[17]||(e[17]=t("div",{class:"min-w-0"},"Field",-1)),e[18]||(e[18]=t("div",{class:"min-w-0"},"Value",-1)),T.value?(l(),i("div",fs,"Binary")):w("",!0)],2),(l(!0),i(U,null,X(c.fields,(o,x)=>(l(),i("div",{key:x,class:R(["hidden md:grid gap-3 p-3 border-b border-stroke-subtle dark:border-stroke/5 last:border-b-0 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors",T.value?"grid-cols-4":"grid-cols-3"])},[t("div",ks,n(o.bits),1),t("div",_s,n(o.name),1),t("div",ws,[t("span",{class:"block",title:o.value},n(o.value),9,$s)]),T.value?(l(),i("div",Ts,[t("span",{class:"block",title:o.binary},n(o.binary),9,Ss)])):w("",!0)],2))),128)),(l(!0),i(U,null,X(c.fields,(o,x)=>(l(),i("div",{key:`mobile-${x}`,class:"md:hidden p-3 border-b border-stroke-subtle dark:border-stroke/5 last:border-b-0 space-y-2"},[t("div",Rs,[t("div",null,[e[19]||(e[19]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide"},"Bits:",-1)),t("div",Ps,n(o.bits),1)]),t("div",null,[e[20]||(e[20]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide"},"Field:",-1)),t("div",Cs,n(o.name),1)])]),t("div",null,[e[21]||(e[21]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide"},"Value:",-1)),t("div",{class:"text-content-primary dark:text-content-primary text-sm font-semibold break-all",title:o.value},n(o.value),9,As)]),T.value?(l(),i("div",Ms,[e[22]||(e[22]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide"},"Binary:",-1)),t("div",{class:"text-orange-500 text-xs font-mono break-all",title:o.binary},n(o.binary),9,Ds)])):w("",!0)]))),128))]),c.description?(l(),i("div",Ns,n(c.description),1)):w("",!0)]))),128))])):(l(),i("div",Bs,e[24]||(e[24]=[t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Payload:",-1),t("span",{class:"text-content-muted dark:text-content-muted ml-2"},"None",-1)])))])])]),t("div",Fs,[e[33]||(e[33]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4 flex items-center"},[t("div",{class:"w-2 h-2 rounded-full bg-purple-400 mr-3"}),rt(" Path Information ")],-1)),t("div",js,[t("div",Es,[t("div",Is,[t("div",Us,[e[26]||(e[26]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Source Hash",-1)),t("span",{class:R(["text-content-primary dark:text-content-primary font-mono text-xs",m.localHash&&s.packet.src_hash===m.localHash?"bg-cyan-400/20 text-cyan-600 dark:text-cyan-300 px-1 rounded":""])},n(s.packet.src_hash||"Unknown"),3)]),t("div",Ls,[e[27]||(e[27]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Destination Hash",-1)),t("span",{class:R(["text-content-primary dark:text-content-primary font-mono text-xs",m.localHash&&s.packet.dst_hash===m.localHash?"bg-cyan-400/20 text-cyan-600 dark:text-cyan-300 px-1 rounded":""])},n(s.packet.dst_hash||"Broadcast"),3)])]),S(s.packet.original_path).length>0?(l(),i("div",Vs,[e[29]||(e[29]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-2"},"Original Path",-1)),t("div",Hs,[t("div",zs,[(l(!0),i(U,null,X(S(s.packet.original_path),(c,d)=>(l(),i("div",{key:d,class:"flex items-center"},[t("div",Xs,[t("div",Gs,[t("div",Os,n(c.length<=2?c.toUpperCase():c.slice(0,2).toUpperCase()),1)]),t("div",Qs," Node: "+n(c),1)]),d0?(l(),i("div",Ws,[t("div",Ks,[e[31]||(e[31]=rt(" Forwarded Path ",-1)),JSON.stringify(S(s.packet.original_path))!==JSON.stringify(S(s.packet.forwarded_path))?(l(),i("svg",Js,e[30]||(e[30]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)]))):w("",!0),JSON.stringify(S(s.packet.original_path))!==JSON.stringify(S(s.packet.forwarded_path))?(l(),i("span",Ys,"(Modified)")):w("",!0)]),t("div",Zs,[t("div",ta,[(l(!0),i(U,null,X(S(s.packet.forwarded_path),(c,d)=>(l(),i("div",{key:d,class:"flex items-center"},[t("div",ea,[t("div",{class:R(["relative px-3 py-2 bg-gradient-to-br from-orange-500/20 to-yellow-500/20 border border-orange-500 dark:border-orange-400/40 rounded-lg transform transition-all hover:scale-105",m.localHash&&c===m.localHash?"bg-gradient-to-br from-yellow-400/30 to-orange-400/30 border-yellow-300 shadow-yellow-400/20 shadow-lg":"hover:border-orange-500 dark:border-orange-400/60"])},[t("div",{class:R(["font-mono text-xs font-semibold",m.localHash&&c===m.localHash?"text-yellow-200":"text-white/90"])},n(c.slice(0,2).toUpperCase()),3),m.localHash&&c===m.localHash?(l(),i("div",sa)):w("",!0)],2),t("div",aa,n(c),1)]),dt("div",{key:c,class:R(["w-2 h-6 rounded-sm transition-all duration-300",c<=h(s.packet.rssi).level?{"signal-excellent":"bg-green-400","signal-good":"bg-cyan-400","signal-fair":"bg-yellow-400","signal-poor":"bg-red-400"}[h(s.packet.rssi).className]:"bg-stroke-subtle dark:bg-stroke/10"])},null,2)),64))]),t("span",ba,n(h(s.packet.rssi).className.replace("signal-","")),1)])])):(l(),i("div",va,e[40]||(e[40]=[t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-2"},"Signal Quality",-1),t("div",{class:"text-content-muted dark:text-content-muted text-sm italic"},"N/A (TX Packet)",-1)]))),s.packet.is_trace&&s.packet.path_snr_details&&s.packet.path_snr_details.length>0?(l(),i("div",ga,[t("div",ha,"Path SNR Details ("+n(s.packet.path_snr_details.length)+" hops)",1),t("div",fa,[(l(!0),i(U,null,X(s.packet.path_snr_details,(c,d)=>(l(),i("div",{key:d,class:"flex items-center justify-between p-2 glass-card bg-background-mute dark:bg-black/20 rounded-[8px]"},[t("div",ka,[t("span",_a,n(d+1)+".",1),t("span",{class:R(["font-mono text-xs text-content-primary dark:text-content-primary",m.localHash&&c.hash===m.localHash?"bg-cyan-400/20 text-cyan-600 dark:text-cyan-300 px-1 rounded":""])},n(c.hash),3)]),t("span",{class:R(["text-sm font-bold",r(c.snr_db,null)])},n(c.snr_db.toFixed(1))+"dB ",3)]))),128))])])):w("",!0),s.packet.transmitted&&s.packet.lbt_attempts!==void 0?(l(),i("div",wa,[e[45]||(e[45]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-3 flex items-center"},[t("svg",{class:"w-4 h-4 mr-2",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"})]),rt(" Listen Before Talk (LBT) Metrics ")],-1)),t("div",$a,[t("div",Ta,[e[41]||(e[41]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"CAD Attempts",-1)),t("div",Sa,n(s.packet.lbt_attempts),1)]),t("div",Ra,[e[42]||(e[42]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Total LBT Delay",-1)),t("div",Pa,n(y(b(s.packet.lbt_backoff_delays_ms).reduce((c,d)=>c+d,0))),1),t("div",Ca,n(b(s.packet.lbt_backoff_delays_ms).length)+" backoffs ",1)]),t("div",Aa,[e[43]||(e[43]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Channel Status",-1)),t("div",{class:R(["text-lg font-bold",s.packet.lbt_channel_busy?"text-yellow-600 dark:text-yellow-400":"text-green-600 dark:text-green-400"])},n(s.packet.lbt_channel_busy?"BUSY":"CLEAR"),3),t("div",Ma,n(s.packet.lbt_channel_busy?"Waited for clear":"Immediate TX"),1)])]),b(s.packet.lbt_backoff_delays_ms).length>0?(l(),i("div",Da,[e[44]||(e[44]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-3 font-semibold"},"Backoff Pattern (Exponential with Jitter)",-1)),t("div",Na,[(l(!0),i(U,null,X(b(s.packet.lbt_backoff_delays_ms),(c,d)=>(l(),i("div",{key:d,class:"flex items-center gap-3"},[t("div",Ba,[t("span",Fa,"Attempt "+n(d+1),1)]),t("div",ja,[t("div",Ea,[t("div",{class:R(["h-full rounded-lg transition-all duration-300",[d===0?"bg-gradient-to-r from-cyan-500/50 to-cyan-600/50":d===1?"bg-gradient-to-r from-yellow-500/50 to-yellow-600/50":d===2?"bg-gradient-to-r from-orange-500/50 to-orange-600/50":"bg-gradient-to-r from-red-500/50 to-red-600/50"]]),style:mt({width:`${Math.min(100,c/Math.max(...b(s.packet.lbt_backoff_delays_ms))*100)}%`})},[t("div",Ia,[t("span",Ua,n(y(c)),1)])],6)])]),t("div",La,[t("span",Va,n(Math.round(c/b(s.packet.lbt_backoff_delays_ms).reduce((o,x)=>o+x,0)*100))+"% ",1)])]))),128))])])):w("",!0)])):w("",!0),t("div",Ha,[t("div",za,[t("div",Xa,[e[46]||(e[46]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"TX Delay",-1)),t("span",Ga,n(Number(s.packet.tx_delay_ms)>0?Number(s.packet.tx_delay_ms).toFixed(1)+"ms":"-"),1)]),t("div",Oa,[e[47]||(e[47]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Transmitted",-1)),t("span",{class:R(s.packet.transmitted?"text-green-600 dark:text-green-400":"text-red-600 dark:text-red-400")},n(s.packet.transmitted?"Yes":"No"),3)])]),t("div",Qa,[t("div",qa,[e[48]||(e[48]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Is Duplicate",-1)),t("span",{class:R(s.packet.is_duplicate?"text-amber-600 dark:text-amber-400":"text-content-muted dark:text-content-muted")},n(s.packet.is_duplicate?"Yes":"No"),3)]),s.packet.drop_reason?(l(),i("div",Wa,[e[49]||(e[49]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Drop Reason",-1)),t("span",Ka,n(s.packet.drop_reason),1)])):w("",!0)])])])])]),t("div",Ja,[t("button",{onClick:e[2]||(e[2]=c=>P("close")),class:"px-6 py-2 bg-gradient-to-r from-cyan-500/20 to-cyan-400/20 hover:from-cyan-500/30 hover:to-cyan-400/30 border border-cyan-400/30 rounded-[10px] text-content-primary dark:text-content-primary transition-all duration-200 backdrop-blur-sm"}," Close ")])])])],32)):w("",!0)]),_:1})]))}}),Za=ut(Ya,[["__scopeId","data-v-7f139e4b"]]),tn={class:"glass-card rounded-[20px] p-6"},en={class:"flex flex-col lg:flex-row lg:justify-between lg:items-center mb-6 gap-4 filter-container"},sn={class:"flex items-center gap-2 header-info relative"},an={class:"text-content-secondary dark:text-content-muted text-sm packet-count"},nn=["title"],on={class:"hidden sm:inline"},rn={key:1,class:"text-accent-red text-sm error-indicator"},ln={class:"flex items-center gap-3 lg:flex filter-controls"},dn={class:"flex flex-col"},cn=["value"],un={class:"flex flex-col"},pn=["value"],mn={class:"flex flex-col"},xn={class:"flex flex-col reset-container"},yn=["disabled"],bn={class:"space-y-4 overflow-hidden"},vn={class:"space-y-4"},gn=["onClick"],hn={class:"hidden lg:grid grid-cols-12 gap-2 items-center"},fn={class:"col-span-1 text-content-primary dark:text-content-primary text-sm"},kn={class:"col-span-1 flex items-center gap-2"},_n={class:"flex flex-col"},wn={class:"text-content-primary dark:text-content-primary text-xs"},$n=["title"],Tn={class:"col-span-2"},Sn={class:"col-span-1 text-content-primary dark:text-content-primary text-xs"},Rn={class:"col-span-2"},Pn={class:"space-y-1"},Cn={key:0,class:"flex items-center gap-0.5 flex-wrap"},An={key:0,class:"w-2.5 h-2.5 text-content-muted dark:text-content-muted/60",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Mn={key:0,class:"text-[9px] text-content-muted dark:text-content-muted ml-1"},Dn={key:1,class:"flex items-center gap-1"},Nn={class:"inline-block px-2 py-0.5 rounded bg-badge-cyan-bg text-badge-cyan-text text-xs font-mono"},Bn={class:"col-span-1 text-content-primary dark:text-content-primary text-xs"},Fn={class:"col-span-1 text-content-primary dark:text-content-primary text-xs"},jn={class:"col-span-1 text-content-primary dark:text-content-primary text-xs"},En={class:"col-span-1 text-content-primary dark:text-content-primary text-xs"},In={key:0,class:"flex items-center gap-1"},Un={class:"col-span-1"},Ln={key:0,class:"text-accent-red text-[8px] italic truncate"},Vn={class:"lg:hidden space-y-2"},Hn={class:"flex items-center justify-between"},zn={class:"flex items-center gap-2"},Xn={class:"flex flex-col"},Gn={class:"text-content-primary dark:text-content-primary text-sm font-medium"},On=["title"],Qn={class:"flex items-center gap-2 text-right"},qn={class:"text-content-secondary dark:text-content-muted text-xs"},Wn={class:"flex items-center justify-between"},Kn={class:"flex items-center gap-1.5"},Jn={key:0,class:"flex items-center gap-0.5"},Yn={key:0,class:"w-2.5 h-2.5 text-content-muted dark:text-content-muted/60",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Zn={key:0,class:"text-[9px] text-content-muted dark:text-content-muted ml-1"},to={class:"flex items-center gap-1"},eo={class:"inline-block px-2 py-0.5 rounded bg-badge-cyan-bg text-badge-cyan-text text-xs font-mono font-semibold"},so={class:"flex items-center gap-0.5 text-content-muted dark:text-content-muted/60"},ao={key:0,class:"text-[9px] font-medium",title:"Multi-hop path"},no={class:"flex items-center gap-1"},oo={class:"flex items-center gap-2"},ro={class:"flex items-center gap-1"},lo={key:0,class:"flex gap-0.5"},io={class:"text-content-primary dark:text-content-primary text-xs"},co={class:"flex items-center justify-between text-content-secondary dark:text-content-muted text-xs"},uo={class:"flex items-center gap-3"},po={class:"flex items-center gap-2"},mo={key:0,class:"flex items-center gap-1"},xo={key:0,class:"text-accent-red text-xs italic"},yo={key:0,class:"flex justify-between items-center mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke pagination-container"},bo={class:"flex items-center gap-4 pagination-info"},vo={class:"text-content-secondary dark:text-content-muted text-sm"},go={key:0,class:"flex items-center gap-2 load-more-section"},ho=["disabled"],fo={class:"text-content-secondary dark:text-content-muted text-xs load-more-count"},ko={class:"flex items-center gap-2 pagination-controls"},_o=["disabled"],wo={class:"flex items-center gap-1 page-numbers"},$o={key:1,class:"text-content-secondary dark:text-content-muted text-sm px-2 ellipsis"},To=["onClick"],So={key:2,class:"text-content-secondary dark:text-content-muted text-sm px-2 ellipsis"},Ro=["disabled"],Po={key:1,class:"flex justify-center mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke"},Co={class:"flex items-center gap-4"},Ao={class:"text-content-secondary dark:text-content-muted text-sm"},Mo={class:"text-content-secondary dark:text-content-muted text-xs"},Do={key:2,class:"flex justify-center mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke"},pt=10,lt=1e3,No=dt({name:"PacketTable",__name:"PacketTable",setup(at){const C=gt(),G=kt(),m=B(1),P=B(null),T=B(100),A=B(!1),O=B(!1);let v=null;Z(()=>C.isLoading,p=>{p?(v&&(clearTimeout(v),v=null),O.value=!0):v=window.setTimeout(()=>{O.value=!1,v=null},600)});const D=B(null),j=B(!1),q=p=>{D.value=p,j.value=!0},V=()=>{j.value=!1,D.value=null},I=B(St("packetTable_selectedType","all")),S=B(St("packetTable_selectedRoute","all")),k=B(!1),r=B(null),h=["all","0","1","2","3","4","5","6","7","8","9","10","11"],b=["all","1","2"];Z(I,p=>{Rt("packetTable_selectedType",p),m.value=1}),Z(S,p=>{Rt("packetTable_selectedRoute",p),m.value=1}),Z(k,()=>{m.value=1});const y=W(()=>{let p=C.recentPackets;if(I.value!=="all"){const u=parseInt(I.value);p=p.filter(a=>a.type===u)}if(S.value!=="all"){const u=parseInt(S.value);p=p.filter(a=>a.route===u)}return k.value&&r.value!==null&&(p=p.filter(u=>u.timestamp>=r.value)),p}),M=W(()=>{const p=(m.value-1)*pt,u=p+pt;return y.value.slice(p,u)}),$=W(()=>Math.ceil(y.value.length/pt)),s=W(()=>m.value===$.value),e=W(()=>C.recentPackets.length>=T.value&&T.values.value&&e.value&&!A.value),d=p=>new Date(p*1e3).toLocaleTimeString(void 0,{hour12:!0}),o=p=>({0:"REQ",1:"RESPONSE",2:"TXT_MSG",3:"ACK",4:"ADVERT",5:"GRP_TXT",6:"GRP_DATA",7:"ANON_REQ",8:"PATH",9:"TRACE",10:"MULTI_PART",11:"CONTROL"})[p]||`TYPE_${p}`,x=p=>({0:"T-Flood",1:"Flood",2:"Direct",3:"T-Direct"})[p]||`Route ${p}`,_=p=>p.transmitted?"text-accent-green":"text-primary",N=p=>p.drop_reason?"Dropped":p.transmitted?"Forward":"Received",E=p=>p===1?"bg-badge-cyan-bg text-badge-cyan-text":"bg-badge-neutral-bg text-badge-neutral-text",K=p=>({0:"bg-primary",1:"bg-accent-green",2:"bg-secondary",3:"bg-accent-purple",4:"bg-accent-red",5:"bg-accent-cyan",6:"bg-primary",7:"bg-accent-purple",8:"bg-accent-green",9:"bg-secondary"})[p]||"bg-gray-500",tt=p=>({0:"border-l-primary",1:"border-l-accent-green",2:"border-l-secondary",3:"border-l-accent-purple",4:"border-l-accent-red",5:"border-l-accent-cyan",6:"border-l-primary",7:"border-l-accent-purple",8:"border-l-accent-green",9:"border-l-secondary"})[p]||"border-l-gray-500",L=p=>!p.transmitted||!p.lbt_attempts||p.lbt_attempts===0?"bg-green-400":p.lbt_attempts===1?"bg-cyan-400":p.lbt_attempts===2?"bg-yellow-400":"bg-orange-400",H=p=>p>=1e3?(p/1e3).toFixed(2)+"s":p.toFixed(1)+"ms",f=p=>{if(!p)return[];if(Array.isArray(p))return p;if(typeof p=="string")try{const u=JSON.parse(p);return typeof u=="string"?JSON.parse(u):Array.isArray(u)?u:[]}catch{return[]}return[]},g=p=>{const u=f(p.original_path),a=f(p.forwarded_path),z=u.length>0?u:a;return z.length===0?null:{hops:z.length-1,nodes:z.map(ot=>ot.slice(-4).toUpperCase())}},F=p=>{if(p.type!==4||!p.payload)return null;try{const u=p.payload.replace(/\s+/g,"").toUpperCase();let a=u,z=0;if(u.length/2>=100)if(u.length>200)a=u.slice(200),z=0;else return null;if(a.length>=2){const J=parseInt(a.slice(0,2),16);z+=2;const Ct=!!(J&16),At=!!(J&32),Mt=!!(J&64);if(!!!(J&128))return null;if(Ct&&a.length>=z+16&&(z+=16),At&&a.length>=z+4&&(z+=4),Mt&&a.length>=z+4&&(z+=4),a.length>z){const _t=(a.slice(z).match(/.{2}/g)||[]).map(Dt=>{const ht=parseInt(Dt,16);return ht>=32&&ht<=126?String.fromCharCode(ht):"."}).join("").replace(/\.*$/,"");return _t.length>0?_t:null}}}catch(u){console.error("Error parsing ADVERT node name:",u)}return null},Q=()=>{I.value="all",S.value="all",k.value=!1,r.value=null,m.value=1},et=()=>{k.value?(k.value=!1,r.value=null):(k.value=!0,r.value=Date.now()/1e3),m.value=1},Y=W(()=>r.value?new Date(r.value*1e3).toLocaleTimeString(void 0,{hour12:!0}):""),nt=async p=>{try{const u=p||T.value;await C.fetchRecentPackets({limit:u})}catch(u){console.error("Error fetching packet data:",u)}},yt=async()=>{if(!(A.value||T.value>=lt)){A.value=!0;try{const p=Math.min(T.value+200,lt);T.value=p,await nt(p)}catch(p){console.error("Error loading more records:",p)}finally{A.value=!1}}};return xt(async()=>{await nt(),G.isConnected||(P.value=window.setInterval(nt,1e4))}),Z(()=>G.isConnected,p=>{p?P.value&&(clearInterval(P.value),P.value=null):P.value||(P.value=window.setInterval(nt,1e4))}),vt(()=>{P.value&&clearInterval(P.value),v&&clearTimeout(v)}),(p,u)=>(l(),i(U,null,[t("div",tn,[t("div",en,[t("div",sn,[u[7]||(u[7]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold"},"Recent Packets",-1)),t("span",an," ("+n(y.value.length)+" of "+n(ct(C).recentPackets.length)+") ",1),k.value?(l(),i("span",{key:0,class:"text-primary text-xs sm:text-sm bg-primary/10 px-2 py-1 rounded-md border border-primary/20 live-mode-badge whitespace-nowrap",title:`Filter activated at ${Y.value}`},[t("span",on,"Live Mode (since "+n(Y.value)+")",1),u[6]||(u[6]=t("span",{class:"sm:hidden"},"Live",-1))],8,nn)):w("",!0),ct(C).error?(l(),i("span",rn,n(ct(C).error),1)):w("",!0)]),t("div",ln,[t("div",dn,[u[8]||(u[8]=t("label",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Type",-1)),$t(t("select",{"onUpdate:modelValue":u[0]||(u[0]=a=>I.value=a),class:"glass-card border border-stroke-subtle dark:border-stroke rounded-[10px] px-3 py-2 text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all duration-200 min-w-[120px] cursor-pointer hover:border-primary/50"},[(l(),i(U,null,X(h,a=>t("option",{key:a,value:a,class:"bg-surface dark:bg-surface-elevated text-content-primary dark:text-content-primary"},n(a==="all"?"All Types":`Type ${a} (${o(parseInt(a))})`),9,cn)),64))],512),[[Tt,I.value]])]),t("div",un,[u[9]||(u[9]=t("label",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Route",-1)),$t(t("select",{"onUpdate:modelValue":u[1]||(u[1]=a=>S.value=a),class:"glass-card border border-stroke-subtle dark:border-stroke rounded-[10px] px-3 py-2 text-content-primary dark:text-content-primary text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20 transition-all duration-200 min-w-[120px] cursor-pointer hover:border-primary/50"},[(l(),i(U,null,X(b,a=>t("option",{key:a,value:a,class:"bg-surface dark:bg-surface-elevated text-content-primary dark:text-content-primary"},n(a==="all"?"All Routes":`Route ${a} (${x(parseInt(a))})`),9,pn)),64))],512),[[Tt,S.value]])]),t("div",mn,[u[10]||(u[10]=t("label",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Filter",-1)),t("button",{onClick:et,class:R(["glass-card border rounded-[10px] px-4 py-2 text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20 min-w-[120px]",{"border-primary bg-primary/10 text-primary":k.value,"border-stroke-subtle dark:border-stroke text-content-secondary dark:text-content-muted hover:border-primary hover:text-content-primary dark:hover:text-content-primary hover:bg-primary/5":!k.value}])},n(k.value?"New Only":"Show New"),3)]),t("div",xn,[u[11]||(u[11]=t("label",{class:"text-transparent text-xs mb-1"},".",-1)),t("button",{onClick:Q,class:R(["glass-card border border-stroke-subtle dark:border-stroke hover:border-primary rounded-[10px] px-4 py-2 text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary text-sm transition-all duration-200 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/20",{"opacity-50 cursor-not-allowed hover:border-stroke-subtle dark:hover:border-stroke hover:text-content-secondary dark:hover:text-content-muted":I.value==="all"&&S.value==="all"&&!k.value,"hover:bg-primary/10":I.value!=="all"||S.value!=="all"||k.value}]),disabled:I.value==="all"&&S.value==="all"&&!k.value}," Reset ",10,yn)])])]),u[25]||(u[25]=Pt('',1)),t("div",bn,[t("div",vn,[(l(!0),i(U,null,X(M.value,(a,z)=>(l(),i("div",{key:`${a.packet_hash}_${a.timestamp}_${z}`,class:R(["packet-row border-b border-stroke-subtle dark:border-dark-border/50 pb-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors duration-150 cursor-pointer rounded-[10px] p-2 border-l-4",tt(a.type)]),onClick:ot=>q(a)},[t("div",hn,[t("div",fn,n(d(a.timestamp)),1),t("div",kn,[t("div",{class:R(["w-2 h-2 rounded-full",K(a.type)])},null,2),t("div",_n,[t("span",wn,n(o(a.type)),1),a.type===4&&F(a)?(l(),i("span",{key:0,class:"text-accent-red/70 text-[10px] font-medium max-w-[80px] truncate",title:F(a)||void 0},n(F(a)),9,$n)):w("",!0)])]),t("div",Tn,[t("span",{class:R(["inline-block px-2 py-1 rounded text-xs font-medium",E(a.route)])},n(x(a.route)),3)]),t("div",Sn,n(a.length)+"B",1),t("div",Rn,[t("div",Pn,[g(a)?(l(),i("div",Cn,[(l(!0),i(U,null,X(g(a).nodes,(ot,J)=>(l(),i(U,{key:J},[t("span",{class:R(["inline-block px-1.5 py-0.5 rounded text-[10px] font-mono font-semibold",J===0?"bg-badge-cyan-bg text-badge-cyan-text":"bg-gray-500/20 text-content-muted dark:text-content-muted"])},n(ot),3),J0?(l(),i("span",Mn," ("+n(g(a).hops)+" hop"+n(g(a).hops>1?"s":"")+") ",1)):w("",!0)])):(l(),i("div",Dn,[t("span",Nn,n(a.src_hash?.slice(-4).toUpperCase()||"????"),1),u[13]||(u[13]=t("svg",{class:"w-3 h-3 text-content-muted dark:text-content-muted/60",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2.5",d:"M9 5l7 7-7 7"})],-1)),t("span",{class:R(["inline-block px-2 py-0.5 rounded text-xs font-mono",a.dst_hash?"bg-badge-cyan-bg text-badge-cyan-text":"bg-yellow-500/20 text-yellow-700 dark:text-yellow-300"])},n(a.dst_hash?a.dst_hash.slice(-4).toUpperCase():"BCAST"),3)]))])]),t("div",Bn,n(a.rssi!=null?a.rssi.toFixed(0):"N/A"),1),t("div",Fn,n(a.snr!=null?a.snr.toFixed(1)+"dB":"N/A"),1),t("div",jn,n(a.score!=null?a.score.toFixed(2):"N/A"),1),t("div",En,[Number(a.tx_delay_ms)>0?(l(),i("div",In,[a.transmitted?(l(),i("div",{key:0,class:R(["w-1.5 h-1.5 rounded-full flex-shrink-0",L(a)])},null,2)):w("",!0),t("span",null,n(H(Number(a.tx_delay_ms))),1)])):w("",!0)]),t("div",Un,[t("div",null,[t("span",{class:R(["text-xs font-medium",_(a)])},n(N(a)),3),a.drop_reason?(l(),i("p",Ln,n(a.drop_reason),1)):w("",!0)])])]),t("div",Vn,[t("div",Hn,[t("div",zn,[t("div",{class:R(["w-2 h-2 rounded-full flex-shrink-0",K(a.type)])},null,2),t("div",Xn,[t("span",Gn,n(o(a.type)),1),a.type===4&&F(a)?(l(),i("span",{key:0,class:"text-accent-red/70 text-[10px] font-medium leading-tight",title:F(a)||void 0},n(F(a)),9,On)):w("",!0)]),t("span",{class:R(["inline-block px-2 py-1 rounded text-xs font-medium ml-2",E(a.route)])},n(x(a.route)),3)]),t("div",Qn,[t("span",qn,n(d(a.timestamp)),1),t("span",{class:R(["text-xs font-medium",_(a)])},n(N(a)),3)])]),t("div",Wn,[t("div",Kn,[g(a)?(l(),i("div",Jn,[u[15]||(u[15]=t("span",{class:"text-content-muted dark:text-content-muted text-[10px] font-medium"},"PATH",-1)),(l(!0),i(U,null,X(g(a).nodes,(ot,J)=>(l(),i(U,{key:J},[t("span",{class:R(["inline-block px-1.5 py-0.5 rounded text-[10px] font-mono font-semibold",J===0?"bg-badge-cyan-bg text-badge-cyan-text":"bg-gray-500/20 text-content-muted dark:text-content-muted"])},n(ot),3),J0?(l(),i("span",Zn," ("+n(g(a).hops)+" hop"+n(g(a).hops>1?"s":"")+") ",1)):w("",!0)])):(l(),i(U,{key:1},[t("div",to,[u[16]||(u[16]=t("span",{class:"text-content-muted dark:text-content-muted text-[10px] font-medium"},"SRC",-1)),t("span",eo,n(a.src_hash?.slice(-4)||"????"),1)]),t("div",so,[u[18]||(u[18]=t("svg",{class:"w-3 h-3",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2.5",d:"M9 5l7 7-7 7"})],-1)),a.route===1?(l(),i("span",ao,u[17]||(u[17]=[t("svg",{class:"w-2.5 h-2.5 inline",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 5l7 7-7 7M5 5l7 7-7 7"})],-1)]))):w("",!0)]),t("div",no,[t("span",{class:R(["inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold",a.dst_hash?"bg-badge-cyan-bg text-badge-cyan-text":"bg-yellow-500/20 text-yellow-700 dark:text-yellow-300"])},n(a.dst_hash?a.dst_hash.slice(-4).toUpperCase():"BCAST"),3),u[19]||(u[19]=t("span",{class:"text-content-muted dark:text-content-muted text-[10px] font-medium"},"DST",-1))])],64))]),t("div",oo,[t("div",ro,[a.snr!=null?(l(),i("div",lo,[t("div",{class:R(["w-1 h-3 rounded-sm",a.snr>=-10?"bg-green-400":"bg-white/20"])},null,2),t("div",{class:R(["w-1 h-4 rounded-sm",a.snr>=-5?"bg-green-400":"bg-white/20"])},null,2),t("div",{class:R(["w-1 h-5 rounded-sm",a.snr>=0?"bg-green-400":"bg-white/20"])},null,2),t("div",{class:R(["w-1 h-6 rounded-sm",a.snr>=10?"bg-green-400":"bg-white/20"])},null,2)])):w("",!0),t("span",io,n(a.rssi!=null?a.rssi.toFixed(0)+"dBm":"TX"),1)])])]),t("div",co,[t("div",uo,[t("span",null,n(a.length)+"B",1),t("span",null,"SNR: "+n(a.snr!=null?a.snr.toFixed(1)+"dB":"N/A"),1),t("span",null,"Score: "+n(a.score!=null?a.score.toFixed(2):"N/A"),1)]),t("div",po,[Number(a.tx_delay_ms)>0?(l(),i("span",mo,[a.transmitted?(l(),i("div",{key:0,class:R(["w-1.5 h-1.5 rounded-full flex-shrink-0",L(a)])},null,2)):w("",!0),t("span",null,n(H(Number(a.tx_delay_ms))),1)])):w("",!0)])]),a.drop_reason?(l(),i("div",xo,n(a.drop_reason),1)):w("",!0)])],10,gn))),128))])]),$.value>1?(l(),i("div",yo,[t("div",bo,[t("span",vo," Showing "+n((m.value-1)*pt+1)+" - "+n(Math.min(m.value*pt,y.value.length))+" of "+n(y.value.length)+" packets ",1),c.value?(l(),i("div",go,[u[20]||(u[20]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs"},"•",-1)),t("button",{onClick:yt,disabled:A.value,class:R(["glass-card border border-primary rounded-[8px] px-3 py-1.5 text-xs transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20 hover:bg-primary/5",{"text-primary border-primary cursor-pointer":!A.value,"text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke cursor-not-allowed opacity-50":A.value}])},n(A.value?"Loading...":`Load ${Math.min(200,lt-T.value)} more`),11,ho),t("span",fo,"("+n(T.value)+"/"+n(lt)+" max)",1)])):w("",!0)]),t("div",ko,[t("button",{onClick:u[2]||(u[2]=a=>m.value=m.value-1),disabled:m.value<=1,class:R(["glass-card border rounded-[10px] px-3 py-2 text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20 prev-next-btn",{"border-stroke-subtle dark:border-stroke text-content-muted dark:text-content-muted cursor-not-allowed opacity-50":m.value<=1,"border-stroke-subtle dark:border-stroke text-content-primary dark:text-content-primary hover:border-primary hover:text-primary hover:bg-primary/5":m.value>1}])},u[21]||(u[21]=[t("span",{class:"hidden sm:inline"},"Previous",-1),t("span",{class:"sm:hidden"},"‹",-1)]),10,_o),t("div",wo,[m.value>3?(l(),i("button",{key:0,onClick:u[3]||(u[3]=a=>m.value=1),class:"glass-card border border-stroke-subtle dark:border-stroke hover:border-primary rounded-[8px] px-3 py-2 text-sm text-content-primary dark:text-content-primary hover:text-primary hover:bg-primary/5 transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20"}," 1 ")):w("",!0),m.value>4?(l(),i("span",$o,"...")):w("",!0),(l(!0),i(U,null,X(Array.from({length:Math.min(5,$.value)},(a,z)=>Math.max(1,Math.min(m.value-2,$.value-4))+z).filter(a=>a<=$.value),a=>(l(),i("button",{key:a,onClick:z=>m.value=a,class:R(["glass-card border rounded-[8px] px-3 py-2 text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20 page-number",{"border-primary bg-primary/10 text-primary":m.value===a,"border-stroke-subtle dark:border-stroke text-content-primary dark:text-content-primary hover:border-primary hover:text-primary hover:bg-primary/5":m.value!==a}])},n(a),11,To))),128)),m.value<$.value-3?(l(),i("span",So,"...")):w("",!0),m.value<$.value-2?(l(),i("button",{key:3,onClick:u[4]||(u[4]=a=>m.value=$.value),class:"glass-card border border-stroke-subtle dark:border-stroke hover:border-primary rounded-[8px] px-3 py-2 text-sm text-content-primary dark:text-content-primary hover:text-primary hover:bg-primary/5 transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20"},n($.value),1)):w("",!0)]),t("button",{onClick:u[5]||(u[5]=a=>m.value=m.value+1),disabled:m.value>=$.value,class:R(["glass-card border rounded-[10px] px-3 py-2 text-sm transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20 prev-next-btn",{"border-stroke-subtle dark:border-stroke text-content-muted dark:text-content-muted cursor-not-allowed opacity-50":m.value>=$.value,"border-stroke-subtle dark:border-stroke text-content-primary dark:text-content-primary hover:border-primary hover:text-primary hover:bg-primary/5":m.value<$.value}])},u[22]||(u[22]=[t("span",{class:"hidden sm:inline"},"Next",-1),t("span",{class:"sm:inline"},"›",-1)]),10,Ro)])])):e.value&&!A.value?(l(),i("div",Po,[t("div",Co,[t("span",Ao," Showing "+n(y.value.length)+" packets ",1),u[23]||(u[23]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs"},"•",-1)),t("button",{onClick:yt,class:"glass-card border border-primary rounded-[8px] px-4 py-2 text-sm text-primary hover:bg-primary/5 transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-primary/20"}," Load "+n(Math.min(200,lt-T.value))+" more records ",1),t("span",Mo,"("+n(T.value)+"/"+n(lt)+" max)",1)])])):A.value?(l(),i("div",Do,u[24]||(u[24]=[t("div",{class:"flex items-center gap-2"},[t("div",{class:"w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin"}),t("span",{class:"text-primary text-sm"},"Loading more records...")],-1)]))):w("",!0)]),st(Za,{packet:D.value,isOpen:j.value,onClose:V},null,8,["packet","isOpen"])],64))}}),Bo=ut(No,[["__scopeId","data-v-ec32abd6"]]),Fo={class:"grid grid-cols-1 lg:grid-cols-2 gap-4 mb-2"},zo=dt({name:"DashboardView",__name:"Dashboard",setup(at){return(C,G)=>(l(),i("div",null,[st(te),t("div",Fo,[st(je),st(he)]),st(Bo)]))}});export{zo as default}; diff --git a/repeater/web/html/assets/Help-DE02Ddt_.js b/repeater/web/html/assets/Help-DWQEtjHZ.js similarity index 96% rename from repeater/web/html/assets/Help-DE02Ddt_.js rename to repeater/web/html/assets/Help-DWQEtjHZ.js index 217e663..aed3bd9 100644 --- a/repeater/web/html/assets/Help-DE02Ddt_.js +++ b/repeater/web/html/assets/Help-DWQEtjHZ.js @@ -1 +1 @@ -import{a as e,b as r,i as o,p as n}from"./index-sHch0610.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('

Help & Documentation

pyMC Repeater Wiki

Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki.

Visit Wiki Documentation
Opens in a new tab
',1)])))}});export{d as default}; +import{a as e,b as r,i as o,p as n}from"./index-DyUIpN7m.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('

Help & Documentation

pyMC Repeater Wiki

Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki.

Visit Wiki Documentation
Opens in a new tab
',1)])))}});export{d as default}; diff --git a/repeater/web/html/assets/Login-dBhjapfH.js b/repeater/web/html/assets/Login-BwBtx78C.js similarity index 99% rename from repeater/web/html/assets/Login-dBhjapfH.js rename to repeater/web/html/assets/Login-BwBtx78C.js index bc7f19d..49bcddb 100644 --- a/repeater/web/html/assets/Login-dBhjapfH.js +++ b/repeater/web/html/assets/Login-BwBtx78C.js @@ -1 +1 @@ -import{a as P,r as a,b as i,g,s as _,e,w as v,v as h,t as y,k as M,y as S,p as u,f as w,_ as $,i as N,G as j,C as B,z as D,m as I,A as L,B as C,x as U}from"./index-sHch0610.js";const q={class:"glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/20 rounded-[15px] p-6 max-w-md w-full shadow-2xl"},E={key:0,class:"bg-red-500/10 border border-red-500/30 rounded-lg p-3"},z={class:"text-red-600 dark:text-red-400 text-sm"},T={key:1,class:"bg-green-500/10 border border-green-600/40 dark:border-green-500/30 rounded-lg p-3"},G={class:"text-green-600 dark:text-green-400 text-sm"},H={class:"flex justify-end gap-3 mt-6"},F=["disabled"],O=["disabled"],R={key:0,class:"w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"},A=P({name:"ChangePasswordModal",__name:"ChangePasswordModal",props:{isOpen:{type:Boolean},canSkip:{type:Boolean,default:!0}},emits:["close","success"],setup(V,{emit:x}){const p=x,l=a(""),s=a(""),d=a(""),o=a(!1),n=a(""),m=a(""),f=()=>{o.value||p("close")},k=()=>{p("close")},c=async()=>{if(n.value="",m.value="",s.value.length<8){n.value="New password must be at least 8 characters long";return}if(s.value!==d.value){n.value="Passwords do not match";return}if(s.value===l.value){n.value="New password must be different from current password";return}o.value=!0;try{const r=(await S.post("/auth/change_password",{current_password:l.value,new_password:s.value})).data;r&&r.success?(m.value=r.message||"Password changed successfully!",setTimeout(()=>{p("success"),p("close")},1500)):n.value=r?.error||"Failed to change password"}catch(t){console.error("Password change error:",t),n.value=t.response?.data?.error||"Failed to change password. Please try again."}finally{o.value=!1}};return(t,r)=>t.isOpen?(u(),i("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm",onClick:_(f,["self"])},[e("div",q,[r[6]||(r[6]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary mb-2"},"Change Default Password",-1)),r[7]||(r[7]=e("p",{class:"text-content-secondary dark:text-content-muted text-sm mb-6"}," You're using the default password. Please change it to secure your account. ",-1)),e("form",{onSubmit:_(c,["prevent"]),class:"space-y-4"},[e("div",null,[r[3]||(r[3]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-primary/70 mb-2"},"Current Password",-1)),v(e("input",{"onUpdate:modelValue":r[0]||(r[0]=b=>l.value=b),type:"password",required:"",class:"w-full px-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary transition-colors",placeholder:"Enter current password"},null,512),[[h,l.value]])]),e("div",null,[r[4]||(r[4]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-primary/70 mb-2"},"New Password",-1)),v(e("input",{"onUpdate:modelValue":r[1]||(r[1]=b=>s.value=b),type:"password",required:"",minlength:"8",class:"w-full px-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary transition-colors",placeholder:"Enter new password (min 8 characters)"},null,512),[[h,s.value]])]),e("div",null,[r[5]||(r[5]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-primary/70 mb-2"},"Confirm New Password",-1)),v(e("input",{"onUpdate:modelValue":r[2]||(r[2]=b=>d.value=b),type:"password",required:"",minlength:"8",class:"w-full px-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary transition-colors",placeholder:"Confirm new password"},null,512),[[h,d.value]])]),n.value?(u(),i("div",E,[e("p",z,y(n.value),1)])):g("",!0),m.value?(u(),i("div",T,[e("p",G,y(m.value),1)])):g("",!0),e("div",H,[t.canSkip?(u(),i("button",{key:0,type:"button",onClick:k,disabled:o.value,class:"px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/10 transition-colors disabled:opacity-50"}," Skip for Now ",8,F)):g("",!0),e("button",{type:"submit",disabled:o.value,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-white rounded-lg border border-primary/50 transition-colors disabled:opacity-50 flex items-center gap-2"},[o.value?(u(),i("div",R)):g("",!0),M(" "+y(o.value?"Changing...":"Change Password"),1)],8,O)])],32)])])):g("",!0)}}),Y={class:"min-h-screen bg-background dark:bg-background overflow-hidden relative flex items-start sm:items-center justify-center p-2 sm:p-4 pt-8 sm:pt-4"},J={class:"absolute top-4 right-4 z-20"},K={class:"login-card relative z-10 w-full max-w-md p-6 sm:p-10 rounded-[16px] sm:rounded-[24px] border-0 sm:border sm:border-stroke-subtle dark:sm:border-stroke/20 shadow-[0_8px_32px_0_rgba(0,0,0,0.1)] dark:shadow-[0_8px_32px_0_rgba(0,0,0,0.37)] backdrop-blur-xl"},Q={class:"relative login-content"},W={class:"form-group"},X={class:"relative"},Z=["disabled"],ee={class:"form-group"},te={class:"relative"},re=["disabled"],se={key:0,class:"bg-red-500/10 border border-red-500/30 rounded-[12px] p-2.5 sm:p-3.5 backdrop-blur-sm animate-shake"},oe={class:"text-red-600 dark:text-red-400 text-xs sm:text-sm font-medium"},ae=["disabled"],ne={key:0,class:"w-4 h-4 sm:w-5 sm:h-5 border-2 border-white border-t-transparent rounded-full animate-spin"},le={key:1,class:"w-4 h-4 sm:w-5 sm:h-5 group-hover:translate-x-1 transition-transform duration-300",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},de={class:"relative"},ie={class:"mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-stroke-subtle dark:border-stroke/10"},ue={class:"flex items-center justify-center gap-3"},pe={href:"https://github.com/rightup",target:"_blank",class:"inline-flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-content-primary dark:bg-white/10 border border-stroke-subtle dark:border-stroke/20 hover:bg-primary/20 dark:hover:bg-primary/30 hover:border-primary/50 transition-all duration-300 hover:scale-110 group backdrop-blur-sm",title:"GitHub"},ce={href:"https://buymeacoffee.com/rightup",target:"_blank",class:"inline-flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-content-primary dark:bg-white/10 border border-stroke-subtle dark:border-stroke/20 hover:bg-yellow-50 dark:hover:bg-yellow-500/20 hover:border-yellow-500/50 transition-all duration-300 hover:scale-110 group backdrop-blur-sm",title:"Buy Me a Coffee"},me=P({name:"LoginView",__name:"Login",setup(V){const x=I(),p=a("admin"),l=a(""),s=a(!1),d=a(""),o=a(!1),n=a(!1),m=async()=>{d.value="",s.value=!0;try{const c=L(),r=(await S.post("/auth/login",{username:p.value,password:l.value,client_id:c})).data;r.success&&r.token?l.value==="admin123"?(C(r.token),n.value=!0,o.value=!0):(C(r.token),x.push("/")):d.value=r.error||"Login failed"}catch(c){console.error("Login error:",c);const t=c;d.value=t.response?.data?.error||"Connection error. Please try again."}finally{s.value=!1}},f=()=>{o.value=!1,x.push("/")},k=()=>{o.value=!1,n.value&&x.push("/")};return(c,t)=>(u(),i("div",Y,[e("div",J,[w($)]),t[9]||(t[9]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slow -top-[79px] left-[575px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[10]||(t[10]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-75 animate-pulse-slower -top-[94px] -left-[92px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[11]||(t[11]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slowest top-[373px] left-[246px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),e("div",K,[t[8]||(t[8]=e("div",{class:"absolute inset-0 rounded-[24px] bg-gradient-to-br from-primary/3 dark:from-primary/5 to-transparent pointer-events-none"},null,-1)),e("div",Q,[t[7]||(t[7]=N('
MeshCore

pyMC Repeater

Sign in to access your dashboard

',1)),e("form",{onSubmit:_(m,["prevent"]),class:"space-y-4 sm:space-y-5"},[e("div",W,[t[3]||(t[3]=e("label",{for:"username",class:"block text-content-secondary dark:text-content-primary/90 text-xs sm:text-sm font-medium mb-2"}," Username ",-1)),e("div",X,[v(e("input",{id:"username","onUpdate:modelValue":t[0]||(t[0]=r=>p.value=r),type:"text",autocomplete:"username",required:"",class:"input-glass w-full px-3 sm:px-4 py-2.5 sm:py-3.5 rounded-[12px] text-content-primary dark:text-content-primary text-sm placeholder-gray-400 dark:placeholder-white/30 focus:outline-none focus:border-primary/50 transition-all duration-300",placeholder:"Enter username",disabled:s.value},null,8,Z),[[h,p.value]]),t[2]||(t[2]=e("div",{class:"absolute inset-0 rounded-[12px] pointer-events-none input-glow"},null,-1))])]),e("div",ee,[t[5]||(t[5]=e("label",{for:"password",class:"block text-content-secondary dark:text-content-primary/90 text-xs sm:text-sm font-medium mb-2"}," Password ",-1)),e("div",te,[v(e("input",{id:"password","onUpdate:modelValue":t[1]||(t[1]=r=>l.value=r),type:"password",autocomplete:"current-password",required:"",class:"input-glass w-full px-3 sm:px-4 py-2.5 sm:py-3.5 rounded-[12px] text-content-primary dark:text-content-primary text-sm placeholder-gray-400 dark:placeholder-white/30 focus:outline-none focus:border-primary/50 transition-all duration-300",placeholder:"Enter password",disabled:s.value},null,8,re),[[h,l.value]]),t[4]||(t[4]=e("div",{class:"absolute inset-0 rounded-[12px] pointer-events-none input-glow"},null,-1))])]),d.value?(u(),i("div",se,[e("p",oe,y(d.value),1)])):g("",!0),e("button",{type:"submit",disabled:s.value,class:"button-glass w-full relative overflow-hidden bg-primary/20 hover:bg-primary/30 active:scale-[0.98] text-primary dark:text-white font-semibold py-3 sm:py-4 px-4 rounded-[12px] border border-primary/50 hover:border-primary/60 transition-all duration-300 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 sm:gap-2.5 group mt-6 sm:mt-8 text-sm sm:text-base backdrop-blur-sm"},[s.value?(u(),i("div",ne)):(u(),i("svg",le,t[6]||(t[6]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"},null,-1)]))),e("span",de,y(s.value?"Signing in...":"Sign In"),1)],8,ae)],32),e("div",ie,[e("div",ue,[e("a",pe,[w(j,{class:"w-5 h-5 sm:w-6 sm:h-6 text-white group-hover:text-primary transition-colors"})]),e("a",ce,[w(B,{class:"w-5 h-5 sm:w-6 sm:h-6 text-white group-hover:text-yellow-500 transition-colors"})])])])])]),w(A,{"is-open":o.value,"can-skip":!0,onClose:k,onSuccess:f},null,8,["is-open"])]))}}),ge=U(me,[["__scopeId","data-v-7d3a3377"]]);export{ge as default}; +import{a as P,r as a,b as i,g,s as _,e,w as v,v as h,t as y,k as M,y as S,p as u,f as w,_ as $,i as N,G as j,C as B,z as D,m as I,A as L,B as C,x as U}from"./index-DyUIpN7m.js";const q={class:"glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/20 rounded-[15px] p-6 max-w-md w-full shadow-2xl"},E={key:0,class:"bg-red-500/10 border border-red-500/30 rounded-lg p-3"},z={class:"text-red-600 dark:text-red-400 text-sm"},T={key:1,class:"bg-green-500/10 border border-green-600/40 dark:border-green-500/30 rounded-lg p-3"},G={class:"text-green-600 dark:text-green-400 text-sm"},H={class:"flex justify-end gap-3 mt-6"},F=["disabled"],O=["disabled"],R={key:0,class:"w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"},A=P({name:"ChangePasswordModal",__name:"ChangePasswordModal",props:{isOpen:{type:Boolean},canSkip:{type:Boolean,default:!0}},emits:["close","success"],setup(V,{emit:x}){const p=x,l=a(""),s=a(""),d=a(""),o=a(!1),n=a(""),m=a(""),f=()=>{o.value||p("close")},k=()=>{p("close")},c=async()=>{if(n.value="",m.value="",s.value.length<8){n.value="New password must be at least 8 characters long";return}if(s.value!==d.value){n.value="Passwords do not match";return}if(s.value===l.value){n.value="New password must be different from current password";return}o.value=!0;try{const r=(await S.post("/auth/change_password",{current_password:l.value,new_password:s.value})).data;r&&r.success?(m.value=r.message||"Password changed successfully!",setTimeout(()=>{p("success"),p("close")},1500)):n.value=r?.error||"Failed to change password"}catch(t){console.error("Password change error:",t),n.value=t.response?.data?.error||"Failed to change password. Please try again."}finally{o.value=!1}};return(t,r)=>t.isOpen?(u(),i("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm",onClick:_(f,["self"])},[e("div",q,[r[6]||(r[6]=e("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary mb-2"},"Change Default Password",-1)),r[7]||(r[7]=e("p",{class:"text-content-secondary dark:text-content-muted text-sm mb-6"}," You're using the default password. Please change it to secure your account. ",-1)),e("form",{onSubmit:_(c,["prevent"]),class:"space-y-4"},[e("div",null,[r[3]||(r[3]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-primary/70 mb-2"},"Current Password",-1)),v(e("input",{"onUpdate:modelValue":r[0]||(r[0]=b=>l.value=b),type:"password",required:"",class:"w-full px-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary transition-colors",placeholder:"Enter current password"},null,512),[[h,l.value]])]),e("div",null,[r[4]||(r[4]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-primary/70 mb-2"},"New Password",-1)),v(e("input",{"onUpdate:modelValue":r[1]||(r[1]=b=>s.value=b),type:"password",required:"",minlength:"8",class:"w-full px-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary transition-colors",placeholder:"Enter new password (min 8 characters)"},null,512),[[h,s.value]])]),e("div",null,[r[5]||(r[5]=e("label",{class:"block text-sm font-medium text-content-secondary dark:text-content-primary/70 mb-2"},"Confirm New Password",-1)),v(e("input",{"onUpdate:modelValue":r[2]||(r[2]=b=>d.value=b),type:"password",required:"",minlength:"8",class:"w-full px-4 py-2 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary transition-colors",placeholder:"Confirm new password"},null,512),[[h,d.value]])]),n.value?(u(),i("div",E,[e("p",z,y(n.value),1)])):g("",!0),m.value?(u(),i("div",T,[e("p",G,y(m.value),1)])):g("",!0),e("div",H,[t.canSkip?(u(),i("button",{key:0,type:"button",onClick:k,disabled:o.value,class:"px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg border border-stroke-subtle dark:border-stroke/10 transition-colors disabled:opacity-50"}," Skip for Now ",8,F)):g("",!0),e("button",{type:"submit",disabled:o.value,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-white rounded-lg border border-primary/50 transition-colors disabled:opacity-50 flex items-center gap-2"},[o.value?(u(),i("div",R)):g("",!0),M(" "+y(o.value?"Changing...":"Change Password"),1)],8,O)])],32)])])):g("",!0)}}),Y={class:"min-h-screen bg-background dark:bg-background overflow-hidden relative flex items-start sm:items-center justify-center p-2 sm:p-4 pt-8 sm:pt-4"},J={class:"absolute top-4 right-4 z-20"},K={class:"login-card relative z-10 w-full max-w-md p-6 sm:p-10 rounded-[16px] sm:rounded-[24px] border-0 sm:border sm:border-stroke-subtle dark:sm:border-stroke/20 shadow-[0_8px_32px_0_rgba(0,0,0,0.1)] dark:shadow-[0_8px_32px_0_rgba(0,0,0,0.37)] backdrop-blur-xl"},Q={class:"relative login-content"},W={class:"form-group"},X={class:"relative"},Z=["disabled"],ee={class:"form-group"},te={class:"relative"},re=["disabled"],se={key:0,class:"bg-red-500/10 border border-red-500/30 rounded-[12px] p-2.5 sm:p-3.5 backdrop-blur-sm animate-shake"},oe={class:"text-red-600 dark:text-red-400 text-xs sm:text-sm font-medium"},ae=["disabled"],ne={key:0,class:"w-4 h-4 sm:w-5 sm:h-5 border-2 border-white border-t-transparent rounded-full animate-spin"},le={key:1,class:"w-4 h-4 sm:w-5 sm:h-5 group-hover:translate-x-1 transition-transform duration-300",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},de={class:"relative"},ie={class:"mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-stroke-subtle dark:border-stroke/10"},ue={class:"flex items-center justify-center gap-3"},pe={href:"https://github.com/rightup",target:"_blank",class:"inline-flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-content-primary dark:bg-white/10 border border-stroke-subtle dark:border-stroke/20 hover:bg-primary/20 dark:hover:bg-primary/30 hover:border-primary/50 transition-all duration-300 hover:scale-110 group backdrop-blur-sm",title:"GitHub"},ce={href:"https://buymeacoffee.com/rightup",target:"_blank",class:"inline-flex items-center justify-center w-9 h-9 sm:w-10 sm:h-10 rounded-xl bg-content-primary dark:bg-white/10 border border-stroke-subtle dark:border-stroke/20 hover:bg-yellow-50 dark:hover:bg-yellow-500/20 hover:border-yellow-500/50 transition-all duration-300 hover:scale-110 group backdrop-blur-sm",title:"Buy Me a Coffee"},me=P({name:"LoginView",__name:"Login",setup(V){const x=I(),p=a("admin"),l=a(""),s=a(!1),d=a(""),o=a(!1),n=a(!1),m=async()=>{d.value="",s.value=!0;try{const c=L(),r=(await S.post("/auth/login",{username:p.value,password:l.value,client_id:c})).data;r.success&&r.token?l.value==="admin123"?(C(r.token),n.value=!0,o.value=!0):(C(r.token),x.push("/")):d.value=r.error||"Login failed"}catch(c){console.error("Login error:",c);const t=c;d.value=t.response?.data?.error||"Connection error. Please try again."}finally{s.value=!1}},f=()=>{o.value=!1,x.push("/")},k=()=>{o.value=!1,n.value&&x.push("/")};return(c,t)=>(u(),i("div",Y,[e("div",J,[w($)]),t[9]||(t[9]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slow -top-[79px] left-[575px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[10]||(t[10]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-75 animate-pulse-slower -top-[94px] -left-[92px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[11]||(t[11]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slowest top-[373px] left-[246px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),e("div",K,[t[8]||(t[8]=e("div",{class:"absolute inset-0 rounded-[24px] bg-gradient-to-br from-primary/3 dark:from-primary/5 to-transparent pointer-events-none"},null,-1)),e("div",Q,[t[7]||(t[7]=N('
MeshCore

pyMC Repeater

Sign in to access your dashboard

',1)),e("form",{onSubmit:_(m,["prevent"]),class:"space-y-4 sm:space-y-5"},[e("div",W,[t[3]||(t[3]=e("label",{for:"username",class:"block text-content-secondary dark:text-content-primary/90 text-xs sm:text-sm font-medium mb-2"}," Username ",-1)),e("div",X,[v(e("input",{id:"username","onUpdate:modelValue":t[0]||(t[0]=r=>p.value=r),type:"text",autocomplete:"username",required:"",class:"input-glass w-full px-3 sm:px-4 py-2.5 sm:py-3.5 rounded-[12px] text-content-primary dark:text-content-primary text-sm placeholder-gray-400 dark:placeholder-white/30 focus:outline-none focus:border-primary/50 transition-all duration-300",placeholder:"Enter username",disabled:s.value},null,8,Z),[[h,p.value]]),t[2]||(t[2]=e("div",{class:"absolute inset-0 rounded-[12px] pointer-events-none input-glow"},null,-1))])]),e("div",ee,[t[5]||(t[5]=e("label",{for:"password",class:"block text-content-secondary dark:text-content-primary/90 text-xs sm:text-sm font-medium mb-2"}," Password ",-1)),e("div",te,[v(e("input",{id:"password","onUpdate:modelValue":t[1]||(t[1]=r=>l.value=r),type:"password",autocomplete:"current-password",required:"",class:"input-glass w-full px-3 sm:px-4 py-2.5 sm:py-3.5 rounded-[12px] text-content-primary dark:text-content-primary text-sm placeholder-gray-400 dark:placeholder-white/30 focus:outline-none focus:border-primary/50 transition-all duration-300",placeholder:"Enter password",disabled:s.value},null,8,re),[[h,l.value]]),t[4]||(t[4]=e("div",{class:"absolute inset-0 rounded-[12px] pointer-events-none input-glow"},null,-1))])]),d.value?(u(),i("div",se,[e("p",oe,y(d.value),1)])):g("",!0),e("button",{type:"submit",disabled:s.value,class:"button-glass w-full relative overflow-hidden bg-primary/20 hover:bg-primary/30 active:scale-[0.98] text-primary dark:text-white font-semibold py-3 sm:py-4 px-4 rounded-[12px] border border-primary/50 hover:border-primary/60 transition-all duration-300 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 sm:gap-2.5 group mt-6 sm:mt-8 text-sm sm:text-base backdrop-blur-sm"},[s.value?(u(),i("div",ne)):(u(),i("svg",le,t[6]||(t[6]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"},null,-1)]))),e("span",de,y(s.value?"Signing in...":"Sign In"),1)],8,ae)],32),e("div",ie,[e("div",ue,[e("a",pe,[w(j,{class:"w-5 h-5 sm:w-6 sm:h-6 text-white group-hover:text-primary transition-colors"})]),e("a",ce,[w(B,{class:"w-5 h-5 sm:w-6 sm:h-6 text-white group-hover:text-yellow-500 transition-colors"})])])])])]),w(A,{"is-open":o.value,"can-skip":!0,onClose:k,onSuccess:f},null,8,["is-open"])]))}}),ge=U(me,[["__scopeId","data-v-7d3a3377"]]);export{ge as default}; diff --git a/repeater/web/html/assets/Logs-DYHdAYG8.js b/repeater/web/html/assets/Logs-DXaq6_-G.js similarity index 99% rename from repeater/web/html/assets/Logs-DYHdAYG8.js rename to repeater/web/html/assets/Logs-DXaq6_-G.js index 36d292d..b682be2 100644 --- a/repeater/web/html/assets/Logs-DYHdAYG8.js +++ b/repeater/web/html/assets/Logs-DXaq6_-G.js @@ -1 +1 @@ -import{a as j,r as i,c as w,o as H,H as T,b as s,e as o,k as $,j as h,t as c,g as q,F as L,h as N,i as J,L as K,p as n}from"./index-sHch0610.js";const P={class:"space-y-6"},Q={class:"glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6"},X={class:"flex items-center justify-between mb-4"},Y=["disabled"],Z={class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4"},ee={class:"flex flex-wrap gap-2"},te=["onClick"],re={key:0,class:"w-px h-6 bg-stroke-subtle dark:bg-stroke/20 mx-2 self-center"},oe=["onClick"],se={class:"glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] overflow-hidden"},ne={key:0,class:"p-8 text-center"},ae={key:1,class:"p-8 text-center"},le={class:"text-content-secondary dark:text-content-muted mb-4"},de={key:2,class:"max-h-[600px] overflow-y-auto"},ce={key:0,class:"p-8 text-center"},ie={key:1,class:"divide-y divide-gray-200 dark:divide-white/5"},ue={class:"flex-shrink-0 text-content-secondary dark:text-content-muted"},ge={class:"flex-shrink-0 px-2 py-1 text-xs font-medium rounded bg-blue-500/20 text-blue-600 dark:text-blue-400"},be={class:"text-content-primary dark:text-content-primary flex-1 break-all"},ve=j({name:"LogsView",__name:"Logs",setup(xe){const x=i([]),a=i(new Set),d=i(new Set(["DEBUG","INFO","WARNING","ERROR"])),v=i(new Set),p=i(new Set),m=i(!0),k=i(null);let u=null;const f=t=>{const e=t.match(/- ([^-]+) - (?:DEBUG|INFO|WARNING|ERROR) -/);return e?e[1].trim():"Unknown"},S=t=>{const e=t.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} - [^-]+ - (?:DEBUG|INFO|WARNING|ERROR) - (.+)$/);return e?e[1]:t},R=(t,e)=>{if(t.size!==e.size)return!1;for(const r of t)if(!e.has(r))return!1;return!0},y=async()=>{try{const t=await K.getLogs();if(t.logs&&t.logs.length>0){x.value=t.logs;const e=new Set;x.value.forEach(b=>{const z=f(b.message);e.add(z)});const r=new Set;x.value.forEach(b=>{r.add(b.level)}),a.value.size===0&&(a.value=new Set(e));const l=!R(v.value,e),g=!R(p.value,r);l&&(v.value=e),g&&(p.value=r),k.value=null}}catch(t){console.error("Error loading logs:",t),k.value=t instanceof Error?t.message:"Failed to load logs"}finally{m.value=!1}},_=w(()=>x.value.filter(e=>{const r=f(e.message),l=a.value.has(r),g=d.value.has(e.level);return l&&g})),C=w(()=>Array.from(v.value).sort()),A=w(()=>{const t=["ERROR","WARNING","WARN","INFO","DEBUG"];return Array.from(p.value).sort((r,l)=>{const g=t.indexOf(r),b=t.indexOf(l);return g!==-1&&b!==-1?g-b:r.localeCompare(l)})}),I=t=>{d.value.has(t)?d.value.delete(t):d.value.add(t),d.value=new Set(d.value)},O=t=>new Date(t).toLocaleTimeString("en-US",{hour12:!1,hour:"2-digit",minute:"2-digit",second:"2-digit"}),B=t=>({ERROR:"text-red-600 dark:text-red-400 bg-red-900/20",WARNING:"text-yellow-600 dark:text-yellow-400 bg-yellow-900/20",WARN:"text-yellow-600 dark:text-yellow-400 bg-yellow-900/20",INFO:"text-blue-600 dark:text-blue-400 bg-blue-900/20",DEBUG:"text-gray-400 bg-gray-900/20"})[t]||"text-gray-400 bg-gray-900/20",E=(t,e)=>e?{ERROR:"bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/50",WARNING:"bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50",WARN:"bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50",INFO:"bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/50",DEBUG:"bg-gray-500/20 text-gray-400 border-gray-500/50"}[t]||"bg-primary/20 text-primary border-primary/50":"bg-background-mute dark:bg-white/5 text-content-muted dark:text-white/60 border-stroke-subtle dark:border-white/20 hover:bg-stroke-subtle dark:hover:bg-white/10",G=t=>{a.value.has(t)?a.value.delete(t):a.value.add(t),a.value=new Set(a.value)},F=()=>{a.value=new Set(v.value)},M=()=>{a.value=new Set},D=()=>{d.value=new Set(p.value)},U=()=>{d.value=new Set},W=()=>{u&&clearInterval(u),u=setInterval(y,5e3)},V=()=>{u&&(clearInterval(u),u=null)};return H(()=>{y(),W()}),T(()=>{V()}),(t,e)=>(n(),s("div",P,[o("div",Q,[o("div",X,[e[1]||(e[1]=o("div",null,[o("h1",{class:"text-content-primary dark:text-content-primary text-2xl font-semibold mb-2"},"System Logs"),o("p",{class:"text-content-secondary dark:text-content-muted"},"Real-time system events and diagnostics")],-1)),o("button",{onClick:y,disabled:m.value,class:"flex items-center gap-2 px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary border border-primary/50 rounded-lg transition-colors disabled:opacity-50"},[(n(),s("svg",{class:h(["w-4 h-4",{"animate-spin":m.value}]),fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},e[0]||(e[0]=[o("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"},null,-1)]),2)),$(" "+c(m.value?"Loading...":"Refresh"),1)],8,Y)]),o("div",Z,[o("div",{class:"flex flex-wrap items-center gap-3 mb-4"},[e[2]||(e[2]=o("span",{class:"text-content-primary dark:text-content-primary font-medium"},"Filters:",-1)),o("button",{onClick:F,class:"px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors"}," All Loggers "),o("button",{onClick:M,class:"px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors"}," Clear Loggers "),e[3]||(e[3]=o("div",{class:"w-px h-4 bg-white/20 mx-1"},null,-1)),o("button",{onClick:D,class:"px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors"}," All Levels "),o("button",{onClick:U,class:"px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors"}," Clear Levels ")]),o("div",ee,[(n(!0),s(L,null,N(C.value,r=>(n(),s("button",{key:"logger-"+r,onClick:l=>G(r),class:h(["px-3 py-1 text-xs border rounded-full transition-colors",a.value.has(r)?"bg-primary/20 text-primary border-primary/50":"bg-background-mute dark:bg-white/5 text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/10"])},c(r),11,te))),128)),C.value.length>0&&A.value.length>0?(n(),s("div",re)):q("",!0),(n(!0),s(L,null,N(A.value,r=>(n(),s("button",{key:"level-"+r,onClick:l=>I(r),class:h(["px-3 py-1 text-xs border rounded-full transition-colors font-medium",d.value.has(r)?E(r,!0):E(r,!1)])},c(r),11,oe))),128))])])]),o("div",se,[m.value&&x.value.length===0?(n(),s("div",ne,e[4]||(e[4]=[o("div",{class:"animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"},null,-1),o("p",{class:"text-content-secondary dark:text-content-muted"},"Loading system logs...",-1)]))):k.value?(n(),s("div",ae,[e[5]||(e[5]=o("div",{class:"text-red-600 dark:text-red-400 mb-4"},[o("svg",{class:"w-12 h-12 mx-auto mb-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[o("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})])],-1)),e[6]||(e[6]=o("h3",{class:"text-content-primary dark:text-content-primary text-lg font-medium mb-2"},"Error Loading Logs",-1)),o("p",le,c(k.value),1),o("button",{onClick:y,class:"px-4 py-2 bg-red-100 dark:bg-red-500/20 hover:bg-red-500/30 text-red-600 dark:text-red-400 border border-red-500/50 rounded-lg transition-colors"}," Try Again ")])):(n(),s("div",de,[_.value.length===0?(n(),s("div",ce,e[7]||(e[7]=[J('

No Logs to Display

No logs match the current filter criteria.

',3)]))):(n(),s("div",ie,[(n(!0),s(L,null,N(_.value,(r,l)=>(n(),s("div",{key:l,class:"flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm"},[o("span",ue," ["+c(O(r.timestamp))+"] ",1),o("span",ge,c(f(r.message)),1),o("span",{class:h(["flex-shrink-0 px-2 py-1 text-xs font-medium rounded",B(r.level)])},c(r.level),3),o("span",be,c(S(r.message)),1)]))),128))]))]))])]))}});export{ve as default}; +import{a as j,r as i,c as w,o as H,H as T,b as s,e as o,k as $,j as h,t as c,g as q,F as L,h as N,i as J,L as K,p as n}from"./index-DyUIpN7m.js";const P={class:"space-y-6"},Q={class:"glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6"},X={class:"flex items-center justify-between mb-4"},Y=["disabled"],Z={class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4"},ee={class:"flex flex-wrap gap-2"},te=["onClick"],re={key:0,class:"w-px h-6 bg-stroke-subtle dark:bg-stroke/20 mx-2 self-center"},oe=["onClick"],se={class:"glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] overflow-hidden"},ne={key:0,class:"p-8 text-center"},ae={key:1,class:"p-8 text-center"},le={class:"text-content-secondary dark:text-content-muted mb-4"},de={key:2,class:"max-h-[600px] overflow-y-auto"},ce={key:0,class:"p-8 text-center"},ie={key:1,class:"divide-y divide-gray-200 dark:divide-white/5"},ue={class:"flex-shrink-0 text-content-secondary dark:text-content-muted"},ge={class:"flex-shrink-0 px-2 py-1 text-xs font-medium rounded bg-blue-500/20 text-blue-600 dark:text-blue-400"},be={class:"text-content-primary dark:text-content-primary flex-1 break-all"},ve=j({name:"LogsView",__name:"Logs",setup(xe){const x=i([]),a=i(new Set),d=i(new Set(["DEBUG","INFO","WARNING","ERROR"])),v=i(new Set),p=i(new Set),m=i(!0),k=i(null);let u=null;const f=t=>{const e=t.match(/- ([^-]+) - (?:DEBUG|INFO|WARNING|ERROR) -/);return e?e[1].trim():"Unknown"},S=t=>{const e=t.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} - [^-]+ - (?:DEBUG|INFO|WARNING|ERROR) - (.+)$/);return e?e[1]:t},R=(t,e)=>{if(t.size!==e.size)return!1;for(const r of t)if(!e.has(r))return!1;return!0},y=async()=>{try{const t=await K.getLogs();if(t.logs&&t.logs.length>0){x.value=t.logs;const e=new Set;x.value.forEach(b=>{const z=f(b.message);e.add(z)});const r=new Set;x.value.forEach(b=>{r.add(b.level)}),a.value.size===0&&(a.value=new Set(e));const l=!R(v.value,e),g=!R(p.value,r);l&&(v.value=e),g&&(p.value=r),k.value=null}}catch(t){console.error("Error loading logs:",t),k.value=t instanceof Error?t.message:"Failed to load logs"}finally{m.value=!1}},_=w(()=>x.value.filter(e=>{const r=f(e.message),l=a.value.has(r),g=d.value.has(e.level);return l&&g})),C=w(()=>Array.from(v.value).sort()),A=w(()=>{const t=["ERROR","WARNING","WARN","INFO","DEBUG"];return Array.from(p.value).sort((r,l)=>{const g=t.indexOf(r),b=t.indexOf(l);return g!==-1&&b!==-1?g-b:r.localeCompare(l)})}),I=t=>{d.value.has(t)?d.value.delete(t):d.value.add(t),d.value=new Set(d.value)},O=t=>new Date(t).toLocaleTimeString("en-US",{hour12:!1,hour:"2-digit",minute:"2-digit",second:"2-digit"}),B=t=>({ERROR:"text-red-600 dark:text-red-400 bg-red-900/20",WARNING:"text-yellow-600 dark:text-yellow-400 bg-yellow-900/20",WARN:"text-yellow-600 dark:text-yellow-400 bg-yellow-900/20",INFO:"text-blue-600 dark:text-blue-400 bg-blue-900/20",DEBUG:"text-gray-400 bg-gray-900/20"})[t]||"text-gray-400 bg-gray-900/20",E=(t,e)=>e?{ERROR:"bg-red-100 dark:bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/50",WARNING:"bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50",WARN:"bg-yellow-100 dark:bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/50",INFO:"bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/50",DEBUG:"bg-gray-500/20 text-gray-400 border-gray-500/50"}[t]||"bg-primary/20 text-primary border-primary/50":"bg-background-mute dark:bg-white/5 text-content-muted dark:text-white/60 border-stroke-subtle dark:border-white/20 hover:bg-stroke-subtle dark:hover:bg-white/10",G=t=>{a.value.has(t)?a.value.delete(t):a.value.add(t),a.value=new Set(a.value)},F=()=>{a.value=new Set(v.value)},M=()=>{a.value=new Set},D=()=>{d.value=new Set(p.value)},U=()=>{d.value=new Set},W=()=>{u&&clearInterval(u),u=setInterval(y,5e3)},V=()=>{u&&(clearInterval(u),u=null)};return H(()=>{y(),W()}),T(()=>{V()}),(t,e)=>(n(),s("div",P,[o("div",Q,[o("div",X,[e[1]||(e[1]=o("div",null,[o("h1",{class:"text-content-primary dark:text-content-primary text-2xl font-semibold mb-2"},"System Logs"),o("p",{class:"text-content-secondary dark:text-content-muted"},"Real-time system events and diagnostics")],-1)),o("button",{onClick:y,disabled:m.value,class:"flex items-center gap-2 px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary border border-primary/50 rounded-lg transition-colors disabled:opacity-50"},[(n(),s("svg",{class:h(["w-4 h-4",{"animate-spin":m.value}]),fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},e[0]||(e[0]=[o("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"},null,-1)]),2)),$(" "+c(m.value?"Loading...":"Refresh"),1)],8,Y)]),o("div",Z,[o("div",{class:"flex flex-wrap items-center gap-3 mb-4"},[e[2]||(e[2]=o("span",{class:"text-content-primary dark:text-content-primary font-medium"},"Filters:",-1)),o("button",{onClick:F,class:"px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors"}," All Loggers "),o("button",{onClick:M,class:"px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors"}," Clear Loggers "),e[3]||(e[3]=o("div",{class:"w-px h-4 bg-white/20 mx-1"},null,-1)),o("button",{onClick:D,class:"px-3 py-1 text-xs bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/50 rounded transition-colors"}," All Levels "),o("button",{onClick:U,class:"px-3 py-1 text-xs bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border border-accent-red/50 rounded transition-colors"}," Clear Levels ")]),o("div",ee,[(n(!0),s(L,null,N(C.value,r=>(n(),s("button",{key:"logger-"+r,onClick:l=>G(r),class:h(["px-3 py-1 text-xs border rounded-full transition-colors",a.value.has(r)?"bg-primary/20 text-primary border-primary/50":"bg-background-mute dark:bg-white/5 text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/10"])},c(r),11,te))),128)),C.value.length>0&&A.value.length>0?(n(),s("div",re)):q("",!0),(n(!0),s(L,null,N(A.value,r=>(n(),s("button",{key:"level-"+r,onClick:l=>I(r),class:h(["px-3 py-1 text-xs border rounded-full transition-colors font-medium",d.value.has(r)?E(r,!0):E(r,!1)])},c(r),11,oe))),128))])])]),o("div",se,[m.value&&x.value.length===0?(n(),s("div",ne,e[4]||(e[4]=[o("div",{class:"animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"},null,-1),o("p",{class:"text-content-secondary dark:text-content-muted"},"Loading system logs...",-1)]))):k.value?(n(),s("div",ae,[e[5]||(e[5]=o("div",{class:"text-red-600 dark:text-red-400 mb-4"},[o("svg",{class:"w-12 h-12 mx-auto mb-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[o("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})])],-1)),e[6]||(e[6]=o("h3",{class:"text-content-primary dark:text-content-primary text-lg font-medium mb-2"},"Error Loading Logs",-1)),o("p",le,c(k.value),1),o("button",{onClick:y,class:"px-4 py-2 bg-red-100 dark:bg-red-500/20 hover:bg-red-500/30 text-red-600 dark:text-red-400 border border-red-500/50 rounded-lg transition-colors"}," Try Again ")])):(n(),s("div",de,[_.value.length===0?(n(),s("div",ce,e[7]||(e[7]=[J('

No Logs to Display

No logs match the current filter criteria.

',3)]))):(n(),s("div",ie,[(n(!0),s(L,null,N(_.value,(r,l)=>(n(),s("div",{key:l,class:"flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm"},[o("span",ue," ["+c(O(r.timestamp))+"] ",1),o("span",ge,c(f(r.message)),1),o("span",{class:h(["flex-shrink-0 px-2 py-1 text-xs font-medium rounded",B(r.level)])},c(r.level),3),o("span",be,c(S(r.message)),1)]))),128))]))]))])]))}});export{ve as default}; diff --git a/repeater/web/html/assets/MessageDialog.vue_vue_type_script_setup_true_lang-DAIhF3Fs.js b/repeater/web/html/assets/MessageDialog.vue_vue_type_script_setup_true_lang-DAIhF3Fs.js new file mode 100644 index 0000000..651b8db --- /dev/null +++ b/repeater/web/html/assets/MessageDialog.vue_vue_type_script_setup_true_lang-DAIhF3Fs.js @@ -0,0 +1 @@ +import{a as k,b as o,g,e as r,j as a,t as p,s as x,p as s}from"./index-DyUIpN7m.js";const f={class:"mb-6"},m={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},v={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},h={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},w={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},C={class:"flex"},B=k({__name:"MessageDialog",props:{show:{type:Boolean},message:{},variant:{default:"success"}},emits:["close"],setup(i,{emit:d}){const t=i,l=d,c=n=>{n.target===n.currentTarget&&l("close")},b={success:"bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400",error:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},u={success:"bg-green-500 hover:bg-green-600",error:"bg-red-500 hover:bg-red-600",info:"bg-blue-500 hover:bg-blue-600"};return(n,e)=>t.show?(s(),o("div",{key:0,onClick:c,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[r("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[1]||(e[1]=x(()=>{},["stop"]))},[r("div",f,[r("div",{class:a(["inline-flex p-3 rounded-xl mb-4",b[t.variant]])},[t.variant==="success"?(s(),o("svg",m,e[2]||(e[2]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"},null,-1)]))):t.variant==="error"?(s(),o("svg",v,e[3]||(e[3]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"},null,-1)]))):(s(),o("svg",h,e[4]||(e[4]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),r("p",w,p(t.message),1)]),r("div",C,[r("button",{onClick:e[0]||(e[0]=y=>l("close")),class:a(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",u[t.variant]])}," OK ",2)])])])):g("",!0)}});export{B as _}; diff --git a/repeater/web/html/assets/Neighbors-IAQBtp4C.css b/repeater/web/html/assets/Neighbors-BPsas1hQ.css similarity index 90% rename from repeater/web/html/assets/Neighbors-IAQBtp4C.css rename to repeater/web/html/assets/Neighbors-BPsas1hQ.css index 70378f8..eb1ae4a 100644 --- a/repeater/web/html/assets/Neighbors-IAQBtp4C.css +++ b/repeater/web/html/assets/Neighbors-BPsas1hQ.css @@ -1 +1 @@ -.modal-enter-active[data-v-bea9143c],.modal-leave-active[data-v-bea9143c]{transition:opacity .2s ease}.modal-enter-from[data-v-bea9143c],.modal-leave-to[data-v-bea9143c]{opacity:0}.modal-enter-active>div[data-v-bea9143c],.modal-leave-active>div[data-v-bea9143c]{transition:transform .2s ease}.modal-enter-from>div[data-v-bea9143c],.modal-leave-to>div[data-v-bea9143c]{transform:scale(.95)}.packet-enter-active[data-v-bea9143c],.packet-leave-active[data-v-bea9143c]{transition:all .15s ease}.packet-enter-from[data-v-bea9143c],.packet-leave-to[data-v-bea9143c]{opacity:0;transform:translate(-50%) scale(.5)}.custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar{width:8px}.custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar-track{background:transparent}.custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar-thumb{background:#0003;border-radius:4px}.dark .custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar-thumb{background:#fff3}.custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar-thumb:hover{background:#0000004d}.dark .custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar-thumb:hover{background:#ffffff4d}.modal-enter-active[data-v-5669a05a],.modal-leave-active[data-v-5669a05a]{transition:opacity .3s ease}.modal-enter-active>div[data-v-5669a05a],.modal-leave-active>div[data-v-5669a05a]{transition:transform .3s ease,opacity .3s ease}.modal-enter-from[data-v-5669a05a],.modal-leave-to[data-v-5669a05a]{opacity:0}.modal-enter-from>div[data-v-5669a05a],.modal-leave-to>div[data-v-5669a05a]{transform:scale(.95);opacity:0}.leaflet-container{background:transparent}.custom-marker{background:transparent!important;border:none!important}.map-container[data-v-a6a23e33]{position:relative;background:transparent;border-radius:15px;overflow:hidden}.leaflet-map-container[data-v-a6a23e33]{background:linear-gradient(135deg,#09090bcc,#0009);-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px)}.map-legend[data-v-a6a23e33]{position:absolute;top:10px;right:10px;background:#0006;border:1px solid rgba(255,255,255,.1);border-radius:15px;padding:12px;font-size:12px;color:#fff;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);z-index:1000;min-width:150px;max-width:180px;box-shadow:0 8px 32px #0000004d}.legend-title[data-v-a6a23e33]{font-weight:700;margin-bottom:10px;color:#fff;font-size:13px}.legend-section[data-v-a6a23e33]{margin-bottom:10px}.legend-section[data-v-a6a23e33]:last-of-type{margin-bottom:8px}.legend-subtitle[data-v-a6a23e33]{font-weight:600;margin-bottom:6px;color:#fffc;font-size:11px;text-transform:uppercase;letter-spacing:.5px}.legend-footer[data-v-a6a23e33]{margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,.1);color:#fff9;font-size:10px;text-align:center}.legend-items[data-v-a6a23e33]{display:flex;flex-direction:column;gap:4px}.legend-item[data-v-a6a23e33]{display:flex;align-items:center;gap:6px}.legend-icon[data-v-a6a23e33]{width:8px;height:8px;border-radius:50%;border:1px solid rgba(255,255,255,.8);box-shadow:0 1px 2px #0003;flex-shrink:0}.legend-icon.cluster-icon[data-v-a6a23e33]{width:16px;height:16px;border-radius:50%;border:1px solid #AAE8E8;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.legend-line[data-v-a6a23e33]{width:16px;height:2px;border-radius:1px;flex-shrink:0;position:relative}.legend-line-dashed[data-v-a6a23e33]{background-image:repeating-linear-gradient(90deg,currentColor 0px,currentColor 4px,transparent 4px,transparent 8px)!important;background-color:transparent!important}.legend-line-dashed[style*="#FFC246"][data-v-a6a23e33]{color:#ffc246!important}.legend-line-dashed[style*="#ea580c"][data-v-a6a23e33]{color:#ea580c!important}.marker-highlight{position:relative!important;z-index:1000!important;animation:marker-glow-a6a23e33 1s ease-in-out infinite!important;border-radius:50%!important;box-shadow:0 0 0 3px #a5e5b6,0 0 8px #a5e5b6,0 0 16px #a5e5b6!important;transform:scale(1.2)!important}@keyframes marker-glow-a6a23e33{0%,to{box-shadow:0 0 0 3px #a5e5b6,0 0 8px #a5e5b6,0 0 16px #a5e5b6;filter:brightness(1)}50%{box-shadow:0 0 0 5px #a5e5b6,0 0 12px #a5e5b6,0 0 24px #a5e5b6;filter:brightness(1.3)}}@keyframes pulse-highlight-a6a23e33{0%{box-shadow:0 0 #3b82f6b3}70%{box-shadow:0 0 0 8px #3b82f600}to{box-shadow:0 0 #3b82f600}}.leaflet-popup-content-wrapper{background:#0006!important;color:#fff!important;border-radius:15px!important;box-shadow:0 8px 32px #0000004d!important;border:1px solid rgba(255,255,255,.1)!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important}.leaflet-popup-tip{background:#0006!important;border:1px solid rgba(255,255,255,.1)!important}.leaflet-popup-close-button{color:#fff9!important;font-size:18px!important}.leaflet-popup-close-button:hover{color:#fff!important}.custom-div-icon,.custom-cluster-icon{background:transparent!important;border:none!important}.custom-cluster-icon div{transition:all .3s ease!important;cursor:pointer!important}.custom-cluster-icon:hover div{transform:scale(1.1)!important;box-shadow:0 6px 16px #aae8e880!important}.leaflet-control-zoom{border:1px solid rgba(255,255,255,.1)!important;border-radius:15px!important;overflow:hidden;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important}.leaflet-control-zoom a{background-color:#0006!important;color:#fff!important;border-bottom:1px solid rgba(255,255,255,.1)!important;transition:all .2s ease!important}.leaflet-control-zoom a:hover{background-color:#ffffff1a!important;color:#fff!important}.leaflet-control-attribution{background-color:#1f2937cc!important;color:#9ca3af!important;border-top:1px solid rgba(75,85,99,.3)!important;border-radius:4px!important;padding:4px 8px!important;font-size:11px!important}.leaflet-control-attribution a{color:#60a5fa!important;text-decoration:none}.leaflet-control-attribution a:hover{color:#93c5fd!important;text-decoration:underline}.leaflet-bottom.leaflet-left .leaflet-control-attribution{margin-left:10px!important;margin-bottom:10px!important}.map-attribution[data-v-a6a23e33]{position:absolute;bottom:10px;left:10px;background:#0006;color:#fff9;border:1px solid rgba(255,255,255,.1);border-radius:15px;padding:4px 8px;font-size:10px;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);z-index:1000}@media (max-width: 640px){.leaflet-control-attribution{display:none!important}} +.modal-enter-active[data-v-b206e10a],.modal-leave-active[data-v-b206e10a]{transition:opacity .2s ease}.modal-enter-from[data-v-b206e10a],.modal-leave-to[data-v-b206e10a]{opacity:0}.modal-enter-active>div[data-v-b206e10a],.modal-leave-active>div[data-v-b206e10a]{transition:transform .2s ease}.modal-enter-from>div[data-v-b206e10a],.modal-leave-to>div[data-v-b206e10a]{transform:scale(.95)}.packet-enter-active[data-v-b206e10a],.packet-leave-active[data-v-b206e10a]{transition:all .15s ease}.packet-enter-from[data-v-b206e10a],.packet-leave-to[data-v-b206e10a]{opacity:0;transform:translate(-50%) scale(.5)}.custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar{width:8px}.custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar-track{background:transparent}.custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar-thumb{background:#0003;border-radius:4px}.dark .custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar-thumb{background:#fff3}.custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar-thumb:hover{background:#0000004d}.dark .custom-scrollbar[data-v-5669a05a]::-webkit-scrollbar-thumb:hover{background:#ffffff4d}.modal-enter-active[data-v-5669a05a],.modal-leave-active[data-v-5669a05a]{transition:opacity .3s ease}.modal-enter-active>div[data-v-5669a05a],.modal-leave-active>div[data-v-5669a05a]{transition:transform .3s ease,opacity .3s ease}.modal-enter-from[data-v-5669a05a],.modal-leave-to[data-v-5669a05a]{opacity:0}.modal-enter-from>div[data-v-5669a05a],.modal-leave-to>div[data-v-5669a05a]{transform:scale(.95);opacity:0}.leaflet-container{background:transparent}.custom-marker{background:transparent!important;border:none!important}.map-container[data-v-a6a23e33]{position:relative;background:transparent;border-radius:15px;overflow:hidden}.leaflet-map-container[data-v-a6a23e33]{background:linear-gradient(135deg,#09090bcc,#0009);-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px)}.map-legend[data-v-a6a23e33]{position:absolute;top:10px;right:10px;background:#0006;border:1px solid rgba(255,255,255,.1);border-radius:15px;padding:12px;font-size:12px;color:#fff;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);z-index:1000;min-width:150px;max-width:180px;box-shadow:0 8px 32px #0000004d}.legend-title[data-v-a6a23e33]{font-weight:700;margin-bottom:10px;color:#fff;font-size:13px}.legend-section[data-v-a6a23e33]{margin-bottom:10px}.legend-section[data-v-a6a23e33]:last-of-type{margin-bottom:8px}.legend-subtitle[data-v-a6a23e33]{font-weight:600;margin-bottom:6px;color:#fffc;font-size:11px;text-transform:uppercase;letter-spacing:.5px}.legend-footer[data-v-a6a23e33]{margin-top:10px;padding-top:8px;border-top:1px solid rgba(255,255,255,.1);color:#fff9;font-size:10px;text-align:center}.legend-items[data-v-a6a23e33]{display:flex;flex-direction:column;gap:4px}.legend-item[data-v-a6a23e33]{display:flex;align-items:center;gap:6px}.legend-icon[data-v-a6a23e33]{width:8px;height:8px;border-radius:50%;border:1px solid rgba(255,255,255,.8);box-shadow:0 1px 2px #0003;flex-shrink:0}.legend-icon.cluster-icon[data-v-a6a23e33]{width:16px;height:16px;border-radius:50%;border:1px solid #AAE8E8;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.legend-line[data-v-a6a23e33]{width:16px;height:2px;border-radius:1px;flex-shrink:0;position:relative}.legend-line-dashed[data-v-a6a23e33]{background-image:repeating-linear-gradient(90deg,currentColor 0px,currentColor 4px,transparent 4px,transparent 8px)!important;background-color:transparent!important}.legend-line-dashed[style*="#FFC246"][data-v-a6a23e33]{color:#ffc246!important}.legend-line-dashed[style*="#ea580c"][data-v-a6a23e33]{color:#ea580c!important}.marker-highlight{position:relative!important;z-index:1000!important;animation:marker-glow-a6a23e33 1s ease-in-out infinite!important;border-radius:50%!important;box-shadow:0 0 0 3px #a5e5b6,0 0 8px #a5e5b6,0 0 16px #a5e5b6!important;transform:scale(1.2)!important}@keyframes marker-glow-a6a23e33{0%,to{box-shadow:0 0 0 3px #a5e5b6,0 0 8px #a5e5b6,0 0 16px #a5e5b6;filter:brightness(1)}50%{box-shadow:0 0 0 5px #a5e5b6,0 0 12px #a5e5b6,0 0 24px #a5e5b6;filter:brightness(1.3)}}@keyframes pulse-highlight-a6a23e33{0%{box-shadow:0 0 #3b82f6b3}70%{box-shadow:0 0 0 8px #3b82f600}to{box-shadow:0 0 #3b82f600}}.leaflet-popup-content-wrapper{background:#0006!important;color:#fff!important;border-radius:15px!important;box-shadow:0 8px 32px #0000004d!important;border:1px solid rgba(255,255,255,.1)!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important}.leaflet-popup-tip{background:#0006!important;border:1px solid rgba(255,255,255,.1)!important}.leaflet-popup-close-button{color:#fff9!important;font-size:18px!important}.leaflet-popup-close-button:hover{color:#fff!important}.custom-div-icon,.custom-cluster-icon{background:transparent!important;border:none!important}.custom-cluster-icon div{transition:all .3s ease!important;cursor:pointer!important}.custom-cluster-icon:hover div{transform:scale(1.1)!important;box-shadow:0 6px 16px #aae8e880!important}.leaflet-control-zoom{border:1px solid rgba(255,255,255,.1)!important;border-radius:15px!important;overflow:hidden;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important}.leaflet-control-zoom a{background-color:#0006!important;color:#fff!important;border-bottom:1px solid rgba(255,255,255,.1)!important;transition:all .2s ease!important}.leaflet-control-zoom a:hover{background-color:#ffffff1a!important;color:#fff!important}.leaflet-control-attribution{background-color:#1f2937cc!important;color:#9ca3af!important;border-top:1px solid rgba(75,85,99,.3)!important;border-radius:4px!important;padding:4px 8px!important;font-size:11px!important}.leaflet-control-attribution a{color:#60a5fa!important;text-decoration:none}.leaflet-control-attribution a:hover{color:#93c5fd!important;text-decoration:underline}.leaflet-bottom.leaflet-left .leaflet-control-attribution{margin-left:10px!important;margin-bottom:10px!important}.map-attribution[data-v-a6a23e33]{position:absolute;bottom:10px;left:10px;background:#0006;color:#fff9;border:1px solid rgba(255,255,255,.1);border-radius:15px;padding:4px 8px;font-size:10px;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);z-index:1000}@media (max-width: 640px){.leaflet-control-attribution{display:none!important}} diff --git a/repeater/web/html/assets/Neighbors-CGfNd9X5.js b/repeater/web/html/assets/Neighbors-CXfm_tfh.js similarity index 91% rename from repeater/web/html/assets/Neighbors-CGfNd9X5.js rename to repeater/web/html/assets/Neighbors-CXfm_tfh.js index 146fde5..fccb664 100644 --- a/repeater/web/html/assets/Neighbors-CGfNd9X5.js +++ b/repeater/web/html/assets/Neighbors-CXfm_tfh.js @@ -1,4 +1,4 @@ -import{a as bt,b as $,g as D,e as t,t as C,s as Lt,p as f,M as Yt,r as F,c as J,D as ht,N as Rt,f as it,T as Ft,l as Dt,O as jt,j as M,F as ct,h as gt,x as It,k as tt,o as Xt,P as te,i as ft,E as Pt,n as At,w as wt,Q as ie,q as Wt,v as le,L as Et}from"./index-sHch0610.js";import{u as Ut}from"./useSignalQuality-CGSFiHBW.js";import{L as W}from"./leaflet-src-BtisrQHC.js";/* empty css */import{g as _t,s as Ct}from"./preferences-DtwbSSgO.js";import"./_commonjsHelpers-CqkleIqs.js";const de={class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4 mb-6"},ce={class:"flex items-center gap-3"},ue={class:"flex-1 min-w-0"},pe={class:"text-content-primary dark:text-content-primary font-medium truncate"},ge={class:"text-content-secondary dark:text-content-muted text-sm font-mono"},me={key:0,class:"text-white/50 text-xs"},he={key:1,class:"text-white/50 text-xs"},be=bt({__name:"DeleteNeighborModal",props:{show:{type:Boolean},neighbor:{}},emits:["close","delete"],setup(A,{emit:o}){const r=A,i=o,e=()=>{r.neighbor&&(i("delete",r.neighbor.id),d())},d=()=>{i("close")},g=s=>{s.target===s.currentTarget&&d()};return(s,a)=>s.show&&s.neighbor?(f(),$("div",{key:0,onClick:g,class:"fixed inset-0 bg-black/80 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:a[0]||(a[0]=Lt(()=>{},["stop"]))},[t("div",{class:"flex items-center gap-3 mb-6"},[a[2]||(a[2]=t("svg",{class:"w-6 h-6 text-accent-red",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"})],-1)),a[3]||(a[3]=t("div",null,[t("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"Delete Neighbor"),t("p",{class:"text-content-secondary dark:text-content-muted text-sm mt-1"}," Are you sure you want to delete this neighbor? ")],-1)),t("button",{onClick:d,class:"ml-auto text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},a[1]||(a[1]=[t("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",de,[t("div",ce,[t("div",ue,[t("div",pe,C(s.neighbor?.node_name||s.neighbor?.long_name||s.neighbor?.short_name||"Unknown"),1),t("div",ge," ID: "+C(s.neighbor?.node_num_hex||s.neighbor?.node_num||s.neighbor?.id||"N/A"),1),s.neighbor?.contact_type?(f(),$("div",me,C(s.neighbor.contact_type),1)):D("",!0),s.neighbor?.hw_model?(f(),$("div",he,C(s.neighbor.hw_model),1)):D("",!0)])])]),a[4]||(a[4]=t("div",{class:"bg-accent-red/10 border border-accent-red/30 rounded-lg p-4 mb-6"},[t("div",{class:"flex items-center gap-2 text-accent-red text-sm"},[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})]),t("span",null,"This action cannot be undone")])],-1)),t("div",{class:"flex gap-3"},[t("button",{onClick:d,class:"flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),t("button",{onClick:e,class:"flex-1 px-4 py-3 bg-accent-red/20 hover:bg-accent-red/30 border border-accent-red/50 text-accent-red rounded-lg transition-colors font-medium"}," Delete ")])])])):D("",!0)}}),xe={class:"bg-gradient-to-r from-primary/20 to-accent-blue/20 border-b border-stroke-subtle dark:border-stroke/10 px-6 py-4"},ve={class:"flex items-center justify-between"},ye={class:"flex items-center gap-3"},ke={key:0,class:"text-sm text-content-secondary dark:text-content-muted"},fe={class:"p-6"},we={key:0,class:"text-center py-8"},_e={key:1,class:"text-center py-8"},Ce={class:"text-content-secondary dark:text-content-muted text-sm"},$e={key:2,class:"space-y-4"},Me={class:"bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4"},Ae={class:"flex items-center justify-between mb-2"},Le={class:"flex items-baseline gap-2"},Te={class:"text-3xl font-bold text-content-primary dark:text-content-primary"},Ee={class:"grid grid-cols-2 gap-3"},Se={class:"bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4"},Be={class:"flex items-center gap-2 mb-2"},Ne={class:"flex gap-0.5"},Fe={class:"flex items-baseline gap-1"},De={class:"text-xl font-bold text-content-primary dark:text-content-primary"},Pe={class:"bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4"},ze={class:"flex items-baseline gap-1"},Re={class:"text-xl font-bold text-content-primary dark:text-content-primary"},je={class:"bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4"},Ie={class:"relative"},Ue={class:"flex items-center gap-2 overflow-x-auto pb-2"},Oe={key:0,class:"relative flex items-center"},Ve={key:0,class:"absolute left-1/2 -translate-x-1/2 animate-pulse"},He={class:"text-content-muted dark:text-content-muted text-xs mt-2 flex items-center justify-between"},Ze={key:0,class:"text-cyan-500 dark:text-primary animate-pulse"},We={class:"flex items-center justify-between text-xs text-content-muted dark:text-content-muted pt-2"},Qe=bt({__name:"PingResultModal",props:{show:{type:Boolean},nodeName:{default:null},result:{default:null},error:{default:null},loading:{type:Boolean,default:!1}},emits:["close"],setup(A,{emit:o}){const r=A,i=o,e=Yt(),{getSignalQuality:d}=Ut(),g=F(0),s=F(!1),a=J(()=>{const x=e.stats?.config?.radio?.spreading_factor??7,b=e.stats?.config?.radio?.bandwidth??125,L=e.stats?.config?.radio?.coding_rate??5,_=Math.pow(2,x)/b,k=8+4.25*(L-4)+20;return _*k}),w=J(()=>{if(!r.result)return{color:"text-gray-400",label:"Unknown"};const x=r.result.rtt_ms,b=a.value,L=r.result.path.length,k=2*b*L+500*L;return x{if(!r.result)return{bars:0,color:"text-gray-400"};const x=d(r.result.rssi);return{bars:x.bars,color:x.color}});ht(()=>r.result,x=>{if(x&&!s.value){s.value=!0,g.value=0;const b=x.path.length,_=1500/(b*2);let k=0;const P=b*2-2,I=()=>{k<=P?(g.value=k/P,k++,setTimeout(I,_)):(s.value=!1,g.value=1)};setTimeout(I,100)}},{immediate:!0});const y=J(()=>{if(!r.result||!s.value)return-1;const x=r.result.path.length;if(x<=1)return-1;const b=g.value,L=.5;if(b<=L)return b/L*(x-1);{const _=(b-L)/L;return(x-1)*(1-_)}}),S=()=>{i("close")};return(x,b)=>(f(),Rt(jt,{to:"body"},[it(Ft,{name:"modal"},{default:Dt(()=>[x.show?(f(),$("div",{key:0,class:"fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[99999] p-4",onClick:Lt(S,["self"])},[t("div",{class:"glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/20 rounded-[20px] shadow-2xl w-full max-w-md overflow-hidden",onClick:b[0]||(b[0]=Lt(()=>{},["stop"]))},[t("div",xe,[t("div",ve,[t("div",ye,[b[2]||(b[2]=t("div",{class:"p-2 bg-cyan-400/20 dark:bg-primary/20 rounded-lg"},[t("svg",{class:"w-5 h-5 text-cyan-500 dark:text-primary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"})])],-1)),t("div",null,[b[1]||(b[1]=t("h2",{class:"text-xl font-bold text-content-primary dark:text-content-primary"},"Ping Result",-1)),x.nodeName?(f(),$("p",ke,C(x.nodeName),1)):D("",!0)])]),t("button",{onClick:S,class:"p-2 hover:bg-stroke-subtle dark:hover:bg-white/10 rounded-lg transition-colors text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary"},b[3]||(b[3]=[t("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))])]),t("div",fe,[x.loading?(f(),$("div",we,b[4]||(b[4]=[t("div",{class:"animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"},null,-1),t("p",{class:"text-content-secondary dark:text-content-muted"},"Sending ping...",-1),t("p",{class:"text-content-muted dark:text-content-muted text-sm mt-1"},"Waiting for response...",-1)]))):x.error?(f(),$("div",_e,[b[5]||(b[5]=t("div",{class:"p-3 bg-accent-red/10 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center"},[t("svg",{class:"w-8 h-8 text-accent-red",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-1.964-1.333-2.732 0L3.268 16c-.77 1.333.192 3 1.732 3z"})])],-1)),b[6]||(b[6]=t("h3",{class:"text-accent-red font-semibold mb-2"},"Ping Failed",-1)),t("p",Ce,C(x.error),1)])):x.result?(f(),$("div",$e,[t("div",Me,[t("div",Ae,[b[7]||(b[7]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Round-Trip Time",-1)),t("span",{class:M(["text-xs font-medium px-2 py-1 rounded-full",w.value.color,"bg-current/10"])},C(w.value.label),3)]),t("div",Le,[t("span",Te,C(x.result.rtt_ms.toFixed(2)),1),b[8]||(b[8]=t("span",{class:"text-content-secondary dark:text-content-muted"},"ms",-1))])]),t("div",Ee,[t("div",Se,[t("div",Be,[b[9]||(b[9]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"RSSI",-1)),t("div",Ne,[(f(),$(ct,null,gt(5,L=>t("div",{key:L,class:M(["w-1 h-3 rounded-sm",L<=m.value.bars?m.value.color:"bg-stroke-subtle dark:bg-stroke/10"])},null,2)),64))])]),t("div",Fe,[t("span",De,C(x.result.rssi),1),b[10]||(b[10]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs"},"dBm",-1))])]),t("div",Pe,[b[12]||(b[12]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-2"},"SNR",-1)),t("div",ze,[t("span",Re,C(x.result.snr_db),1),b[11]||(b[11]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs"},"dB",-1))])])]),t("div",je,[b[15]||(b[15]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-3"},"Network Path",-1)),t("div",Ie,[t("div",Ue,[(f(!0),$(ct,null,gt(x.result.path,(L,_)=>(f(),$("div",{key:_,class:"flex items-center gap-2 flex-shrink-0 relative"},[t("div",{class:M(["bg-cyan-400/20 dark:bg-primary/20 text-cyan-600 dark:text-primary border border-cyan-400/40 dark:border-primary/30 px-3 py-1.5 rounded-lg text-sm font-mono transition-all duration-300",s.value&&Math.floor(y.value)===_?"ring-2 ring-cyan-400/50 dark:ring-primary/50 scale-105":""])},C(L),3),_[s.value&&y.value>=_&&y.value<_+1?(f(),$("div",Ve,b[13]||(b[13]=[t("svg",{class:"w-3 h-3 text-cyan-500 dark:text-primary drop-shadow-[0_0_6px_rgba(6,182,212,0.8)] dark:drop-shadow-[0_0_6px_rgba(59,130,246,0.8)]",fill:"currentColor",viewBox:"0 0 24 24"},[t("circle",{cx:"12",cy:"12",r:"8"})],-1)]))):D("",!0)]),_:2},1024)])):D("",!0)]))),128))])]),t("div",He,[t("span",null,C(x.result.path.length)+" hop"+C(x.result.path.length!==1?"s":""),1),s.value?(f(),$("span",Ze,"● Tracing route...")):D("",!0)])]),t("div",We,[t("span",null,"Target: "+C(x.result.target_id),1),t("span",null,"Tag: #"+C(x.result.tag),1)])])):D("",!0)]),t("div",{class:"border-t border-stroke-subtle dark:border-stroke/10 px-6 py-4"},[t("button",{onClick:S,class:"w-full py-2.5 bg-gradient-to-r from-cyan-400 to-cyan-500 text-white hover:from-cyan-500 hover:to-cyan-600 dark:bg-primary/20 dark:text-primary dark:border dark:border-primary/30 dark:hover:bg-primary/30 dark:from-transparent dark:to-transparent rounded-lg font-medium transition-all shadow-[0_2px_12px_rgba(6,182,212,0.3)] dark:shadow-none"}," Close ")])])])):D("",!0)]),_:1})]))}}),qe=It(Qe,[["__scopeId","data-v-bea9143c"]]),Ke={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] shadow-2xl border border-stroke-subtle dark:border-white/20 flex flex-col h-full overflow-hidden"},Ge={class:"flex items-center justify-between p-8 pb-4 flex-shrink-0"},Je={class:"flex-1 min-w-0"},Ye={class:"text-2xl font-bold text-content-primary dark:text-content-primary mb-1"},Xe={class:"text-content-secondary dark:text-content-muted text-sm font-mono break-all"},to={class:"flex items-center gap-2"},eo={class:"flex-1 overflow-y-auto custom-scrollbar px-8"},oo={class:"mb-6"},ro={class:"grid grid-cols-1 md:grid-cols-2 gap-4"},no={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},so={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},ao={class:"text-content-primary dark:text-content-primary font-medium"},io={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},lo={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},co={class:"text-content-primary dark:text-content-primary font-medium"},uo={class:"mb-6"},po={class:"grid grid-cols-1 md:grid-cols-3 gap-4"},go={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},mo={class:"text-content-primary dark:text-content-primary font-medium"},ho={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},bo={class:"text-content-primary dark:text-content-primary font-medium"},xo={key:0,class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},vo={class:"flex items-center gap-2"},yo={class:"flex gap-0.5"},ko={class:"mb-6"},fo={class:"grid grid-cols-1 md:grid-cols-2 gap-4"},wo={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},_o={class:"text-content-primary dark:text-content-primary text-sm"},Co={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},$o={class:"text-content-primary dark:text-content-primary text-sm"},Mo={key:0,class:"mb-6"},Ao={class:"grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"},Lo={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},To={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Eo={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},So={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Bo={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},No={class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},Fo={key:0,class:"text-content-primary dark:text-content-primary font-medium"},Do={class:"p-8 pt-4 border-t border-stroke-subtle dark:border-white/10 flex-shrink-0"},Po=bt({name:"NeighborDetailsModal",__name:"NeighborDetailsModal",props:{neighbor:{},isOpen:{type:Boolean},baseLatitude:{default:null},baseLongitude:{default:null}},emits:["close"],setup(A,{emit:o}){const{getSignalQuality:r}=Ut(),i=F("Copy"),e=A,d=o,g=F();let s=null;const a=v=>new Date(v*1e3).toLocaleString(),w=v=>v?`${v} dBm`:"N/A",m=v=>v?`${v.toFixed(1)} dB`:"N/A",y=v=>({0:"Transport Flood",1:"Flood",2:"Direct",3:"Transport Direct"})[v||0]||"Unknown",S=v=>({Unknown:"Unknown","Chat Node":"Chat Node",Repeater:"Repeater","Room Server":"Room Server","Hybrid Node":"Hybrid Node"})[v]||v,x=v=>({Unknown:"text-gray-600 dark:text-gray-400","Chat Node":"text-blue-600 dark:text-blue-400",Repeater:"text-emerald-600 dark:text-emerald-400","Room Server":"text-purple-600 dark:text-purple-400","Hybrid Node":"text-amber-600 dark:text-amber-400"})[v]||"text-gray-600 dark:text-gray-400",b=async()=>{if(!e.neighbor?.latitude||!e.neighbor?.longitude)return;const v=e.neighbor.latitude.toFixed(6),u=e.neighbor.longitude.toFixed(6),j=`${v}, ${u}`;try{await navigator.clipboard.writeText(j),i.value="Copied!",setTimeout(()=>{i.value="Copy"},2e3)}catch(K){console.error("Failed to copy coordinates:",K),i.value="Failed",setTimeout(()=>{i.value="Copy"},2e3)}},L=J(()=>{if(!e.neighbor?.latitude||!e.neighbor?.longitude||!e.baseLatitude||!e.baseLongitude)return null;const v=6371,u=(e.neighbor.latitude-e.baseLatitude)*Math.PI/180,j=(e.neighbor.longitude-e.baseLongitude)*Math.PI/180,K=Math.sin(u/2)*Math.sin(u/2)+Math.cos(e.baseLatitude*Math.PI/180)*Math.cos(e.neighbor.latitude*Math.PI/180)*Math.sin(j/2)*Math.sin(j/2),et=2*Math.atan2(Math.sqrt(K),Math.sqrt(1-K));return v*et}),_=J(()=>e.neighbor?.latitude!==null&&e.neighbor?.longitude!==null&&e.neighbor?.latitude!==0&&e.neighbor?.longitude!==0&&Math.abs(e.neighbor?.latitude??0)<=90&&Math.abs(e.neighbor?.longitude??0)<=180),k=()=>{if(!g.value||!e.neighbor||!_.value)return;s&&(s.remove(),s=null);const v=document.documentElement.classList.contains("dark");s=W.map(g.value,{center:[e.neighbor.latitude,e.neighbor.longitude],zoom:13,zoomControl:!0,attributionControl:!1});const u=v?"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png":"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";W.tileLayer(u,{maxZoom:19,attribution:"© OpenStreetMap © CARTO"}).addTo(s);const j=W.divIcon({className:"custom-marker",html:`
${e.neighbor.node_name?.charAt(0)||"?"}
`,iconSize:[32,32],iconAnchor:[16,16]});if(W.marker([e.neighbor.latitude,e.neighbor.longitude],{icon:j}).addTo(s).bindPopup(`${e.neighbor.node_name||"Unknown"}
${e.neighbor.pubkey.slice(0,8)}...`),e.baseLatitude!==null&&e.baseLongitude!==null&&e.baseLatitude!==0&&e.baseLongitude!==0&&Math.abs(e.baseLatitude)<=90&&Math.abs(e.baseLongitude)<=180){const et=W.divIcon({className:"custom-marker",html:'
B
',iconSize:[32,32],iconAnchor:[16,16]});W.marker([e.baseLatitude,e.baseLongitude],{icon:et}).addTo(s).bindPopup("Base Station"),W.polyline([[e.baseLatitude,e.baseLongitude],[e.neighbor.latitude,e.neighbor.longitude]],{color:"#3b82f6",weight:2,opacity:.6,dashArray:"5, 10"}).addTo(s);const lt=W.latLngBounds([e.baseLatitude,e.baseLongitude],[e.neighbor.latitude,e.neighbor.longitude]);s.fitBounds(lt,{padding:[50,50]})}},P=v=>{v.key==="Escape"&&d("close")},I=v=>{v.target===v.currentTarget&&d("close")};ht(()=>e.isOpen,v=>{v?(document.body.style.overflow="hidden",setTimeout(()=>{_.value&&k()},100)):(document.body.style.overflow="",s&&(s.remove(),s=null))},{immediate:!0});const Z=J(()=>e.neighbor?.rssi?r(e.neighbor.rssi):null);return(v,u)=>(f(),Rt(jt,{to:"body"},[it(Ft,{name:"modal",appear:""},{default:Dt(()=>[v.isOpen&&v.neighbor?(f(),$("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 overflow-hidden",onClick:I,onKeydown:P,tabindex:"0"},[u[20]||(u[20]=t("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-md pointer-events-none"},null,-1)),t("div",{class:"relative w-full max-w-4xl max-h-[90vh] flex flex-col",onClick:u[2]||(u[2]=Lt(()=>{},["stop"]))},[t("div",Ke,[t("div",Ge,[t("div",Je,[t("h2",Ye,C(v.neighbor.node_name||"Unknown Node"),1),t("p",Xe,C(v.neighbor.pubkey),1)]),t("div",to,[t("button",{onClick:u[0]||(u[0]=j=>d("close")),class:"w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200 text-gray-700 dark:text-white hover:text-gray-900 dark:hover:text-white"},u[3]||(u[3]=[t("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))])]),t("div",eo,[t("div",oo,[u[8]||(u[8]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4"},"Basic Information",-1)),t("div",ro,[t("div",no,[u[4]||(u[4]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Contact Type",-1)),t("div",{class:M(["font-medium",x(v.neighbor.contact_type)])},C(S(v.neighbor.contact_type)),3)]),t("div",so,[u[5]||(u[5]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Route Type",-1)),t("div",ao,C(y(v.neighbor.route_type)),1)]),t("div",io,[u[6]||(u[6]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Zero Hop",-1)),t("div",{class:M(["font-medium",v.neighbor.zero_hop?"text-green-600 dark:text-green-400":"text-gray-600 dark:text-gray-400"])},C(v.neighbor.zero_hop?"Yes":"No"),3)]),t("div",lo,[u[7]||(u[7]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Advert Count",-1)),t("div",co,C(v.neighbor.advert_count.toLocaleString()),1)])])]),t("div",uo,[u[12]||(u[12]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4"},"Signal Quality",-1)),t("div",po,[t("div",go,[u[9]||(u[9]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"RSSI",-1)),t("div",mo,C(w(v.neighbor.rssi)),1)]),t("div",ho,[u[10]||(u[10]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"SNR",-1)),t("div",bo,C(m(v.neighbor.snr)),1)]),Z.value?(f(),$("div",xo,[u[11]||(u[11]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Signal Strength",-1)),t("div",vo,[t("div",yo,[(f(),$(ct,null,gt(4,j=>t("div",{key:j,class:M(["w-1 h-3 rounded-sm",j<=Z.value.bars?Z.value.color:"bg-gray-300 dark:bg-gray-700"])},null,2)),64))]),t("span",{class:M(["text-sm font-medium",Z.value.color])},C(Z.value.quality),3)])])):D("",!0)])]),t("div",ko,[u[15]||(u[15]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4"},"Timeline",-1)),t("div",fo,[t("div",wo,[u[13]||(u[13]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"First Seen",-1)),t("div",_o,C(a(v.neighbor.first_seen)),1)]),t("div",Co,[u[14]||(u[14]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Last Seen",-1)),t("div",$o,C(a(v.neighbor.last_seen)),1)])])]),_.value?(f(),$("div",Mo,[u[19]||(u[19]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4"},"Location",-1)),t("div",Ao,[t("div",Lo,[u[16]||(u[16]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Latitude",-1)),t("div",To,C(v.neighbor.latitude?.toFixed(6)),1)]),t("div",Eo,[u[17]||(u[17]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Longitude",-1)),t("div",So,C(v.neighbor.longitude?.toFixed(6)),1)]),t("div",Bo,[t("div",No,C(L.value!==null?"Distance":"Coordinates"),1),L.value!==null?(f(),$("div",Fo,C(L.value.toFixed(2))+" km ",1)):(f(),$("button",{key:1,onClick:b,class:"w-full px-3 py-1.5 bg-primary hover:bg-primary/90 dark:bg-gray-700 dark:hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors flex items-center justify-center gap-1.5"},[u[18]||(u[18]=t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"})],-1)),tt(" "+C(i.value),1)]))])]),t("div",{ref_key:"mapContainer",ref:g,class:"w-full h-96 rounded-[12px] overflow-hidden border border-stroke-subtle dark:border-white/10"},null,512)])):D("",!0)]),t("div",Do,[t("button",{onClick:u[1]||(u[1]=j=>d("close")),class:"w-full px-4 py-2.5 bg-primary hover:bg-primary/90 dark:bg-gray-700 dark:hover:bg-gray-600 text-white font-medium rounded-lg transition-colors"}," Close ")])])])],32)):D("",!0)]),_:1})]))}}),zo=It(Po,[["__scopeId","data-v-5669a05a"]]),Qt=[Int8Array,Uint8Array,Uint8ClampedArray,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array],St=1,yt=8;class Ot{static from(o){if(!(o instanceof ArrayBuffer))throw new Error("Data must be an instance of ArrayBuffer.");const[r,i]=new Uint8Array(o,0,2);if(r!==219)throw new Error("Data does not appear to be in a KDBush format.");const e=i>>4;if(e!==St)throw new Error(`Got v${e} data when expected v${St}.`);const d=Qt[i&15];if(!d)throw new Error("Unrecognized array type.");const[g]=new Uint16Array(o,2,1),[s]=new Uint32Array(o,4,1);return new Ot(s,g,d,o)}constructor(o,r=64,i=Float64Array,e){if(isNaN(o)||o<0)throw new Error(`Unpexpected numItems value: ${o}.`);this.numItems=+o,this.nodeSize=Math.min(Math.max(+r,2),65535),this.ArrayType=i,this.IndexArrayType=o<65536?Uint16Array:Uint32Array;const d=Qt.indexOf(this.ArrayType),g=o*2*this.ArrayType.BYTES_PER_ELEMENT,s=o*this.IndexArrayType.BYTES_PER_ELEMENT,a=(8-s%8)%8;if(d<0)throw new Error(`Unexpected typed array class: ${i}.`);e&&e instanceof ArrayBuffer?(this.data=e,this.ids=new this.IndexArrayType(this.data,yt,o),this.coords=new this.ArrayType(this.data,yt+s+a,o*2),this._pos=o*2,this._finished=!0):(this.data=new ArrayBuffer(yt+g+s+a),this.ids=new this.IndexArrayType(this.data,yt,o),this.coords=new this.ArrayType(this.data,yt+s+a,o*2),this._pos=0,this._finished=!1,new Uint8Array(this.data,0,2).set([219,(St<<4)+d]),new Uint16Array(this.data,2,1)[0]=r,new Uint32Array(this.data,4,1)[0]=o)}add(o,r){const i=this._pos>>1;return this.ids[i]=i,this.coords[this._pos++]=o,this.coords[this._pos++]=r,i}finish(){const o=this._pos>>1;if(o!==this.numItems)throw new Error(`Added ${o} items when expected ${this.numItems}.`);return zt(this.ids,this.coords,this.nodeSize,0,this.numItems-1,0),this._finished=!0,this}range(o,r,i,e){if(!this._finished)throw new Error("Data not yet indexed - call index.finish().");const{ids:d,coords:g,nodeSize:s}=this,a=[0,d.length-1,0],w=[];for(;a.length;){const m=a.pop()||0,y=a.pop()||0,S=a.pop()||0;if(y-S<=s){for(let _=S;_<=y;_++){const k=g[2*_],P=g[2*_+1];k>=o&&k<=i&&P>=r&&P<=e&&w.push(d[_])}continue}const x=S+y>>1,b=g[2*x],L=g[2*x+1];b>=o&&b<=i&&L>=r&&L<=e&&w.push(d[x]),(m===0?o<=b:r<=L)&&(a.push(S),a.push(x-1),a.push(1-m)),(m===0?i>=b:e>=L)&&(a.push(x+1),a.push(y),a.push(1-m))}return w}within(o,r,i){if(!this._finished)throw new Error("Data not yet indexed - call index.finish().");const{ids:e,coords:d,nodeSize:g}=this,s=[0,e.length-1,0],a=[],w=i*i;for(;s.length;){const m=s.pop()||0,y=s.pop()||0,S=s.pop()||0;if(y-S<=g){for(let _=S;_<=y;_++)qt(d[2*_],d[2*_+1],o,r)<=w&&a.push(e[_]);continue}const x=S+y>>1,b=d[2*x],L=d[2*x+1];qt(b,L,o,r)<=w&&a.push(e[x]),(m===0?o-i<=b:r-i<=L)&&(s.push(S),s.push(x-1),s.push(1-m)),(m===0?o+i>=b:r+i>=L)&&(s.push(x+1),s.push(y),s.push(1-m))}return a}}function zt(A,o,r,i,e,d){if(e-i<=r)return;const g=i+e>>1;ee(A,o,g,i,e,d),zt(A,o,r,i,g-1,1-d),zt(A,o,r,g+1,e,1-d)}function ee(A,o,r,i,e,d){for(;e>i;){if(e-i>600){const w=e-i+1,m=r-i+1,y=Math.log(w),S=.5*Math.exp(2*y/3),x=.5*Math.sqrt(y*S*(w-S)/w)*(m-w/2<0?-1:1),b=Math.max(i,Math.floor(r-m*S/w+x)),L=Math.min(e,Math.floor(r+(w-m)*S/w+x));ee(A,o,r,b,L,d)}const g=o[2*r+d];let s=i,a=e;for(kt(A,o,i,r),o[2*e+d]>g&&kt(A,o,i,e);sg;)a--}o[2*i+d]===g?kt(A,o,i,a):(a++,kt(A,o,a,e)),a<=r&&(i=a+1),r<=a&&(e=a-1)}}function kt(A,o,r,i){Bt(A,r,i),Bt(o,2*r,2*i),Bt(o,2*r+1,2*i+1)}function Bt(A,o,r){const i=A[o];A[o]=A[r],A[r]=i}function qt(A,o,r,i){const e=A-r,d=o-i;return e*e+d*d}const Ro={minZoom:0,maxZoom:16,minPoints:2,radius:40,extent:512,nodeSize:64,log:!1,generateId:!1,reduce:null,map:A=>A},Kt=Math.fround||(A=>o=>(A[0]=+o,A[0]))(new Float32Array(1)),mt=2,pt=3,Nt=4,ut=5,oe=6;class jo{constructor(o){this.options=Object.assign(Object.create(Ro),o),this.trees=new Array(this.options.maxZoom+1),this.stride=this.options.reduce?7:6,this.clusterProps=[]}load(o){const{log:r,minZoom:i,maxZoom:e}=this.options;r&&console.time("total time");const d=`prepare ${o.length} points`;r&&console.time(d),this.points=o;const g=[];for(let a=0;a=i;a--){const w=+Date.now();s=this.trees[a]=this._createTree(this._cluster(s,a)),r&&console.log("z%d: %d clusters in %dms",a,s.numItems,+Date.now()-w)}return r&&console.timeEnd("total time"),this}getClusters(o,r){let i=((o[0]+180)%360+360)%360-180;const e=Math.max(-90,Math.min(90,o[1]));let d=o[2]===180?180:((o[2]+180)%360+360)%360-180;const g=Math.max(-90,Math.min(90,o[3]));if(o[2]-o[0]>=360)i=-180,d=180;else if(i>d){const y=this.getClusters([i,e,180,g],r),S=this.getClusters([-180,e,d,g],r);return y.concat(S)}const s=this.trees[this._limitZoom(r)],a=s.range($t(i),Mt(g),$t(d),Mt(e)),w=s.data,m=[];for(const y of a){const S=this.stride*y;m.push(w[S+ut]>1?Gt(w,S,this.clusterProps):this.points[w[S+pt]])}return m}getChildren(o){const r=this._getOriginId(o),i=this._getOriginZoom(o),e="No cluster with the specified id.",d=this.trees[i];if(!d)throw new Error(e);const g=d.data;if(r*this.stride>=g.length)throw new Error(e);const s=this.options.radius/(this.options.extent*Math.pow(2,i-1)),a=g[r*this.stride],w=g[r*this.stride+1],m=d.within(a,w,s),y=[];for(const S of m){const x=S*this.stride;g[x+Nt]===o&&y.push(g[x+ut]>1?Gt(g,x,this.clusterProps):this.points[g[x+pt]])}if(y.length===0)throw new Error(e);return y}getLeaves(o,r,i){r=r||10,i=i||0;const e=[];return this._appendLeaves(e,o,r,i,0),e}getTile(o,r,i){const e=this.trees[this._limitZoom(o)],d=Math.pow(2,o),{extent:g,radius:s}=this.options,a=s/g,w=(i-a)/d,m=(i+1+a)/d,y={features:[]};return this._addTileFeatures(e.range((r-a)/d,w,(r+1+a)/d,m),e.data,r,i,d,y),r===0&&this._addTileFeatures(e.range(1-a/d,w,1,m),e.data,d,i,d,y),r===d-1&&this._addTileFeatures(e.range(0,w,a/d,m),e.data,-1,i,d,y),y.features.length?y:null}getClusterExpansionZoom(o){let r=this._getOriginZoom(o)-1;for(;r<=this.options.maxZoom;){const i=this.getChildren(o);if(r++,i.length!==1)break;o=i[0].properties.cluster_id}return r}_appendLeaves(o,r,i,e,d){const g=this.getChildren(r);for(const s of g){const a=s.properties;if(a&&a.cluster?d+a.point_count<=e?d+=a.point_count:d=this._appendLeaves(o,a.cluster_id,i,e,d):d1;let m,y,S;if(w)m=re(r,a,this.clusterProps),y=r[a],S=r[a+1];else{const L=this.points[r[a+pt]];m=L.properties;const[_,k]=L.geometry.coordinates;y=$t(_),S=Mt(k)}const x={type:1,geometry:[[Math.round(this.options.extent*(y*d-i)),Math.round(this.options.extent*(S*d-e))]],tags:m};let b;w||this.options.generateId?b=r[a+pt]:b=this.points[r[a+pt]].id,b!==void 0&&(x.id=b),g.features.push(x)}}_limitZoom(o){return Math.max(this.options.minZoom,Math.min(Math.floor(+o),this.options.maxZoom+1))}_cluster(o,r){const{radius:i,extent:e,reduce:d,minPoints:g}=this.options,s=i/(e*Math.pow(2,r)),a=o.data,w=[],m=this.stride;for(let y=0;yr&&(_+=a[P+ut])}if(_>L&&_>=g){let k=S*L,P=x*L,I,Z=-1;const v=((y/m|0)<<5)+(r+1)+this.points.length;for(const u of b){const j=u*m;if(a[j+mt]<=r)continue;a[j+mt]=r;const K=a[j+ut];k+=a[j]*K,P+=a[j+1]*K,a[j+Nt]=v,d&&(I||(I=this._map(a,y,!0),Z=this.clusterProps.length,this.clusterProps.push(I)),d(I,this._map(a,j)))}a[y+Nt]=v,w.push(k/_,P/_,1/0,v,-1,_),d&&w.push(Z)}else{for(let k=0;k1)for(const k of b){const P=k*m;if(!(a[P+mt]<=r)){a[P+mt]=r;for(let I=0;I>5}_getOriginZoom(o){return(o-this.points.length)%32}_map(o,r,i){if(o[r+ut]>1){const g=this.clusterProps[o[r+oe]];return i?Object.assign({},g):g}const e=this.points[o[r+pt]].properties,d=this.options.map(e);return i&&d===e?Object.assign({},d):d}}function Gt(A,o,r){return{type:"Feature",id:A[o+pt],properties:re(A,o,r),geometry:{type:"Point",coordinates:[Io(A[o]),Uo(A[o+1])]}}}function re(A,o,r){const i=A[o+ut],e=i>=1e4?`${Math.round(i/1e3)}k`:i>=1e3?`${Math.round(i/100)/10}k`:i,d=A[o+oe],g=d===-1?{}:Object.assign({},r[d]);return Object.assign(g,{cluster:!0,cluster_id:A[o+pt],point_count:i,point_count_abbreviated:e})}function $t(A){return A/360+.5}function Mt(A){const o=Math.sin(A*Math.PI/180),r=.5-.25*Math.log((1+o)/(1-o))/Math.PI;return r<0?0:r>1?1:r}function Io(A){return(A-.5)*360}function Uo(A){const o=(180-A*360)*Math.PI/180;return 360*Math.atan(Math.exp(o))/Math.PI-90}const Oo={class:"map-container"},Vo={key:0,class:"flex items-center justify-center h-96 glass-card backdrop-blur border border-black/6 dark:border-white/10 rounded-[12px] shadow-sm dark:shadow-none"},Ho={class:"hidden sm:inline"},Zo={key:3,class:"map-legend"},Wo={class:"legend-footer"},Qo={key:4,class:"map-attribution"},qo=bt({__name:"NetworkMap",props:{adverts:{},baseLatitude:{default:null},baseLongitude:{default:null},showLegend:{type:Boolean,default:!0}},emits:["update:showLegend"],setup(A,{expose:o,emit:r}){typeof window<"u"&&!window.chrome&&(window.chrome={runtime:{}});const i=A,e=r,d=()=>{e("update:showLegend",!i.showLegend)},g=F();let s=null;const a=F(new Map);let w=null;const m=F(new Map),y=F([]),S=F(!0),x=F(60),b=F(14),L=F(document.documentElement.classList.contains("dark")),_=new MutationObserver(()=>{const E=document.documentElement.classList.contains("dark");E!==L.value&&(L.value=E,s&&K())}),k=J(()=>i.baseLatitude!==null&&i.baseLongitude!==null&&typeof i.baseLatitude=="number"&&typeof i.baseLongitude=="number"&&i.baseLatitude!==0&&i.baseLongitude!==0&&Math.abs(i.baseLatitude)<=90&&Math.abs(i.baseLongitude)<=180),P=E=>new Date(E*1e3).toLocaleString(),I=E=>E?`${E} dBm`:"N/A",Z=E=>E?`${E} dB`:"N/A",v=E=>({0:"Transport Flood",1:"Flood",2:"Direct",3:"Transport Direct"})[E||0]||"Unknown",u=(E,c,n,l)=>{const R=(n-E)*Math.PI/180,V=(l-c)*Math.PI/180,G=Math.sin(R/2)*Math.sin(R/2)+Math.cos(E*Math.PI/180)*Math.cos(n*Math.PI/180)*Math.sin(V/2)*Math.sin(V/2);return 6371*(2*Math.atan2(Math.sqrt(G),Math.sqrt(1-G)))},j=()=>{s&&(y.value.forEach(E=>{s&&E.remove()}),y.value.length=0,s.remove(),s=null),a.value.clear(),m.value.clear(),w=null},K=async()=>{const E=s?.getZoom()||11,c=s?.getCenter()||(k.value?[i.baseLatitude,i.baseLongitude]:[0,0]);j(),await Pt(),await at(),s&&s.setView(c,E)},et=E=>{const c=new Map;return E.filter(n=>n.latitude!==null&&n.longitude!==null).map(n=>{let l=n.latitude,B=n.longitude;const R=`${l.toFixed(6)}_${B.toFixed(6)}`,V=c.get(R)||0;if(c.set(R,V+1),V>0){const X=V*60*(Math.PI/180);l+=Math.sin(X)*.001*(V*.5),B+=Math.cos(X)*.001*(V*.5)}return{type:"Feature",properties:{advert:{...n,jittered_latitude:l,jittered_longitude:B}},geometry:{type:"Point",coordinates:[B,l]}}})},lt=E=>{w=new jo({radius:x.value,maxZoom:b.value,minPoints:2}),w.load(E)},at=async()=>{if(!g.value||!k.value){console.warn("Cannot initialize map: missing container or coordinates");return}j(),await Pt();const E=i.baseLatitude,c=i.baseLongitude;s=W.map(g.value,{center:[E,c],zoom:11,zoomControl:!0,attributionControl:!1,preferCanvas:!1});try{const n=L.value?"https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png":"https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png",l=L.value?"https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png":"https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png",B=W.tileLayer(n,{maxZoom:19,attribution:'© OpenStreetMap contributors © CARTO',errorTileUrl:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}),R=W.tileLayer(l,{maxZoom:19,attribution:"",errorTileUrl:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="});B.addTo(s),R.addTo(s)}catch(n){console.warn("Error loading tiles:",n)}try{const n=(z,H=!1)=>{const h=H?16:12;return W.divIcon({className:"custom-div-icon",html:`
`,iconSize:[h+4,h+4],iconAnchor:[(h+4)/2,(h+4)/2]})},l=z=>{const H=z<10?30:z<100?40:50;return W.divIcon({className:"custom-cluster-icon",html:` +import{a as bt,b as $,g as D,e as t,t as C,s as Lt,p as f,M as Yt,r as F,c as J,D as ht,N as Rt,f as it,T as Ft,l as Dt,O as jt,j as M,F as ct,h as gt,x as It,k as tt,o as Xt,P as te,i as ft,E as Pt,n as At,w as wt,Q as ie,q as Wt,v as le,L as Et}from"./index-DyUIpN7m.js";import{u as Ut}from"./useSignalQuality-DR_wpBbb.js";import{L as W}from"./leaflet-src-BtisrQHC.js";/* empty css */import{g as _t,s as Ct}from"./preferences-DtwbSSgO.js";import"./_commonjsHelpers-CqkleIqs.js";const de={class:"bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4 mb-6"},ce={class:"flex items-center gap-3"},ue={class:"flex-1 min-w-0"},pe={class:"text-content-primary dark:text-content-primary font-medium truncate"},ge={class:"text-content-secondary dark:text-content-muted text-sm font-mono"},me={key:0,class:"text-white/50 text-xs"},he={key:1,class:"text-white/50 text-xs"},be=bt({__name:"DeleteNeighborModal",props:{show:{type:Boolean},neighbor:{}},emits:["close","delete"],setup(A,{emit:o}){const r=A,i=o,e=()=>{r.neighbor&&(i("delete",r.neighbor.id),d())},d=()=>{i("close")},g=s=>{s.target===s.currentTarget&&d()};return(s,a)=>s.show&&s.neighbor?(f(),$("div",{key:0,onClick:g,class:"fixed inset-0 bg-black/80 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:a[0]||(a[0]=Lt(()=>{},["stop"]))},[t("div",{class:"flex items-center gap-3 mb-6"},[a[2]||(a[2]=t("svg",{class:"w-6 h-6 text-accent-red",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"})],-1)),a[3]||(a[3]=t("div",null,[t("h3",{class:"text-xl font-semibold text-content-primary dark:text-content-primary"},"Delete Neighbor"),t("p",{class:"text-content-secondary dark:text-content-muted text-sm mt-1"}," Are you sure you want to delete this neighbor? ")],-1)),t("button",{onClick:d,class:"ml-auto text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},a[1]||(a[1]=[t("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",de,[t("div",ce,[t("div",ue,[t("div",pe,C(s.neighbor?.node_name||s.neighbor?.long_name||s.neighbor?.short_name||"Unknown"),1),t("div",ge," ID: "+C(s.neighbor?.node_num_hex||s.neighbor?.node_num||s.neighbor?.id||"N/A"),1),s.neighbor?.contact_type?(f(),$("div",me,C(s.neighbor.contact_type),1)):D("",!0),s.neighbor?.hw_model?(f(),$("div",he,C(s.neighbor.hw_model),1)):D("",!0)])])]),a[4]||(a[4]=t("div",{class:"bg-accent-red/10 border border-accent-red/30 rounded-lg p-4 mb-6"},[t("div",{class:"flex items-center gap-2 text-accent-red text-sm"},[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})]),t("span",null,"This action cannot be undone")])],-1)),t("div",{class:"flex gap-3"},[t("button",{onClick:d,class:"flex-1 px-4 py-3 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),t("button",{onClick:e,class:"flex-1 px-4 py-3 bg-accent-red/20 hover:bg-accent-red/30 border border-accent-red/50 text-accent-red rounded-lg transition-colors font-medium"}," Delete ")])])])):D("",!0)}}),xe={class:"bg-gradient-to-r from-primary/20 to-accent-cyan/20 border-b border-stroke-subtle dark:border-stroke/10 px-6 py-4"},ye={class:"flex items-center justify-between"},ve={class:"flex items-center gap-3"},ke={key:0,class:"text-sm text-content-secondary dark:text-content-muted"},fe={class:"p-6"},we={key:0,class:"text-center py-8"},_e={key:1,class:"text-center py-8"},Ce={class:"text-content-secondary dark:text-content-muted text-sm"},$e={key:2,class:"space-y-4"},Me={class:"bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4"},Ae={class:"flex items-center justify-between mb-2"},Le={class:"flex items-baseline gap-2"},Te={class:"text-3xl font-bold text-content-primary dark:text-content-primary"},Ee={class:"grid grid-cols-2 gap-3"},Se={class:"bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4"},Be={class:"flex items-center gap-2 mb-2"},Ne={class:"flex gap-0.5"},Fe={class:"flex items-baseline gap-1"},De={class:"text-xl font-bold text-content-primary dark:text-content-primary"},Pe={class:"bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4"},ze={class:"flex items-baseline gap-1"},Re={class:"text-xl font-bold text-content-primary dark:text-content-primary"},je={class:"bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4"},Ie={class:"relative"},Ue={class:"flex items-center gap-2 overflow-x-auto pb-2"},Oe={key:0,class:"relative flex items-center"},Ve={key:0,class:"absolute left-1/2 -translate-x-1/2 animate-pulse"},He={class:"text-content-muted dark:text-content-muted text-xs mt-2 flex items-center justify-between"},Ze={key:0,class:"text-cyan-500 dark:text-primary animate-pulse"},We={class:"flex items-center justify-between text-xs text-content-muted dark:text-content-muted pt-2"},Qe=bt({__name:"PingResultModal",props:{show:{type:Boolean},nodeName:{default:null},result:{default:null},error:{default:null},loading:{type:Boolean,default:!1}},emits:["close"],setup(A,{emit:o}){const r=A,i=o,e=Yt(),{getSignalQuality:d}=Ut(),g=F(0),s=F(!1),a=J(()=>{const x=e.stats?.config?.radio?.spreading_factor??7,b=e.stats?.config?.radio?.bandwidth??125,L=e.stats?.config?.radio?.coding_rate??5,_=Math.pow(2,x)/b,k=8+4.25*(L-4)+20;return _*k}),w=J(()=>{if(!r.result)return{color:"text-gray-400",label:"Unknown"};const x=r.result.rtt_ms,b=a.value,L=r.result.path.length,k=2*b*L+500*L;return x{if(!r.result)return{bars:0,color:"text-gray-400"};const x=d(r.result.rssi);return{bars:x.bars,color:x.color}});ht(()=>r.result,x=>{if(x&&!s.value){s.value=!0,g.value=0;const b=x.path.length,_=1500/(b*2);let k=0;const P=b*2-2,I=()=>{k<=P?(g.value=k/P,k++,setTimeout(I,_)):(s.value=!1,g.value=1)};setTimeout(I,100)}},{immediate:!0});const v=J(()=>{if(!r.result||!s.value)return-1;const x=r.result.path.length;if(x<=1)return-1;const b=g.value,L=.5;if(b<=L)return b/L*(x-1);{const _=(b-L)/L;return(x-1)*(1-_)}}),S=()=>{i("close")};return(x,b)=>(f(),Rt(jt,{to:"body"},[it(Ft,{name:"modal"},{default:Dt(()=>[x.show?(f(),$("div",{key:0,class:"fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[99999] p-4",onClick:Lt(S,["self"])},[t("div",{class:"glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/20 rounded-[20px] shadow-2xl w-full max-w-md overflow-hidden",onClick:b[0]||(b[0]=Lt(()=>{},["stop"]))},[t("div",xe,[t("div",ye,[t("div",ve,[b[2]||(b[2]=t("div",{class:"p-2 bg-cyan-400/20 dark:bg-primary/20 rounded-lg"},[t("svg",{class:"w-5 h-5 text-cyan-500 dark:text-primary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"})])],-1)),t("div",null,[b[1]||(b[1]=t("h2",{class:"text-xl font-bold text-content-primary dark:text-content-primary"},"Ping Result",-1)),x.nodeName?(f(),$("p",ke,C(x.nodeName),1)):D("",!0)])]),t("button",{onClick:S,class:"p-2 hover:bg-stroke-subtle dark:hover:bg-white/10 rounded-lg transition-colors text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary"},b[3]||(b[3]=[t("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))])]),t("div",fe,[x.loading?(f(),$("div",we,b[4]||(b[4]=[t("div",{class:"animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"},null,-1),t("p",{class:"text-content-secondary dark:text-content-muted"},"Sending ping...",-1),t("p",{class:"text-content-muted dark:text-content-muted text-sm mt-1"},"Waiting for response...",-1)]))):x.error?(f(),$("div",_e,[b[5]||(b[5]=t("div",{class:"p-3 bg-accent-red/10 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center"},[t("svg",{class:"w-8 h-8 text-accent-red",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-1.964-1.333-2.732 0L3.268 16c-.77 1.333.192 3 1.732 3z"})])],-1)),b[6]||(b[6]=t("h3",{class:"text-accent-red font-semibold mb-2"},"Ping Failed",-1)),t("p",Ce,C(x.error),1)])):x.result?(f(),$("div",$e,[t("div",Me,[t("div",Ae,[b[7]||(b[7]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"Round-Trip Time",-1)),t("span",{class:M(["text-xs font-medium px-2 py-1 rounded-full",w.value.color,"bg-current/10"])},C(w.value.label),3)]),t("div",Le,[t("span",Te,C(x.result.rtt_ms.toFixed(2)),1),b[8]||(b[8]=t("span",{class:"text-content-secondary dark:text-content-muted"},"ms",-1))])]),t("div",Ee,[t("div",Se,[t("div",Be,[b[9]||(b[9]=t("span",{class:"text-content-secondary dark:text-content-muted text-sm"},"RSSI",-1)),t("div",Ne,[(f(),$(ct,null,gt(5,L=>t("div",{key:L,class:M(["w-1 h-3 rounded-sm",L<=m.value.bars?m.value.color:"bg-stroke-subtle dark:bg-stroke/10"])},null,2)),64))])]),t("div",Fe,[t("span",De,C(x.result.rssi),1),b[10]||(b[10]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs"},"dBm",-1))])]),t("div",Pe,[b[12]||(b[12]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-2"},"SNR",-1)),t("div",ze,[t("span",Re,C(x.result.snr_db),1),b[11]||(b[11]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs"},"dB",-1))])])]),t("div",je,[b[15]||(b[15]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-3"},"Network Path",-1)),t("div",Ie,[t("div",Ue,[(f(!0),$(ct,null,gt(x.result.path,(L,_)=>(f(),$("div",{key:_,class:"flex items-center gap-2 flex-shrink-0 relative"},[t("div",{class:M(["bg-cyan-400/20 dark:bg-primary/20 text-cyan-600 dark:text-primary border border-cyan-400/40 dark:border-primary/30 px-3 py-1.5 rounded-lg text-sm font-mono transition-all duration-300",s.value&&Math.floor(v.value)===_?"ring-2 ring-cyan-400/50 dark:ring-primary/50 scale-105":""])},C(L),3),_[s.value&&v.value>=_&&v.value<_+1?(f(),$("div",Ve,b[13]||(b[13]=[t("svg",{class:"w-3 h-3 text-cyan-500 dark:text-primary drop-shadow-[0_0_6px_rgba(6,182,212,0.8)] dark:drop-shadow-[0_0_6px_rgba(59,130,246,0.8)]",fill:"currentColor",viewBox:"0 0 24 24"},[t("circle",{cx:"12",cy:"12",r:"8"})],-1)]))):D("",!0)]),_:2},1024)])):D("",!0)]))),128))])]),t("div",He,[t("span",null,C(x.result.path.length)+" hop"+C(x.result.path.length!==1?"s":""),1),s.value?(f(),$("span",Ze,"● Tracing route...")):D("",!0)])]),t("div",We,[t("span",null,"Target: "+C(x.result.target_id),1),t("span",null,"Tag: #"+C(x.result.tag),1)])])):D("",!0)]),t("div",{class:"border-t border-stroke-subtle dark:border-stroke/10 px-6 py-4"},[t("button",{onClick:S,class:"w-full py-2.5 bg-gradient-to-r from-cyan-400 to-cyan-500 text-white hover:from-cyan-500 hover:to-cyan-600 dark:bg-primary/20 dark:text-primary dark:border dark:border-primary/30 dark:hover:bg-primary/30 dark:from-transparent dark:to-transparent rounded-lg font-medium transition-all shadow-[0_2px_12px_rgba(6,182,212,0.3)] dark:shadow-none"}," Close ")])])])):D("",!0)]),_:1})]))}}),qe=It(Qe,[["__scopeId","data-v-b206e10a"]]),Ke={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] shadow-2xl border border-stroke-subtle dark:border-white/20 flex flex-col h-full overflow-hidden"},Ge={class:"flex items-center justify-between p-8 pb-4 flex-shrink-0"},Je={class:"flex-1 min-w-0"},Ye={class:"text-2xl font-bold text-content-primary dark:text-content-primary mb-1"},Xe={class:"text-content-secondary dark:text-content-muted text-sm font-mono break-all"},to={class:"flex items-center gap-2"},eo={class:"flex-1 overflow-y-auto custom-scrollbar px-8"},oo={class:"mb-6"},ro={class:"grid grid-cols-1 md:grid-cols-2 gap-4"},no={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},so={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},ao={class:"text-content-primary dark:text-content-primary font-medium"},io={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},lo={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},co={class:"text-content-primary dark:text-content-primary font-medium"},uo={class:"mb-6"},po={class:"grid grid-cols-1 md:grid-cols-3 gap-4"},go={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},mo={class:"text-content-primary dark:text-content-primary font-medium"},ho={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},bo={class:"text-content-primary dark:text-content-primary font-medium"},xo={key:0,class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},yo={class:"flex items-center gap-2"},vo={class:"flex gap-0.5"},ko={class:"mb-6"},fo={class:"grid grid-cols-1 md:grid-cols-2 gap-4"},wo={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},_o={class:"text-content-primary dark:text-content-primary text-sm"},Co={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},$o={class:"text-content-primary dark:text-content-primary text-sm"},Mo={key:0,class:"mb-6"},Ao={class:"grid grid-cols-1 md:grid-cols-3 gap-4 mb-4"},Lo={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},To={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Eo={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},So={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Bo={class:"glass-card bg-background-mute dark:bg-black/20 p-4 rounded-[12px]"},No={class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},Fo={key:0,class:"text-content-primary dark:text-content-primary font-medium"},Do={class:"p-8 pt-4 border-t border-stroke-subtle dark:border-white/10 flex-shrink-0"},Po=bt({name:"NeighborDetailsModal",__name:"NeighborDetailsModal",props:{neighbor:{},isOpen:{type:Boolean},baseLatitude:{default:null},baseLongitude:{default:null}},emits:["close"],setup(A,{emit:o}){const{getSignalQuality:r}=Ut(),i=F("Copy"),e=A,d=o,g=F();let s=null;const a=y=>new Date(y*1e3).toLocaleString(),w=y=>y?`${y} dBm`:"N/A",m=y=>y?`${y.toFixed(1)} dB`:"N/A",v=y=>({0:"Transport Flood",1:"Flood",2:"Direct",3:"Transport Direct"})[y||0]||"Unknown",S=y=>({Unknown:"Unknown","Chat Node":"Chat Node",Repeater:"Repeater","Room Server":"Room Server","Hybrid Node":"Hybrid Node"})[y]||y,x=y=>({Unknown:"text-gray-600 dark:text-gray-400","Chat Node":"text-blue-600 dark:text-blue-400",Repeater:"text-emerald-600 dark:text-emerald-400","Room Server":"text-purple-600 dark:text-purple-400","Hybrid Node":"text-amber-600 dark:text-amber-400"})[y]||"text-gray-600 dark:text-gray-400",b=async()=>{if(!e.neighbor?.latitude||!e.neighbor?.longitude)return;const y=e.neighbor.latitude.toFixed(6),u=e.neighbor.longitude.toFixed(6),j=`${y}, ${u}`;try{await navigator.clipboard.writeText(j),i.value="Copied!",setTimeout(()=>{i.value="Copy"},2e3)}catch(K){console.error("Failed to copy coordinates:",K),i.value="Failed",setTimeout(()=>{i.value="Copy"},2e3)}},L=J(()=>{if(!e.neighbor?.latitude||!e.neighbor?.longitude||!e.baseLatitude||!e.baseLongitude)return null;const y=6371,u=(e.neighbor.latitude-e.baseLatitude)*Math.PI/180,j=(e.neighbor.longitude-e.baseLongitude)*Math.PI/180,K=Math.sin(u/2)*Math.sin(u/2)+Math.cos(e.baseLatitude*Math.PI/180)*Math.cos(e.neighbor.latitude*Math.PI/180)*Math.sin(j/2)*Math.sin(j/2),et=2*Math.atan2(Math.sqrt(K),Math.sqrt(1-K));return y*et}),_=J(()=>e.neighbor?.latitude!==null&&e.neighbor?.longitude!==null&&e.neighbor?.latitude!==0&&e.neighbor?.longitude!==0&&Math.abs(e.neighbor?.latitude??0)<=90&&Math.abs(e.neighbor?.longitude??0)<=180),k=()=>{if(!g.value||!e.neighbor||!_.value)return;s&&(s.remove(),s=null);const y=document.documentElement.classList.contains("dark");s=W.map(g.value,{center:[e.neighbor.latitude,e.neighbor.longitude],zoom:13,zoomControl:!0,attributionControl:!1});const u=y?"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png":"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";W.tileLayer(u,{maxZoom:19,attribution:"© OpenStreetMap © CARTO"}).addTo(s);const j=W.divIcon({className:"custom-marker",html:`
${e.neighbor.node_name?.charAt(0)||"?"}
`,iconSize:[32,32],iconAnchor:[16,16]});if(W.marker([e.neighbor.latitude,e.neighbor.longitude],{icon:j}).addTo(s).bindPopup(`${e.neighbor.node_name||"Unknown"}
${e.neighbor.pubkey.slice(0,8)}...`),e.baseLatitude!==null&&e.baseLongitude!==null&&e.baseLatitude!==0&&e.baseLongitude!==0&&Math.abs(e.baseLatitude)<=90&&Math.abs(e.baseLongitude)<=180){const et=W.divIcon({className:"custom-marker",html:'
B
',iconSize:[32,32],iconAnchor:[16,16]});W.marker([e.baseLatitude,e.baseLongitude],{icon:et}).addTo(s).bindPopup("Base Station"),W.polyline([[e.baseLatitude,e.baseLongitude],[e.neighbor.latitude,e.neighbor.longitude]],{color:"#3b82f6",weight:2,opacity:.6,dashArray:"5, 10"}).addTo(s);const lt=W.latLngBounds([e.baseLatitude,e.baseLongitude],[e.neighbor.latitude,e.neighbor.longitude]);s.fitBounds(lt,{padding:[50,50]})}},P=y=>{y.key==="Escape"&&d("close")},I=y=>{y.target===y.currentTarget&&d("close")};ht(()=>e.isOpen,y=>{y?(document.body.style.overflow="hidden",setTimeout(()=>{_.value&&k()},100)):(document.body.style.overflow="",s&&(s.remove(),s=null))},{immediate:!0});const Z=J(()=>e.neighbor?.rssi?r(e.neighbor.rssi):null);return(y,u)=>(f(),Rt(jt,{to:"body"},[it(Ft,{name:"modal",appear:""},{default:Dt(()=>[y.isOpen&&y.neighbor?(f(),$("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 overflow-hidden",onClick:I,onKeydown:P,tabindex:"0"},[u[20]||(u[20]=t("div",{class:"absolute inset-0 bg-black/60 backdrop-blur-md pointer-events-none"},null,-1)),t("div",{class:"relative w-full max-w-4xl max-h-[90vh] flex flex-col",onClick:u[2]||(u[2]=Lt(()=>{},["stop"]))},[t("div",Ke,[t("div",Ge,[t("div",Je,[t("h2",Ye,C(y.neighbor.node_name||"Unknown Node"),1),t("p",Xe,C(y.neighbor.pubkey),1)]),t("div",to,[t("button",{onClick:u[0]||(u[0]=j=>d("close")),class:"w-8 h-8 flex items-center justify-center rounded-full bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200 text-gray-700 dark:text-white hover:text-gray-900 dark:hover:text-white"},u[3]||(u[3]=[t("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))])]),t("div",eo,[t("div",oo,[u[8]||(u[8]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4"},"Basic Information",-1)),t("div",ro,[t("div",no,[u[4]||(u[4]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Contact Type",-1)),t("div",{class:M(["font-medium",x(y.neighbor.contact_type)])},C(S(y.neighbor.contact_type)),3)]),t("div",so,[u[5]||(u[5]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Route Type",-1)),t("div",ao,C(v(y.neighbor.route_type)),1)]),t("div",io,[u[6]||(u[6]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Zero Hop",-1)),t("div",{class:M(["font-medium",y.neighbor.zero_hop?"text-green-600 dark:text-green-400":"text-gray-600 dark:text-gray-400"])},C(y.neighbor.zero_hop?"Yes":"No"),3)]),t("div",lo,[u[7]||(u[7]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Advert Count",-1)),t("div",co,C(y.neighbor.advert_count.toLocaleString()),1)])])]),t("div",uo,[u[12]||(u[12]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4"},"Signal Quality",-1)),t("div",po,[t("div",go,[u[9]||(u[9]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"RSSI",-1)),t("div",mo,C(w(y.neighbor.rssi)),1)]),t("div",ho,[u[10]||(u[10]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"SNR",-1)),t("div",bo,C(m(y.neighbor.snr)),1)]),Z.value?(f(),$("div",xo,[u[11]||(u[11]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Signal Strength",-1)),t("div",yo,[t("div",vo,[(f(),$(ct,null,gt(4,j=>t("div",{key:j,class:M(["w-1 h-3 rounded-sm",j<=Z.value.bars?Z.value.color:"bg-gray-300 dark:bg-gray-700"])},null,2)),64))]),t("span",{class:M(["text-sm font-medium",Z.value.color])},C(Z.value.quality),3)])])):D("",!0)])]),t("div",ko,[u[15]||(u[15]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4"},"Timeline",-1)),t("div",fo,[t("div",wo,[u[13]||(u[13]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"First Seen",-1)),t("div",_o,C(a(y.neighbor.first_seen)),1)]),t("div",Co,[u[14]||(u[14]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Last Seen",-1)),t("div",$o,C(a(y.neighbor.last_seen)),1)])])]),_.value?(f(),$("div",Mo,[u[19]||(u[19]=t("h3",{class:"text-lg font-semibold text-content-primary dark:text-content-primary mb-4"},"Location",-1)),t("div",Ao,[t("div",Lo,[u[16]||(u[16]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Latitude",-1)),t("div",To,C(y.neighbor.latitude?.toFixed(6)),1)]),t("div",Eo,[u[17]||(u[17]=t("div",{class:"text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1"},"Longitude",-1)),t("div",So,C(y.neighbor.longitude?.toFixed(6)),1)]),t("div",Bo,[t("div",No,C(L.value!==null?"Distance":"Coordinates"),1),L.value!==null?(f(),$("div",Fo,C(L.value.toFixed(2))+" km ",1)):(f(),$("button",{key:1,onClick:b,class:"w-full px-3 py-1.5 bg-primary hover:bg-primary/90 dark:bg-gray-700 dark:hover:bg-gray-600 text-white text-sm font-medium rounded-lg transition-colors flex items-center justify-center gap-1.5"},[u[18]||(u[18]=t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"})],-1)),tt(" "+C(i.value),1)]))])]),t("div",{ref_key:"mapContainer",ref:g,class:"w-full h-96 rounded-[12px] overflow-hidden border border-stroke-subtle dark:border-white/10"},null,512)])):D("",!0)]),t("div",Do,[t("button",{onClick:u[1]||(u[1]=j=>d("close")),class:"w-full px-4 py-2.5 bg-primary hover:bg-primary/90 dark:bg-gray-700 dark:hover:bg-gray-600 text-white font-medium rounded-lg transition-colors"}," Close ")])])])],32)):D("",!0)]),_:1})]))}}),zo=It(Po,[["__scopeId","data-v-5669a05a"]]),Qt=[Int8Array,Uint8Array,Uint8ClampedArray,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array],St=1,vt=8;class Ot{static from(o){if(!(o instanceof ArrayBuffer))throw new Error("Data must be an instance of ArrayBuffer.");const[r,i]=new Uint8Array(o,0,2);if(r!==219)throw new Error("Data does not appear to be in a KDBush format.");const e=i>>4;if(e!==St)throw new Error(`Got v${e} data when expected v${St}.`);const d=Qt[i&15];if(!d)throw new Error("Unrecognized array type.");const[g]=new Uint16Array(o,2,1),[s]=new Uint32Array(o,4,1);return new Ot(s,g,d,o)}constructor(o,r=64,i=Float64Array,e){if(isNaN(o)||o<0)throw new Error(`Unpexpected numItems value: ${o}.`);this.numItems=+o,this.nodeSize=Math.min(Math.max(+r,2),65535),this.ArrayType=i,this.IndexArrayType=o<65536?Uint16Array:Uint32Array;const d=Qt.indexOf(this.ArrayType),g=o*2*this.ArrayType.BYTES_PER_ELEMENT,s=o*this.IndexArrayType.BYTES_PER_ELEMENT,a=(8-s%8)%8;if(d<0)throw new Error(`Unexpected typed array class: ${i}.`);e&&e instanceof ArrayBuffer?(this.data=e,this.ids=new this.IndexArrayType(this.data,vt,o),this.coords=new this.ArrayType(this.data,vt+s+a,o*2),this._pos=o*2,this._finished=!0):(this.data=new ArrayBuffer(vt+g+s+a),this.ids=new this.IndexArrayType(this.data,vt,o),this.coords=new this.ArrayType(this.data,vt+s+a,o*2),this._pos=0,this._finished=!1,new Uint8Array(this.data,0,2).set([219,(St<<4)+d]),new Uint16Array(this.data,2,1)[0]=r,new Uint32Array(this.data,4,1)[0]=o)}add(o,r){const i=this._pos>>1;return this.ids[i]=i,this.coords[this._pos++]=o,this.coords[this._pos++]=r,i}finish(){const o=this._pos>>1;if(o!==this.numItems)throw new Error(`Added ${o} items when expected ${this.numItems}.`);return zt(this.ids,this.coords,this.nodeSize,0,this.numItems-1,0),this._finished=!0,this}range(o,r,i,e){if(!this._finished)throw new Error("Data not yet indexed - call index.finish().");const{ids:d,coords:g,nodeSize:s}=this,a=[0,d.length-1,0],w=[];for(;a.length;){const m=a.pop()||0,v=a.pop()||0,S=a.pop()||0;if(v-S<=s){for(let _=S;_<=v;_++){const k=g[2*_],P=g[2*_+1];k>=o&&k<=i&&P>=r&&P<=e&&w.push(d[_])}continue}const x=S+v>>1,b=g[2*x],L=g[2*x+1];b>=o&&b<=i&&L>=r&&L<=e&&w.push(d[x]),(m===0?o<=b:r<=L)&&(a.push(S),a.push(x-1),a.push(1-m)),(m===0?i>=b:e>=L)&&(a.push(x+1),a.push(v),a.push(1-m))}return w}within(o,r,i){if(!this._finished)throw new Error("Data not yet indexed - call index.finish().");const{ids:e,coords:d,nodeSize:g}=this,s=[0,e.length-1,0],a=[],w=i*i;for(;s.length;){const m=s.pop()||0,v=s.pop()||0,S=s.pop()||0;if(v-S<=g){for(let _=S;_<=v;_++)qt(d[2*_],d[2*_+1],o,r)<=w&&a.push(e[_]);continue}const x=S+v>>1,b=d[2*x],L=d[2*x+1];qt(b,L,o,r)<=w&&a.push(e[x]),(m===0?o-i<=b:r-i<=L)&&(s.push(S),s.push(x-1),s.push(1-m)),(m===0?o+i>=b:r+i>=L)&&(s.push(x+1),s.push(v),s.push(1-m))}return a}}function zt(A,o,r,i,e,d){if(e-i<=r)return;const g=i+e>>1;ee(A,o,g,i,e,d),zt(A,o,r,i,g-1,1-d),zt(A,o,r,g+1,e,1-d)}function ee(A,o,r,i,e,d){for(;e>i;){if(e-i>600){const w=e-i+1,m=r-i+1,v=Math.log(w),S=.5*Math.exp(2*v/3),x=.5*Math.sqrt(v*S*(w-S)/w)*(m-w/2<0?-1:1),b=Math.max(i,Math.floor(r-m*S/w+x)),L=Math.min(e,Math.floor(r+(w-m)*S/w+x));ee(A,o,r,b,L,d)}const g=o[2*r+d];let s=i,a=e;for(kt(A,o,i,r),o[2*e+d]>g&&kt(A,o,i,e);sg;)a--}o[2*i+d]===g?kt(A,o,i,a):(a++,kt(A,o,a,e)),a<=r&&(i=a+1),r<=a&&(e=a-1)}}function kt(A,o,r,i){Bt(A,r,i),Bt(o,2*r,2*i),Bt(o,2*r+1,2*i+1)}function Bt(A,o,r){const i=A[o];A[o]=A[r],A[r]=i}function qt(A,o,r,i){const e=A-r,d=o-i;return e*e+d*d}const Ro={minZoom:0,maxZoom:16,minPoints:2,radius:40,extent:512,nodeSize:64,log:!1,generateId:!1,reduce:null,map:A=>A},Kt=Math.fround||(A=>o=>(A[0]=+o,A[0]))(new Float32Array(1)),mt=2,pt=3,Nt=4,ut=5,oe=6;class jo{constructor(o){this.options=Object.assign(Object.create(Ro),o),this.trees=new Array(this.options.maxZoom+1),this.stride=this.options.reduce?7:6,this.clusterProps=[]}load(o){const{log:r,minZoom:i,maxZoom:e}=this.options;r&&console.time("total time");const d=`prepare ${o.length} points`;r&&console.time(d),this.points=o;const g=[];for(let a=0;a=i;a--){const w=+Date.now();s=this.trees[a]=this._createTree(this._cluster(s,a)),r&&console.log("z%d: %d clusters in %dms",a,s.numItems,+Date.now()-w)}return r&&console.timeEnd("total time"),this}getClusters(o,r){let i=((o[0]+180)%360+360)%360-180;const e=Math.max(-90,Math.min(90,o[1]));let d=o[2]===180?180:((o[2]+180)%360+360)%360-180;const g=Math.max(-90,Math.min(90,o[3]));if(o[2]-o[0]>=360)i=-180,d=180;else if(i>d){const v=this.getClusters([i,e,180,g],r),S=this.getClusters([-180,e,d,g],r);return v.concat(S)}const s=this.trees[this._limitZoom(r)],a=s.range($t(i),Mt(g),$t(d),Mt(e)),w=s.data,m=[];for(const v of a){const S=this.stride*v;m.push(w[S+ut]>1?Gt(w,S,this.clusterProps):this.points[w[S+pt]])}return m}getChildren(o){const r=this._getOriginId(o),i=this._getOriginZoom(o),e="No cluster with the specified id.",d=this.trees[i];if(!d)throw new Error(e);const g=d.data;if(r*this.stride>=g.length)throw new Error(e);const s=this.options.radius/(this.options.extent*Math.pow(2,i-1)),a=g[r*this.stride],w=g[r*this.stride+1],m=d.within(a,w,s),v=[];for(const S of m){const x=S*this.stride;g[x+Nt]===o&&v.push(g[x+ut]>1?Gt(g,x,this.clusterProps):this.points[g[x+pt]])}if(v.length===0)throw new Error(e);return v}getLeaves(o,r,i){r=r||10,i=i||0;const e=[];return this._appendLeaves(e,o,r,i,0),e}getTile(o,r,i){const e=this.trees[this._limitZoom(o)],d=Math.pow(2,o),{extent:g,radius:s}=this.options,a=s/g,w=(i-a)/d,m=(i+1+a)/d,v={features:[]};return this._addTileFeatures(e.range((r-a)/d,w,(r+1+a)/d,m),e.data,r,i,d,v),r===0&&this._addTileFeatures(e.range(1-a/d,w,1,m),e.data,d,i,d,v),r===d-1&&this._addTileFeatures(e.range(0,w,a/d,m),e.data,-1,i,d,v),v.features.length?v:null}getClusterExpansionZoom(o){let r=this._getOriginZoom(o)-1;for(;r<=this.options.maxZoom;){const i=this.getChildren(o);if(r++,i.length!==1)break;o=i[0].properties.cluster_id}return r}_appendLeaves(o,r,i,e,d){const g=this.getChildren(r);for(const s of g){const a=s.properties;if(a&&a.cluster?d+a.point_count<=e?d+=a.point_count:d=this._appendLeaves(o,a.cluster_id,i,e,d):d1;let m,v,S;if(w)m=re(r,a,this.clusterProps),v=r[a],S=r[a+1];else{const L=this.points[r[a+pt]];m=L.properties;const[_,k]=L.geometry.coordinates;v=$t(_),S=Mt(k)}const x={type:1,geometry:[[Math.round(this.options.extent*(v*d-i)),Math.round(this.options.extent*(S*d-e))]],tags:m};let b;w||this.options.generateId?b=r[a+pt]:b=this.points[r[a+pt]].id,b!==void 0&&(x.id=b),g.features.push(x)}}_limitZoom(o){return Math.max(this.options.minZoom,Math.min(Math.floor(+o),this.options.maxZoom+1))}_cluster(o,r){const{radius:i,extent:e,reduce:d,minPoints:g}=this.options,s=i/(e*Math.pow(2,r)),a=o.data,w=[],m=this.stride;for(let v=0;vr&&(_+=a[P+ut])}if(_>L&&_>=g){let k=S*L,P=x*L,I,Z=-1;const y=((v/m|0)<<5)+(r+1)+this.points.length;for(const u of b){const j=u*m;if(a[j+mt]<=r)continue;a[j+mt]=r;const K=a[j+ut];k+=a[j]*K,P+=a[j+1]*K,a[j+Nt]=y,d&&(I||(I=this._map(a,v,!0),Z=this.clusterProps.length,this.clusterProps.push(I)),d(I,this._map(a,j)))}a[v+Nt]=y,w.push(k/_,P/_,1/0,y,-1,_),d&&w.push(Z)}else{for(let k=0;k1)for(const k of b){const P=k*m;if(!(a[P+mt]<=r)){a[P+mt]=r;for(let I=0;I>5}_getOriginZoom(o){return(o-this.points.length)%32}_map(o,r,i){if(o[r+ut]>1){const g=this.clusterProps[o[r+oe]];return i?Object.assign({},g):g}const e=this.points[o[r+pt]].properties,d=this.options.map(e);return i&&d===e?Object.assign({},d):d}}function Gt(A,o,r){return{type:"Feature",id:A[o+pt],properties:re(A,o,r),geometry:{type:"Point",coordinates:[Io(A[o]),Uo(A[o+1])]}}}function re(A,o,r){const i=A[o+ut],e=i>=1e4?`${Math.round(i/1e3)}k`:i>=1e3?`${Math.round(i/100)/10}k`:i,d=A[o+oe],g=d===-1?{}:Object.assign({},r[d]);return Object.assign(g,{cluster:!0,cluster_id:A[o+pt],point_count:i,point_count_abbreviated:e})}function $t(A){return A/360+.5}function Mt(A){const o=Math.sin(A*Math.PI/180),r=.5-.25*Math.log((1+o)/(1-o))/Math.PI;return r<0?0:r>1?1:r}function Io(A){return(A-.5)*360}function Uo(A){const o=(180-A*360)*Math.PI/180;return 360*Math.atan(Math.exp(o))/Math.PI-90}const Oo={class:"map-container"},Vo={key:0,class:"flex items-center justify-center h-96 glass-card backdrop-blur border border-black/6 dark:border-white/10 rounded-[12px] shadow-sm dark:shadow-none"},Ho={class:"hidden sm:inline"},Zo={key:3,class:"map-legend"},Wo={class:"legend-footer"},Qo={key:4,class:"map-attribution"},qo=bt({__name:"NetworkMap",props:{adverts:{},baseLatitude:{default:null},baseLongitude:{default:null},showLegend:{type:Boolean,default:!0}},emits:["update:showLegend"],setup(A,{expose:o,emit:r}){typeof window<"u"&&!window.chrome&&(window.chrome={runtime:{}});const i=A,e=r,d=()=>{e("update:showLegend",!i.showLegend)},g=F();let s=null;const a=F(new Map);let w=null;const m=F(new Map),v=F([]),S=F(!0),x=F(60),b=F(14),L=F(document.documentElement.classList.contains("dark")),_=new MutationObserver(()=>{const E=document.documentElement.classList.contains("dark");E!==L.value&&(L.value=E,s&&K())}),k=J(()=>i.baseLatitude!==null&&i.baseLongitude!==null&&typeof i.baseLatitude=="number"&&typeof i.baseLongitude=="number"&&i.baseLatitude!==0&&i.baseLongitude!==0&&Math.abs(i.baseLatitude)<=90&&Math.abs(i.baseLongitude)<=180),P=E=>new Date(E*1e3).toLocaleString(),I=E=>E?`${E} dBm`:"N/A",Z=E=>E?`${E} dB`:"N/A",y=E=>({0:"Transport Flood",1:"Flood",2:"Direct",3:"Transport Direct"})[E||0]||"Unknown",u=(E,c,n,l)=>{const R=(n-E)*Math.PI/180,V=(l-c)*Math.PI/180,G=Math.sin(R/2)*Math.sin(R/2)+Math.cos(E*Math.PI/180)*Math.cos(n*Math.PI/180)*Math.sin(V/2)*Math.sin(V/2);return 6371*(2*Math.atan2(Math.sqrt(G),Math.sqrt(1-G)))},j=()=>{s&&(v.value.forEach(E=>{s&&E.remove()}),v.value.length=0,s.remove(),s=null),a.value.clear(),m.value.clear(),w=null},K=async()=>{const E=s?.getZoom()||11,c=s?.getCenter()||(k.value?[i.baseLatitude,i.baseLongitude]:[0,0]);j(),await Pt(),await at(),s&&s.setView(c,E)},et=E=>{const c=new Map;return E.filter(n=>n.latitude!==null&&n.longitude!==null).map(n=>{let l=n.latitude,B=n.longitude;const R=`${l.toFixed(6)}_${B.toFixed(6)}`,V=c.get(R)||0;if(c.set(R,V+1),V>0){const X=V*60*(Math.PI/180);l+=Math.sin(X)*.001*(V*.5),B+=Math.cos(X)*.001*(V*.5)}return{type:"Feature",properties:{advert:{...n,jittered_latitude:l,jittered_longitude:B}},geometry:{type:"Point",coordinates:[B,l]}}})},lt=E=>{w=new jo({radius:x.value,maxZoom:b.value,minPoints:2}),w.load(E)},at=async()=>{if(!g.value||!k.value){console.warn("Cannot initialize map: missing container or coordinates");return}j(),await Pt();const E=i.baseLatitude,c=i.baseLongitude;s=W.map(g.value,{center:[E,c],zoom:11,zoomControl:!0,attributionControl:!1,preferCanvas:!1});try{const n=L.value?"https://{s}.basemaps.cartocdn.com/dark_nolabels/{z}/{x}/{y}{r}.png":"https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png",l=L.value?"https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png":"https://{s}.basemaps.cartocdn.com/light_only_labels/{z}/{x}/{y}{r}.png",B=W.tileLayer(n,{maxZoom:19,attribution:'© OpenStreetMap contributors © CARTO',errorTileUrl:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="}),R=W.tileLayer(l,{maxZoom:19,attribution:"",errorTileUrl:"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="});B.addTo(s),R.addTo(s)}catch(n){console.warn("Error loading tiles:",n)}try{const n=(z,H=!1)=>{const h=H?16:12;return W.divIcon({className:"custom-div-icon",html:`
`,iconSize:[h+4,h+4],iconAnchor:[(h+4)/2,(h+4)/2]})},l=z=>{const H=z<10?30:z<100?40:50;return W.divIcon({className:"custom-cluster-icon",html:`
Connection to ${z.node_name||"Unknown Node"}
Distance: ${ae.toFixed(2)} km
- Route: ${v(z.route_type)}
+ Route: ${y(z.route_type)}
Signal: ${I(z.rssi)} / ${Z(z.snr)}
- `),y.value.push(dt)},200)};Ht()},T)},G=()=>{if(!s||!w)return;const z=s.getBounds(),H=Math.floor(s.getZoom());m.value.forEach(p=>{s&&p.remove()}),m.value.clear(),y.value.forEach(p=>{s&&p.remove()}),y.value.length=0,w.getClusters([z.getWest(),z.getSouth(),z.getEast(),z.getNorth()],H).forEach(p=>{const[T,N]=p.geometry.coordinates,U=p.properties;if(U.cluster){const O=W.marker([N,T],{icon:l(U.point_count||0)}).addTo(s);O.on("click",()=>{if(s&&w){const st=w.getClusterExpansionZoom(U.cluster_id);s.setView([N,T],st)}});const ot=w.getLeaves(U.cluster_id,1/0).map(st=>`
+ `),v.value.push(dt)},200)};Ht()},T)},G=()=>{if(!s||!w)return;const z=s.getBounds(),H=Math.floor(s.getZoom());m.value.forEach(p=>{s&&p.remove()}),m.value.clear(),v.value.forEach(p=>{s&&p.remove()}),v.value.length=0,w.getClusters([z.getWest(),z.getSouth(),z.getEast(),z.getNorth()],H).forEach(p=>{const[T,N]=p.geometry.coordinates,U=p.properties;if(U.cluster){const O=W.marker([N,T],{icon:l(U.point_count||0)}).addTo(s);O.on("click",()=>{if(s&&w){const st=w.getClusterExpansionZoom(U.cluster_id);s.setView([N,T],st)}});const ot=w.getLeaves(U.cluster_id,1/0).map(st=>`
• ${st.properties.advert.node_name||"Unknown Node"} (${st.properties.advert.contact_type})
`).join("");O.bindPopup(`
@@ -48,7 +48,7 @@ import{a as bt,b as $,g as D,e as t,t as C,s as Lt,p as f,M as Yt,r as F,c as J, Type: ${O.contact_type}
Distance: ${rt.toFixed(2)} km
Signal: ${I(O.rssi)} / ${Z(O.snr)}
- Route: ${v(O.route_type)}
+ Route: ${y(O.route_type)}
Last Seen: ${P(O.last_seen)} ${O.jittered_latitude?'
Position adjusted to separate overlapping nodes':""}
@@ -58,8 +58,8 @@ import{a as bt,b as $,g as D,e as t,t as C,s as Lt,p as f,M as Yt,r as F,c as J, Type: ${N.contact_type}
Distance: ${u(z,H,Y,ot).toFixed(2)} km
Signal: ${I(N.rssi)} / ${Z(N.snr)}
- Route: ${v(N.route_type)}
+ Route: ${y(N.route_type)}
Last Seen: ${P(N.last_seen)} ${N.jittered_latitude?'
Position adjusted to separate overlapping nodes':""}
- `);a.value.set(N.pubkey,Q);const q=Q.getElement();q&&(q.style.opacity="0",q.style.transition="opacity 0.5s ease-out"),V(N,z,H,U,h),setTimeout(()=>{q&&(q.style.opacity="1")},h+1e3),h+=100}})};if(S.value&&i.adverts.length>0)try{const z=et(i.adverts);lt(z);const H=Math.min(14,s.getZoom());s.setZoom(H),setTimeout(()=>{try{G()}catch(h){console.warn("Error updating clusters:",h),X(E,c)}},100),s.on("moveend",()=>{try{G()}catch(h){console.warn("Error updating clusters on move:",h)}}),s.on("zoomend",()=>{try{G()}catch(h){console.warn("Error updating clusters on zoom:",h)}})}catch(z){console.warn("Error initializing clustering:",z),X(E,c)}else X(E,c);setTimeout(()=>{s&&s.invalidateSize()},1e3)}catch(n){console.error("Error initializing map:",n)}};return o({highlightNode:E=>{const c=a.value.get(E);if(c){const n=c.getElement();if(n){const l=n.querySelector("div");l&&l.classList.add("marker-highlight")}}},unhighlightNode:E=>{const c=a.value.get(E);if(c){const n=c.getElement();if(n){const l=n.querySelector("div");l&&l.classList.remove("marker-highlight")}}},initializeOpenStreetMap:at}),ht(()=>i.adverts,()=>{s&&k.value&&setTimeout(()=>{at()},100)},{immediate:!1}),Xt(()=>{_.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]}),k.value&&i.adverts.length>0&&setTimeout(()=>{at()},300)}),te(()=>{_.disconnect(),j()}),(E,c)=>(f(),$("div",Oo,[k.value?(f(),$("div",{key:1,ref_key:"mapContainer",ref:g,class:"leaflet-map-container h-96 w-full glass-card backdrop-blur border border-black/6 dark:border-white/10 rounded-[12px] overflow-hidden shadow-sm dark:shadow-none",style:{"min-height":"384px",position:"relative"}},null,512)):(f(),$("div",Vo,c[0]||(c[0]=[ft('

No valid coordinates available

Configure base station location to view map

',1)]))),k.value&&E.adverts.length>0?(f(),$("button",{key:2,onClick:d,class:"absolute bottom-3 right-3 z-[1001] flex items-center gap-2 px-3 py-2 bg-black/40 border border-white/10 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors text-sm backdrop-blur-sm"},[c[1]||(c[1]=t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"})],-1)),t("span",Ho,C(E.showLegend?"Hide":"Show"),1)])):D("",!0),k.value&&E.adverts.length>0&&E.showLegend?(f(),$("div",Zo,[c[2]||(c[2]=ft('
Node Types
Base Station
Chat Node
Repeater
Room Server
Hybrid Node
Route Types
Direct
Transport Direct
Flood
Transport Flood
',2)),t("div",Wo,C(E.adverts.length)+" node"+C(E.adverts.length!==1?"s":"")+" visible ",1)])):D("",!0),k.value?(f(),$("div",Qo," © OpenStreetMap contributors © CARTO ")):D("",!0)]))}}),Ko=It(qo,[["__scopeId","data-v-a6a23e33"]]),Go={class:"relative","data-menu-container":""},Jt=bt({__name:"NeighborMenu",props:{neighbor:{},canPing:{type:Boolean}},emits:["ping","delete","show-details"],setup(A,{emit:o}){const r=window.__neighborMenuManager||{activeMenu:null,setActiveMenu:_=>{if(r.activeMenu&&r.activeMenu!==_)try{r.activeMenu.closeMenu()}catch(k){console.warn("Error closing previous menu:",k)}r.activeMenu=_}};window.__neighborMenuManager=r;const i=A,e=o,d=F(!1),g=F(),s=F({top:0,left:0}),a=()=>{d.value=!1,document.removeEventListener("click",x,!0),document.removeEventListener("keydown",b),r.activeMenu===w&&(r.activeMenu=null)},w={closeMenu:a},m=()=>{a(),e("ping",i.neighbor)},y=()=>{a(),e("show-details",i.neighbor)},S=()=>{a(),e("delete",i.neighbor)},x=_=>{_.target.closest("[data-menu-container]")||a()},b=_=>{_.key==="Escape"&&a()},L=async()=>{if(!d.value&&g.value){r.setActiveMenu(w);const _=g.value.getBoundingClientRect(),k=window.innerWidth,P=144,I=k<1024,Z=_.left+P>k-16;let v=_.left;I&&Z&&(v=_.right-P),v=Math.max(8,v),s.value={top:_.bottom+4,left:v},d.value=!0,await Pt(),document.addEventListener("click",x,!0),document.addEventListener("keydown",b)}else a()};return te(()=>{a()}),(_,k)=>(f(),$("div",Go,[t("button",{ref_key:"buttonRef",ref:g,onClick:L,class:M(["p-1 rounded hover:bg-stroke-subtle dark:hover:bg-white/10 transition-colors text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary/80",{"bg-background-mute dark:bg-stroke/10 text-content-primary dark:text-content-primary/80":d.value}]),"data-menu-container":""},k[0]||(k[0]=[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"})],-1)]),2),(f(),Rt(jt,{to:"body"},[d.value?(f(),$("div",{key:0,class:"fixed w-36 bg-white dark:bg-surface-elevated backdrop-blur-lg border border-stroke-subtle dark:border-white/20 rounded-[15px] shadow-2xl z-[999999]",style:At({top:s.value.top+"px",left:s.value.left+"px"}),"data-menu-container":""},[t("div",{class:"py-2"},[t("button",{onClick:y,class:"flex items-center gap-3 w-full px-4 py-3 text-sm text-content-primary dark:text-content-primary hover:bg-primary/10 transition-colors border-b border-stroke-subtle dark:border-white/10"},k[1]||(k[1]=[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})],-1),t("span",{class:"font-medium"},"Details",-1)])),t("button",{onClick:m,class:"flex items-center gap-3 w-full px-4 py-3 text-sm text-content-primary dark:text-content-primary hover:bg-primary/10 transition-colors border-b border-stroke-subtle dark:border-white/10"},k[2]||(k[2]=[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"})],-1),t("span",{class:"font-medium"},"Ping",-1)])),t("button",{onClick:S,class:"flex items-center gap-3 w-full px-4 py-3 text-sm text-accent-red hover:bg-accent-red/10 transition-colors"},k[3]||(k[3]=[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"})],-1),t("span",{class:"font-medium"},"Delete",-1)]))])],4)):D("",!0)]))]))}}),Jo={class:"glass-card/30 backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[12px] p-6 shadow-sm dark:shadow-none"},Yo={class:"flex items-center justify-between mb-4"},Xo={class:"flex items-center gap-3"},tr={class:"text-content-primary dark:text-content-primary text-lg font-semibold"},er={class:"bg-background-mute dark:bg-white/10 text-content-secondary dark:text-content-primary text-xs px-2 py-1 rounded-full"},or={key:0,class:"text-content-muted dark:text-content-muted"},rr={key:0,class:"hidden lg:flex bg-background-mute dark:bg-surface-elevated/30 backdrop-blur rounded-lg border border-stroke-subtle dark:border-stroke/10 p-1"},nr={class:"hidden lg:block overflow-x-auto"},sr={class:"w-full"},ar={class:"bg-background-mute dark:bg-transparent"},ir={class:"flex items-center gap-1"},lr={class:"flex items-center gap-1"},dr={class:"flex items-center gap-1"},cr={class:"flex items-center gap-1"},ur={class:"flex items-center gap-1"},pr={class:"flex items-center gap-1"},gr={class:"flex items-center gap-1"},mr={class:"flex items-center gap-1"},hr={class:"flex items-center gap-1"},br={class:"bg-surface/50 dark:bg-transparent"},xr=["onMouseenter","onMouseleave"],vr=["onClick","title"],yr={key:0,class:"ml-1 text-xs"},kr={key:0,class:"flex items-center gap-3"},fr={class:"text-content-secondary dark:text-content-muted"},wr={class:"flex gap-1"},_r=["onClick"],Cr=["onClick"],$r={key:1,class:"text-content-muted"},Mr={class:"flex items-center gap-2"},Ar={class:"flex items-end gap-0.5"},Lr={class:"flex items-center gap-2"},Tr=["title"],Er=["title"],Sr={class:"lg:hidden space-y-3"},Br=["onClick"],Nr={class:"flex items-center justify-between mb-3"},Fr={class:"flex items-center gap-3"},Dr={class:"text-content-primary dark:text-content-primary font-medium text-base"},Pr={class:"flex items-center gap-2"},zr={class:"grid grid-cols-1 gap-3"},Rr={class:"grid grid-cols-2 gap-4"},jr=["onClick","title"],Ir={key:0,class:"ml-1 text-xs"},Ur={class:"flex items-center gap-2 justify-end"},Or={class:"flex items-end gap-0.5"},Vr={class:"grid grid-cols-2 gap-4"},Hr={class:"flex items-center gap-2"},Zr=["title"],Wr={class:"text-content-primary dark:text-content-primary text-sm block text-right"},Qr={key:0,class:"border-t border-white/10 pt-3"},qr={class:"flex items-center justify-between"},Kr={class:"text-content-secondary dark:text-content-muted text-sm font-mono"},Gr={class:"flex gap-2"},Jr=["onClick"],Yr=["onClick"],Xr={class:"grid grid-cols-3 gap-4 pt-3 border-t border-white/10"},tn={class:"text-center"},en={class:"text-content-primary dark:text-content-primary text-sm font-medium"},on={class:"text-center"},rn={class:"text-content-primary dark:text-content-primary text-sm font-medium"},nn={class:"text-center"},sn=["title"],an=bt({__name:"NeighborTable",props:{contactType:{},contactTypeKey:{},adverts:{},originalCount:{default:0},color:{},baseLatitude:{default:null},baseLongitude:{default:null},isCompactView:{type:Boolean,default:!1},isFirstTable:{type:Boolean,default:!1},showViewToggle:{type:Boolean,default:!1}},emits:["highlight-node","unhighlight-node","menu-ping","menu-delete","show-details","toggle-view"],setup(A,{emit:o}){const r=F(null),{getSignalQuality:i}=Ut(),e=F("advert_count"),d=F("desc"),g=A,s=o,a=c=>new Date(c*1e3).toLocaleString(),w=c=>`${c.slice(0,4)}...${c.slice(-4)}`,m=c=>{switch(c){case 2:return{text:"Direct",bgColor:"bg-green-100 dark:bg-green-500/20",borderColor:"border-green-500 dark:border-green-400/30",textColor:"text-green-600 dark:text-green-400"};case 3:return{text:"Transport Direct",bgColor:"bg-green-100 dark:bg-green-600/20",borderColor:"border-green-600/40 dark:border-green-500/30",textColor:"text-green-700 dark:text-green-500"};case 1:return{text:"Flood",bgColor:"bg-yellow-100 dark:bg-yellow-500/20",borderColor:"border-yellow-500 dark:border-yellow-400/30",textColor:"text-yellow-600 dark:text-yellow-400"};case 0:return{text:"Transport Flood",bgColor:"bg-orange-100 dark:bg-orange-500/20",borderColor:"border-orange-500 dark:border-orange-400/30",textColor:"text-orange-600 dark:text-orange-400"};default:return{text:"Unknown",bgColor:"bg-gray-500/20",borderColor:"border-gray-400/30",textColor:"text-gray-400"}}},y=c=>c?`${c} dBm`:"N/A",S=c=>c?`${c} dB`:"N/A",x=(c,n,l,B)=>{const V=(l-c)*Math.PI/180,G=(B-n)*Math.PI/180,X=Math.sin(V/2)*Math.sin(V/2)+Math.cos(c*Math.PI/180)*Math.cos(l*Math.PI/180)*Math.sin(G/2)*Math.sin(G/2);return 6371*(2*Math.atan2(Math.sqrt(X),Math.sqrt(1-X)))},b=c=>g.baseLatitude===null||g.baseLongitude===null||c.latitude===null||c.longitude===null?"N/A":`${x(g.baseLatitude,g.baseLongitude,c.latitude,c.longitude).toFixed(1)} km`,L=async c=>{try{return await navigator.clipboard.writeText(c),!0}catch{const n=document.createElement("textarea");return n.value=c,document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}},_=c=>{const n=Date.now(),l=c*1e3,B=n-l,R=Math.floor(B/1e3),V=Math.floor(R/60),G=Math.floor(V/60),X=Math.floor(G/24);return R<60?`${R}s ago`:V<60?`${V}m ago`:G<24?`${G}h ago`:`${X}d ago`},k=c=>{const n=Date.now(),l=c*1e3,B=n-l,R=Math.floor(B/(1e3*60*60));return R<1?{color:"text-green-600 dark:text-green-400"}:R<26?{color:"text-yellow-600 dark:text-yellow-400"}:{color:"text-red-600 dark:text-red-400"}},P=async(c,n)=>{const l=`${c.toFixed(6)}, ${n.toFixed(6)}`;await L(l)},I=(c,n)=>{const l=`https://www.google.com/maps?q=${c},${n}`;window.open(l,"_blank")},Z=async c=>{await L(c),r.value=c,setTimeout(()=>{r.value=null},2e3)},v=c=>{const n=i(c);return{bars:n.bars,color:n.color}},u=()=>g.isCompactView?"py-2 px-2":"py-4 px-3",j=()=>{s("toggle-view")},K=c=>{s("highlight-node",c)},et=c=>{s("unhighlight-node",c)},lt=c=>{s("menu-ping",c)},at=c=>{s("show-details",c)},vt=c=>{s("menu-delete",c)},nt=c=>{e.value===c?d.value=d.value==="asc"?"desc":"asc":(e.value=c,d.value=typeof g.adverts[0]?.[c]=="number"?"desc":"asc")},E=J(()=>e.value?[...g.adverts].sort((c,n)=>{const l=c[e.value],B=n[e.value];if(l==null)return 1;if(B==null)return-1;let R=0;return typeof l=="string"&&typeof B=="string"?R=l.localeCompare(B):typeof l=="number"&&typeof B=="number"?R=l-B:typeof l=="boolean"&&typeof B=="boolean"&&(R=l===B?0:l?1:-1),d.value==="asc"?R:-R}):g.adverts);return(c,n)=>(f(),$("div",Jo,[t("div",Yo,[t("div",Xo,[t("div",{class:"w-3 h-3 rounded-full border border-white/20",style:At({backgroundColor:c.color})},null,4),t("h3",tr,C(c.contactType),1),t("span",er,[tt(C(c.adverts.length)+" ",1),c.originalCount>0&&c.adverts.lengthnt("node_name")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",ir,[n[12]||(n[12]=tt(" Node Name ",-1)),e.value==="node_name"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[11]||(n[11]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[1]||(n[1]=l=>nt("pubkey")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",lr,[n[14]||(n[14]=tt(" Public Key ",-1)),e.value==="pubkey"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[13]||(n[13]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5`)},"Location",2),t("th",{class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5`)},"Distance",2),t("th",{onClick:n[2]||(n[2]=l=>nt("route_type")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",dr,[n[16]||(n[16]=tt(" Route Type ",-1)),e.value==="route_type"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[15]||(n[15]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[3]||(n[3]=l=>nt("zero_hop")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",cr,[n[18]||(n[18]=tt(" Zero Hop ",-1)),e.value==="zero_hop"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[17]||(n[17]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[4]||(n[4]=l=>nt("rssi")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",ur,[n[20]||(n[20]=tt(" RSSI ",-1)),e.value==="rssi"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[19]||(n[19]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[5]||(n[5]=l=>nt("snr")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",pr,[n[22]||(n[22]=tt(" SNR ",-1)),e.value==="snr"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[21]||(n[21]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[6]||(n[6]=l=>nt("last_seen")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",gr,[n[24]||(n[24]=tt(" Last Seen ",-1)),e.value==="last_seen"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[23]||(n[23]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[7]||(n[7]=l=>nt("first_seen")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",mr,[n[26]||(n[26]=tt(" First Seen ",-1)),e.value==="first_seen"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[25]||(n[25]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[8]||(n[8]=l=>nt("advert_count")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",hr,[n[28]||(n[28]=tt(" Advert Count ",-1)),e.value==="advert_count"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[27]||(n[27]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2)])]),t("tbody",br,[(f(!0),$(ct,null,gt(E.value,l=>(f(),$("tr",{key:l.id,class:"hover:bg-background-mute/50 dark:hover:bg-white/5 transition-colors",onMouseenter:B=>K(l.pubkey),onMouseleave:B=>et(l.pubkey)},[t("td",{class:M(u())},[it(Jt,{neighbor:l,onPing:lt,onShowDetails:at,onDelete:vt},null,8,["neighbor"])],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},C(l.node_name||"Unknown"),3),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm font-mono`)},[t("button",{onClick:B=>Z(l.pubkey),class:M(["text-content-primary dark:text-content-primary hover:text-primary-light transition-colors cursor-pointer underline underline-offset-2 decoration-gray-400 dark:decoration-white/30 hover:decoration-primary-light/60",r.value===l.pubkey?"text-green-600 dark:text-green-400 decoration-green-400/60":""]),title:r.value===l.pubkey?"Copied!":"Click to copy full public key"},[tt(C(w(l.pubkey))+" ",1),r.value===l.pubkey?(f(),$("span",yr,"✓")):D("",!0)],10,vr)],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[l.latitude!==null&&l.longitude!==null?(f(),$("div",kr,[t("span",fr,C(l.latitude.toFixed(4))+", "+C(l.longitude.toFixed(4)),1),t("div",wr,[t("button",{onClick:B=>P(l.latitude,l.longitude),class:"text-content-muted dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors cursor-pointer",title:"Copy coordinates to clipboard"},n[29]||(n[29]=[t("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"9",y:"9",width:"13",height:"13",rx:"2",ry:"2",stroke:"currentColor","stroke-width":"2"}),t("path",{d:"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1",stroke:"currentColor","stroke-width":"2"})],-1)]),8,_r),t("button",{onClick:B=>I(l.latitude,l.longitude),class:"text-white/60 hover:text-blue-600 dark:text-blue-400 transition-colors cursor-pointer",title:"Open in Google Maps"},n[30]||(n[30]=[t("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("path",{d:"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z",stroke:"currentColor","stroke-width":"2"}),t("circle",{cx:"12",cy:"10",r:"3",stroke:"currentColor","stroke-width":"2"})],-1)]),8,Cr)])])):(f(),$("span",$r,"Unknown"))],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},C(b(l)),3),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[t("span",{class:M(["inline-block px-2 py-1 rounded-full text-xs border transition-colors",m(l.route_type).bgColor,m(l.route_type).borderColor,m(l.route_type).textColor])},C(m(l.route_type).text),3)],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[t("span",{class:M(["inline-block px-2 py-1 rounded-full text-xs border transition-colors",l.zero_hop?"bg-green-100 dark:bg-green-500/20 border-green-500 dark:border-green-400/30 text-green-600 dark:text-green-400":"bg-orange-100 dark:bg-orange-500/20 border-orange-500 dark:border-orange-400/30 text-orange-600 dark:text-orange-400"])},C(l.zero_hop?"Zero Hop":"Multi-Hop"),3)],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[t("div",Mr,[t("div",Ar,[(f(),$(ct,null,gt(5,B=>t("div",{key:B,class:M(["w-1 transition-colors",B<=v(l.rssi).bars?v(l.rssi).color:"text-gray-600"]),style:At({height:`${4+B*2}px`})},n[31]||(n[31]=[t("div",{class:"w-full h-full bg-current rounded-sm"},null,-1)]),6)),64))]),t("span",{class:M(v(l.rssi).color)},C(y(l.rssi)),3)])],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},C(S(l.snr)),3),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[t("div",Lr,[t("div",{class:M(["w-2 h-2 rounded-full",k(l.last_seen).color==="text-green-600 dark:text-green-400"?"bg-green-400":"",k(l.last_seen).color==="text-yellow-600 dark:text-yellow-400"?"bg-yellow-400":"",k(l.last_seen).color==="text-red-600 dark:text-red-400"?"bg-red-400":""])},null,2),t("span",{class:M([k(l.last_seen).color,"cursor-help"]),title:a(l.last_seen)},C(_(l.last_seen)),11,Tr)])],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[t("span",{title:a(l.first_seen),class:"cursor-help"},C(_(l.first_seen)),9,Er)],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm text-center`)},C(l.advert_count),3)],40,xr))),128))])])]),t("div",Sr,[(f(!0),$(ct,null,gt(E.value,l=>(f(),$("div",{key:l.id,class:"bg-surface/50 dark:bg-transparent border border-stroke-subtle dark:border-white/10 rounded-lg p-4 hover:bg-background-mute/50 dark:hover:bg-white/5 transition-colors",onClick:B=>K(l.pubkey)},[t("div",Nr,[t("div",Fr,[t("h4",Dr,C(l.node_name||"Unknown Node"),1),t("div",Pr,[t("span",{class:M(["inline-block px-2 py-1 rounded-full text-xs border",m(l.route_type).bgColor,m(l.route_type).borderColor,m(l.route_type).textColor])},C(m(l.route_type).text),3),t("span",{class:M(["inline-block px-2 py-1 rounded-full text-xs border",l.zero_hop?"bg-green-100 dark:bg-green-500/20 border-green-500 dark:border-green-400/30 text-green-600 dark:text-green-400":"bg-orange-100 dark:bg-orange-500/20 border-orange-500 dark:border-orange-400/30 text-orange-600 dark:text-orange-400"])},C(l.zero_hop?"Zero Hop":"Multi-Hop"),3)])]),it(Jt,{neighbor:l,onPing:lt,onShowDetails:at,onDelete:vt},null,8,["neighbor"])]),t("div",zr,[t("div",Rr,[t("div",null,[n[32]||(n[32]=t("div",{class:"text-content-muted text-xs mb-1"},"Public Key",-1)),t("button",{onClick:B=>Z(l.pubkey),class:M(["text-content-primary dark:text-content-primary hover:text-primary-light transition-colors cursor-pointer font-mono text-sm underline underline-offset-2 decoration-gray-400 dark:decoration-white/30 hover:decoration-primary-light/60 break-all",r.value===l.pubkey?"text-green-600 dark:text-green-400 decoration-green-400/60":""]),title:r.value===l.pubkey?"Copied!":"Click to copy full public key"},[tt(C(w(l.pubkey))+" ",1),r.value===l.pubkey?(f(),$("span",Ir,"✓")):D("",!0)],10,jr)]),t("div",null,[n[34]||(n[34]=t("div",{class:"text-content-muted text-xs mb-1"},"Signal",-1)),t("div",Ur,[t("div",Or,[(f(),$(ct,null,gt(5,B=>t("div",{key:B,class:M(["w-1.5 transition-colors",B<=v(l.rssi).bars?v(l.rssi).color:"text-gray-600"]),style:At({height:`${6+B*2}px`})},n[33]||(n[33]=[t("div",{class:"w-full h-full bg-current rounded-sm"},null,-1)]),6)),64))]),t("span",{class:M(`${v(l.rssi).color} text-sm font-medium`)},C(y(l.rssi)),3)])])]),t("div",Vr,[t("div",null,[n[35]||(n[35]=t("div",{class:"text-content-muted text-xs mb-1"},"Last Seen",-1)),t("div",Hr,[t("div",{class:M(["w-2 h-2 rounded-full",k(l.last_seen).color==="text-green-600 dark:text-green-400"?"bg-green-400":"",k(l.last_seen).color==="text-yellow-600 dark:text-yellow-400"?"bg-yellow-400":"",k(l.last_seen).color==="text-red-600 dark:text-red-400"?"bg-red-400":""])},null,2),t("span",{class:M(`${k(l.last_seen).color} text-sm`),title:a(l.last_seen)},C(_(l.last_seen)),11,Zr)])]),t("div",null,[n[36]||(n[36]=t("div",{class:"text-content-muted text-xs mb-1"},"Distance",-1)),t("span",Wr,C(b(l)),1)])]),l.latitude!==null&&l.longitude!==null?(f(),$("div",Qr,[n[39]||(n[39]=t("div",{class:"text-content-muted text-xs mb-1"},"Location",-1)),t("div",qr,[t("span",Kr,C(l.latitude.toFixed(4))+", "+C(l.longitude.toFixed(4)),1),t("div",Gr,[t("button",{onClick:B=>P(l.latitude,l.longitude),class:"text-content-muted dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors p-2 hover:bg-stroke-subtle dark:hover:bg-white/10 rounded-lg",title:"Copy coordinates"},n[37]||(n[37]=[t("svg",{width:"16",height:"16",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"9",y:"9",width:"13",height:"13",rx:"2",ry:"2",stroke:"currentColor","stroke-width":"2"}),t("path",{d:"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1",stroke:"currentColor","stroke-width":"2"})],-1)]),8,Jr),t("button",{onClick:B=>I(l.latitude,l.longitude),class:"text-white/60 hover:text-blue-600 dark:text-blue-400 transition-colors p-2 hover:bg-white/10 rounded-lg",title:"Open in Maps"},n[38]||(n[38]=[t("svg",{width:"16",height:"16",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("path",{d:"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z",stroke:"currentColor","stroke-width":"2"}),t("circle",{cx:"12",cy:"10",r:"3",stroke:"currentColor","stroke-width":"2"})],-1)]),8,Yr)])])])):D("",!0),t("div",Xr,[t("div",tn,[n[40]||(n[40]=t("div",{class:"text-content-muted text-xs mb-1"},"SNR",-1)),t("span",en,C(S(l.snr)),1)]),t("div",on,[n[41]||(n[41]=t("div",{class:"text-content-muted text-xs mb-1"},"Adverts",-1)),t("span",rn,C(l.advert_count),1)]),t("div",nn,[n[42]||(n[42]=t("div",{class:"text-content-muted text-xs mb-1"},"First Seen",-1)),t("span",{class:"text-content-primary dark:text-content-primary text-sm",title:a(l.first_seen)},C(_(l.first_seen)),9,sn)])])])],8,Br))),128))])]))}}),ln={class:"space-y-6"},dn={key:0,class:"flex items-center justify-center py-12"},cn={key:1,class:"bg-red-50 dark:bg-accent-red/10 border border-red-300 dark:border-accent-red/20 rounded-[15px] p-6"},un={class:"flex items-center gap-3"},pn={class:"text-red-500 dark:text-accent-red/80 text-sm"},gn={key:0,class:""},mn={class:"flex items-center justify-between"},hn={class:"flex items-center gap-3"},bn={class:"hidden lg:flex bg-background-mute dark:bg-surface-elevated/30 backdrop-blur rounded-lg border border-stroke-subtle dark:border-stroke/10 mb p-1"},xn={class:"flex items-center gap-2"},vn={key:0,class:"ml-1 bg-accent-blue/20 text-accent-blue border border-accent-blue/30 text-xs px-1.5 py-0.5 rounded-full font-medium"},yn={class:"bg-background dark:bg-background/30 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4 mt-4 space-y-4"},kn={class:"grid grid-cols-1 md:grid-cols-3 gap-4"},fn={key:1,class:"text-center py-12"},wn={key:2,class:"text-center py-12"},Tn=bt({name:"NeighborsView",__name:"Neighbors",setup(A){const o=Yt(),r={0:"Unknown",1:"Chat Node",2:"Repeater",3:"Room Server",4:"Hybrid Node"},i={0:"#6b7280",1:"#60a5fa",2:"#34d399",3:"#a855f7",4:"#f59e0b"},e=F({}),d=F(!0),g=F(null),s=F(_t("neighbors_compactView",!1)),a=F(_t("neighbors_showMapLegend",typeof window<"u"?window.innerWidth>=1024:!0)),w=F(_t("neighbors_showFilters",!1)),m=F(_t("neighbors_filters",{zeroHop:"all",routeType:"all",searchText:""}));ht(s,h=>Ct("neighbors_compactView",h)),ht(a,h=>Ct("neighbors_showMapLegend",h)),ht(w,h=>Ct("neighbors_showFilters",h)),ht(m,h=>Ct("neighbors_filters",h),{deep:!0});const y=F(!1),S=F(!1),x=F(!1),b=F(null),L=F(null),_=F(null),k=F(null),P=F(!1),I=F(null),Z=J(()=>{if(!k.value)return null;const h=k.value;return{id:h.id,pubkey:h.pubkey,node_name:h.node_name,contact_type:h.contact_type,latitude:h.latitude,longitude:h.longitude,rssi:h.rssi,snr:h.snr,route_type:h.route_type,last_seen:h.last_seen,first_seen:h.first_seen,advert_count:h.advert_count,timestamp:h.timestamp,is_repeater:h.is_repeater,is_new_neighbor:h.is_new_neighbor,zero_hop:h.zero_hop}}),v=J(()=>o.stats?.config?.repeater?.latitude),u=J(()=>o.stats?.config?.repeater?.longitude),j=h=>h.filter(p=>{if(m.value.zeroHop!=="all"){const T=p.zero_hop;if(m.value.zeroHop==="true"&&!T||m.value.zeroHop==="false"&&T)return!1}if(m.value.routeType!=="all"){const T=p.route_type;if(m.value.routeType==="direct"&&T!==2||m.value.routeType==="transport_direct"&&T!==3||m.value.routeType==="flood"&&T!==1||m.value.routeType==="transport_flood"&&T!==0)return!1}if(m.value.searchText){const T=m.value.searchText.toLowerCase(),N=p.node_name?.toLowerCase()||"",U=p.pubkey.toLowerCase();if(!N.includes(T)&&!U.includes(T))return!1}return!0}),K=()=>{m.value={zeroHop:"all",routeType:"all",searchText:""}},et=J(()=>m.value.zeroHop!=="all"||m.value.routeType!=="all"||m.value.searchText!==""),lt=J(()=>{const h={};for(const[p,T]of Object.entries(e.value))h[p]=j(T);return h}),at=J(()=>Object.entries(r).filter(([h])=>lt.value[h]?.length>0).sort(([h],[p])=>parseInt(h)-parseInt(p))),vt=J(()=>Object.values(e.value).flat().filter(h=>{const p=h.latitude,T=h.longitude;return p!=null&&p!==0&&T!==null&&T!==void 0&&T!==0&&typeof p=="number"&&typeof T=="number"&&!isNaN(p)&&!isNaN(T)&&h.zero_hop===!0})),nt=async h=>{try{const p=await Et.get(`/adverts_by_contact_type?contact_type=${encodeURIComponent(h)}&hours=168`);return p.success&&Array.isArray(p.data)?p.data:[]}catch(p){return console.error(`Error fetching adverts for contact type ${h}:`,p),[]}},E=async()=>{d.value=!0,g.value=null;try{e.value={};for(const[h,p]of Object.entries(r)){const T=await nt(p);T.length>0&&(e.value[h]=T)}}catch(h){console.error("Error loading adverts:",h),g.value=h instanceof Error?h.message:"Failed to load neighbor data"}finally{d.value=!1}},c=F(),n=h=>{c.value?.highlightNode(h)},l=h=>{c.value?.unhighlightNode(h)},B=async h=>{const p=h;b.value=null,L.value=null,x.value=!0,_.value=p.node_name||"Unknown Node",S.value=!0;try{const N=`0x${parseInt(p.pubkey.substring(0,2),16).toString(16).padStart(2,"0")}`;console.log(`Pinging neighbor ${p.node_name||"Unknown"} (${N})...`);const U=await Et.pingNeighbor(N,10);U.success&&U.data?(b.value=U.data,console.log("Ping successful:",U.data)):(L.value=U.error||"Unknown error occurred",console.error("Failed to ping neighbor:",U.error))}catch(T){console.error("Error pinging neighbor:",T),L.value=T instanceof Error?T.message:"Unknown error occurred"}finally{x.value=!1}},R=()=>{S.value=!1,b.value=null,L.value=null,_.value=null},V=h=>{k.value=h,y.value=!0},G=h=>{I.value=h,P.value=!0},X=()=>{P.value=!1,I.value=null},z=()=>{y.value=!1,k.value=null},H=async h=>{try{await Et.deleteAdvert(h),await E(),z()}catch(p){console.error("Error deleting neighbor:",p)}};return Xt(async()=>{await E()}),(h,p)=>(f(),$("div",ln,[d.value?(f(),$("div",dn,p[7]||(p[7]=[t("div",{class:"text-center"},[t("div",{class:"animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"}),t("p",{class:"text-content-secondary dark:text-content-muted"},"Loading neighbor data...")],-1)]))):g.value?(f(),$("div",cn,[t("div",un,[p[9]||(p[9]=t("svg",{class:"w-5 h-5 text-red-600 dark:text-accent-red",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"})],-1)),t("div",null,[p[8]||(p[8]=t("h3",{class:"text-red-600 dark:text-accent-red font-medium"},"Error Loading Neighbors",-1)),t("p",pn,C(g.value),1)])])])):(f(),$(ct,{key:2},[it(Ko,{ref_key:"networkMapRef",ref:c,adverts:vt.value,"base-latitude":v.value,"base-longitude":u.value,"show-legend":a.value,"onUpdate:showLegend":p[0]||(p[0]=T=>a.value=T)},null,8,["adverts","base-latitude","base-longitude","show-legend"]),Object.keys(e.value).length>0?(f(),$("div",gn,[t("div",mn,[p[14]||(p[14]=t("span",{class:"text-content-primary dark:text-content-primary text-lg font-semibold"},null,-1)),t("div",hn,[t("div",bn,[t("button",{onClick:p[1]||(p[1]=T=>s.value=!1),class:M(["p-2 rounded-md transition-colors",s.value?"text-content-secondary dark:text-content-muted hover:text-primary hover:bg-primary/10":"bg-primary/20 text-primary border border-primary/30"]),title:"Comfortable view"},p[10]||(p[10]=[t("svg",{width:"18",height:"18",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"3",y:"3",width:"18",height:"6",rx:"2",stroke:"currentColor","stroke-width":"2"}),t("rect",{x:"3",y:"12",width:"18",height:"6",rx:"2",stroke:"currentColor","stroke-width":"2"})],-1)]),2),t("button",{onClick:p[2]||(p[2]=T=>s.value=!0),class:M(["p-2 rounded-md transition-colors",s.value?"bg-primary/20 text-primary border border-primary/30":"text-content-secondary dark:text-content-muted hover:text-primary hover:bg-primary/10"]),title:"Compact view"},p[11]||(p[11]=[t("svg",{width:"18",height:"18",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"3",y:"3",width:"18",height:"4",rx:"2",stroke:"currentColor","stroke-width":"2"}),t("rect",{x:"3",y:"10",width:"18",height:"4",rx:"2",stroke:"currentColor","stroke-width":"2"}),t("rect",{x:"3",y:"17",width:"18",height:"4",rx:"2",stroke:"currentColor","stroke-width":"2"})],-1)]),2)]),t("div",xn,[t("button",{onClick:p[3]||(p[3]=T=>w.value=!w.value),class:M(["px-3 py-1.5 text-xs rounded-lg transition-colors border",et.value?"bg-primary/20 text-primary border-primary/30":"bg-background-mute dark:bg-white/10 text-content-secondary dark:text-content-primary border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/20"])},[p[12]||(p[12]=t("svg",{class:"w-4 h-4 inline mr-1",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707v6.586a1 1 0 01-1.447.894l-4-2A1 1 0 717 18.586V13.414a1 1 0 00-.293-.707L.293 6.293A1 1 0 010 5.586V3a1 1 0 011-1z"})],-1)),p[13]||(p[13]=tt(" Filters ",-1)),et.value?(f(),$("span",vn," Active ")):D("",!0)],2),et.value?(f(),$("button",{key:0,onClick:K,class:"px-3 py-1.5 text-xs rounded-lg bg-background-mute dark:bg-white/10 text-content-secondary dark:text-content-primary border border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/20 transition-colors"}," Clear Filters ")):D("",!0)])])]),wt(t("div",yn,[t("div",kn,[t("div",null,[p[16]||(p[16]=t("label",{class:"block text-xs font-medium text-content-secondary dark:text-content-muted mb-1"},"Zero Hop",-1)),wt(t("select",{"onUpdate:modelValue":p[4]||(p[4]=T=>m.value.zeroHop=T),class:"w-full bg-surface dark:bg-surface/50 border border-stroke-subtle dark:border-stroke/20 rounded-lg px-3 py-2 text-content-primary dark:text-content-primary text-sm focus:border-primary/50 focus:outline-none"},p[15]||(p[15]=[t("option",{value:"all"},"All Nodes",-1),t("option",{value:"true"},"Zero Hop Only",-1),t("option",{value:"false"},"Multi-Hop Only",-1)]),512),[[Wt,m.value.zeroHop]])]),t("div",null,[p[18]||(p[18]=t("label",{class:"block text-xs font-medium text-content-secondary dark:text-content-muted mb-1"},"Route Type",-1)),wt(t("select",{"onUpdate:modelValue":p[5]||(p[5]=T=>m.value.routeType=T),class:"w-full bg-surface dark:bg-surface/50 border border-stroke-subtle dark:border-stroke/20 rounded-lg px-3 py-2 text-content-primary dark:text-content-primary text-sm focus:border-primary/50 focus:outline-none"},p[17]||(p[17]=[ft('',5)]),512),[[Wt,m.value.routeType]])]),t("div",null,[p[19]||(p[19]=t("label",{class:"block text-xs font-medium text-content-secondary dark:text-content-muted mb-1"},"Search",-1)),wt(t("input",{"onUpdate:modelValue":p[6]||(p[6]=T=>m.value.searchText=T),type:"text",placeholder:"Node name or pubkey...",class:"w-full bg-surface dark:bg-surface/50 border border-stroke-subtle dark:border-stroke/20 rounded-lg px-3 py-2 text-content-primary dark:text-content-primary text-sm focus:border-primary/50 focus:outline-none placeholder-gray-400 dark:placeholder-white/40"},null,512),[[le,m.value.searchText]])])])],512),[[ie,w.value]])])):D("",!0),(f(!0),$(ct,null,gt(at.value,([T,N])=>(f(),$("div",{key:T,class:"space-y-6"},[it(an,{"contact-type":N,"contact-type-key":T,adverts:lt.value[T],"original-count":e.value[T]?.length||0,color:i[parseInt(T)],"base-latitude":v.value,"base-longitude":u.value,"is-compact-view":s.value,"is-first-table":!1,"show-view-toggle":!1,onHighlightNode:n,onUnhighlightNode:l,onMenuPing:B,onMenuDelete:V,onShowDetails:G},null,8,["contact-type","contact-type-key","adverts","original-count","color","base-latitude","base-longitude","is-compact-view"])]))),128)),at.value.length===0&&Object.keys(e.value).length===0?(f(),$("div",fn,[p[20]||(p[20]=ft('

No Neighbors Found

No mesh neighbors have been discovered in your area yet.

',3)),t("button",{onClick:E,class:"mt-4 px-4 py-2 bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-colors"}," Refresh ")])):at.value.length===0&&et.value?(f(),$("div",wn,[p[21]||(p[21]=ft('

No neighbors match your filters

Try adjusting your filter criteria to see more results.

',3)),t("button",{onClick:K,class:"px-4 py-2 bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-colors"}," Clear Filters ")])):D("",!0)],64)),it(be,{show:y.value,neighbor:Z.value,onClose:z,onDelete:H},null,8,["show","neighbor"]),it(qe,{show:S.value,"node-name":_.value,result:b.value,error:L.value,loading:x.value,onClose:R},null,8,["show","node-name","result","error","loading"]),it(zo,{"is-open":P.value,neighbor:I.value,"base-latitude":v.value,"base-longitude":u.value,onClose:X},null,8,["is-open","neighbor","base-latitude","base-longitude"])]))}});export{Tn as default}; + `);a.value.set(N.pubkey,Q);const q=Q.getElement();q&&(q.style.opacity="0",q.style.transition="opacity 0.5s ease-out"),V(N,z,H,U,h),setTimeout(()=>{q&&(q.style.opacity="1")},h+1e3),h+=100}})};if(S.value&&i.adverts.length>0)try{const z=et(i.adverts);lt(z);const H=Math.min(14,s.getZoom());s.setZoom(H),setTimeout(()=>{try{G()}catch(h){console.warn("Error updating clusters:",h),X(E,c)}},100),s.on("moveend",()=>{try{G()}catch(h){console.warn("Error updating clusters on move:",h)}}),s.on("zoomend",()=>{try{G()}catch(h){console.warn("Error updating clusters on zoom:",h)}})}catch(z){console.warn("Error initializing clustering:",z),X(E,c)}else X(E,c);setTimeout(()=>{s&&s.invalidateSize()},1e3)}catch(n){console.error("Error initializing map:",n)}};return o({highlightNode:E=>{const c=a.value.get(E);if(c){const n=c.getElement();if(n){const l=n.querySelector("div");l&&l.classList.add("marker-highlight")}}},unhighlightNode:E=>{const c=a.value.get(E);if(c){const n=c.getElement();if(n){const l=n.querySelector("div");l&&l.classList.remove("marker-highlight")}}},initializeOpenStreetMap:at}),ht(()=>i.adverts,()=>{s&&k.value&&setTimeout(()=>{at()},100)},{immediate:!1}),Xt(()=>{_.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]}),k.value&&i.adverts.length>0&&setTimeout(()=>{at()},300)}),te(()=>{_.disconnect(),j()}),(E,c)=>(f(),$("div",Oo,[k.value?(f(),$("div",{key:1,ref_key:"mapContainer",ref:g,class:"leaflet-map-container h-96 w-full glass-card backdrop-blur border border-black/6 dark:border-white/10 rounded-[12px] overflow-hidden shadow-sm dark:shadow-none",style:{"min-height":"384px",position:"relative"}},null,512)):(f(),$("div",Vo,c[0]||(c[0]=[ft('

No valid coordinates available

Configure base station location to view map

',1)]))),k.value&&E.adverts.length>0?(f(),$("button",{key:2,onClick:d,class:"absolute bottom-3 right-3 z-[1001] flex items-center gap-2 px-3 py-2 bg-black/40 border border-white/10 rounded-lg text-white/80 hover:bg-white/10 hover:text-white transition-colors text-sm backdrop-blur-sm"},[c[1]||(c[1]=t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"})],-1)),t("span",Ho,C(E.showLegend?"Hide":"Show"),1)])):D("",!0),k.value&&E.adverts.length>0&&E.showLegend?(f(),$("div",Zo,[c[2]||(c[2]=ft('
Node Types
Base Station
Chat Node
Repeater
Room Server
Hybrid Node
Route Types
Direct
Transport Direct
Flood
Transport Flood
',2)),t("div",Wo,C(E.adverts.length)+" node"+C(E.adverts.length!==1?"s":"")+" visible ",1)])):D("",!0),k.value?(f(),$("div",Qo," © OpenStreetMap contributors © CARTO ")):D("",!0)]))}}),Ko=It(qo,[["__scopeId","data-v-a6a23e33"]]),Go={class:"relative","data-menu-container":""},Jt=bt({__name:"NeighborMenu",props:{neighbor:{},canPing:{type:Boolean}},emits:["ping","delete","show-details"],setup(A,{emit:o}){const r=window.__neighborMenuManager||{activeMenu:null,setActiveMenu:_=>{if(r.activeMenu&&r.activeMenu!==_)try{r.activeMenu.closeMenu()}catch(k){console.warn("Error closing previous menu:",k)}r.activeMenu=_}};window.__neighborMenuManager=r;const i=A,e=o,d=F(!1),g=F(),s=F({top:0,left:0}),a=()=>{d.value=!1,document.removeEventListener("click",x,!0),document.removeEventListener("keydown",b),r.activeMenu===w&&(r.activeMenu=null)},w={closeMenu:a},m=()=>{a(),e("ping",i.neighbor)},v=()=>{a(),e("show-details",i.neighbor)},S=()=>{a(),e("delete",i.neighbor)},x=_=>{_.target.closest("[data-menu-container]")||a()},b=_=>{_.key==="Escape"&&a()},L=async()=>{if(!d.value&&g.value){r.setActiveMenu(w);const _=g.value.getBoundingClientRect(),k=window.innerWidth,P=144,I=k<1024,Z=_.left+P>k-16;let y=_.left;I&&Z&&(y=_.right-P),y=Math.max(8,y),s.value={top:_.bottom+4,left:y},d.value=!0,await Pt(),document.addEventListener("click",x,!0),document.addEventListener("keydown",b)}else a()};return te(()=>{a()}),(_,k)=>(f(),$("div",Go,[t("button",{ref_key:"buttonRef",ref:g,onClick:L,class:M(["p-1 rounded hover:bg-stroke-subtle dark:hover:bg-white/10 transition-colors text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary/80",{"bg-background-mute dark:bg-stroke/10 text-content-primary dark:text-content-primary/80":d.value}]),"data-menu-container":""},k[0]||(k[0]=[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 5v.01M12 12v.01M12 19v.01M12 6a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2zm0 7a1 1 0 110-2 1 1 0 010 2z"})],-1)]),2),(f(),Rt(jt,{to:"body"},[d.value?(f(),$("div",{key:0,class:"fixed w-36 bg-white dark:bg-surface-elevated backdrop-blur-lg border border-stroke-subtle dark:border-white/20 rounded-[15px] shadow-2xl z-[999999]",style:At({top:s.value.top+"px",left:s.value.left+"px"}),"data-menu-container":""},[t("div",{class:"py-2"},[t("button",{onClick:v,class:"flex items-center gap-3 w-full px-4 py-3 text-sm text-content-primary dark:text-content-primary hover:bg-primary/10 transition-colors border-b border-stroke-subtle dark:border-white/10"},k[1]||(k[1]=[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})],-1),t("span",{class:"font-medium"},"Details",-1)])),t("button",{onClick:m,class:"flex items-center gap-3 w-full px-4 py-3 text-sm text-content-primary dark:text-content-primary hover:bg-primary/10 transition-colors border-b border-stroke-subtle dark:border-white/10"},k[2]||(k[2]=[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"})],-1),t("span",{class:"font-medium"},"Ping",-1)])),t("button",{onClick:S,class:"flex items-center gap-3 w-full px-4 py-3 text-sm text-accent-red hover:bg-accent-red/10 transition-colors"},k[3]||(k[3]=[t("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"})],-1),t("span",{class:"font-medium"},"Delete",-1)]))])],4)):D("",!0)]))]))}}),Jo={class:"glass-card/30 backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[12px] p-6 shadow-sm dark:shadow-none"},Yo={class:"flex items-center justify-between mb-4"},Xo={class:"flex items-center gap-3"},tr={class:"text-content-primary dark:text-content-primary text-lg font-semibold"},er={class:"bg-background-mute dark:bg-white/10 text-content-secondary dark:text-content-primary text-xs px-2 py-1 rounded-full"},or={key:0,class:"text-content-muted dark:text-content-muted"},rr={key:0,class:"hidden lg:flex bg-background-mute dark:bg-surface-elevated/30 backdrop-blur rounded-lg border border-stroke-subtle dark:border-stroke/10 p-1"},nr={class:"hidden lg:block overflow-x-auto"},sr={class:"w-full"},ar={class:"bg-background-mute dark:bg-transparent"},ir={class:"flex items-center gap-1"},lr={class:"flex items-center gap-1"},dr={class:"flex items-center gap-1"},cr={class:"flex items-center gap-1"},ur={class:"flex items-center gap-1"},pr={class:"flex items-center gap-1"},gr={class:"flex items-center gap-1"},mr={class:"flex items-center gap-1"},hr={class:"flex items-center gap-1"},br={class:"bg-surface/50 dark:bg-transparent"},xr=["onMouseenter","onMouseleave"],yr=["onClick","title"],vr={key:0,class:"ml-1 text-xs"},kr={key:0,class:"flex items-center gap-3"},fr={class:"text-content-secondary dark:text-content-muted"},wr={class:"flex gap-1"},_r=["onClick"],Cr=["onClick"],$r={key:1,class:"text-content-muted"},Mr={class:"flex items-center gap-2"},Ar={class:"flex items-end gap-0.5"},Lr={class:"flex items-center gap-2"},Tr=["title"],Er=["title"],Sr={class:"lg:hidden space-y-3"},Br=["onClick"],Nr={class:"flex items-center justify-between mb-3"},Fr={class:"flex items-center gap-3"},Dr={class:"text-content-primary dark:text-content-primary font-medium text-base"},Pr={class:"flex items-center gap-2"},zr={class:"grid grid-cols-1 gap-3"},Rr={class:"grid grid-cols-2 gap-4"},jr=["onClick","title"],Ir={key:0,class:"ml-1 text-xs"},Ur={class:"flex items-center gap-2 justify-end"},Or={class:"flex items-end gap-0.5"},Vr={class:"grid grid-cols-2 gap-4"},Hr={class:"flex items-center gap-2"},Zr=["title"],Wr={class:"text-content-primary dark:text-content-primary text-sm block text-right"},Qr={key:0,class:"border-t border-white/10 pt-3"},qr={class:"flex items-center justify-between"},Kr={class:"text-content-secondary dark:text-content-muted text-sm font-mono"},Gr={class:"flex gap-2"},Jr=["onClick"],Yr=["onClick"],Xr={class:"grid grid-cols-3 gap-4 pt-3 border-t border-white/10"},tn={class:"text-center"},en={class:"text-content-primary dark:text-content-primary text-sm font-medium"},on={class:"text-center"},rn={class:"text-content-primary dark:text-content-primary text-sm font-medium"},nn={class:"text-center"},sn=["title"],an=bt({__name:"NeighborTable",props:{contactType:{},contactTypeKey:{},adverts:{},originalCount:{default:0},color:{},baseLatitude:{default:null},baseLongitude:{default:null},isCompactView:{type:Boolean,default:!1},isFirstTable:{type:Boolean,default:!1},showViewToggle:{type:Boolean,default:!1}},emits:["highlight-node","unhighlight-node","menu-ping","menu-delete","show-details","toggle-view"],setup(A,{emit:o}){const r=F(null),{getSignalQuality:i}=Ut(),e=F("advert_count"),d=F("desc"),g=A,s=o,a=c=>new Date(c*1e3).toLocaleString(),w=c=>`${c.slice(0,4)}...${c.slice(-4)}`,m=c=>{switch(c){case 2:return{text:"Direct",bgColor:"bg-green-100 dark:bg-green-500/20",borderColor:"border-green-500 dark:border-green-400/30",textColor:"text-green-600 dark:text-green-400"};case 3:return{text:"Transport Direct",bgColor:"bg-green-100 dark:bg-green-600/20",borderColor:"border-green-600/40 dark:border-green-500/30",textColor:"text-green-700 dark:text-green-500"};case 1:return{text:"Flood",bgColor:"bg-yellow-100 dark:bg-yellow-500/20",borderColor:"border-yellow-500 dark:border-yellow-400/30",textColor:"text-yellow-600 dark:text-yellow-400"};case 0:return{text:"Transport Flood",bgColor:"bg-orange-100 dark:bg-orange-500/20",borderColor:"border-orange-500 dark:border-orange-400/30",textColor:"text-orange-600 dark:text-orange-400"};default:return{text:"Unknown",bgColor:"bg-gray-500/20",borderColor:"border-gray-400/30",textColor:"text-gray-400"}}},v=c=>c?`${c} dBm`:"N/A",S=c=>c?`${c} dB`:"N/A",x=(c,n,l,B)=>{const V=(l-c)*Math.PI/180,G=(B-n)*Math.PI/180,X=Math.sin(V/2)*Math.sin(V/2)+Math.cos(c*Math.PI/180)*Math.cos(l*Math.PI/180)*Math.sin(G/2)*Math.sin(G/2);return 6371*(2*Math.atan2(Math.sqrt(X),Math.sqrt(1-X)))},b=c=>g.baseLatitude===null||g.baseLongitude===null||c.latitude===null||c.longitude===null?"N/A":`${x(g.baseLatitude,g.baseLongitude,c.latitude,c.longitude).toFixed(1)} km`,L=async c=>{try{return await navigator.clipboard.writeText(c),!0}catch{const n=document.createElement("textarea");return n.value=c,document.body.appendChild(n),n.select(),document.execCommand("copy"),document.body.removeChild(n),!0}},_=c=>{const n=Date.now(),l=c*1e3,B=n-l,R=Math.floor(B/1e3),V=Math.floor(R/60),G=Math.floor(V/60),X=Math.floor(G/24);return R<60?`${R}s ago`:V<60?`${V}m ago`:G<24?`${G}h ago`:`${X}d ago`},k=c=>{const n=Date.now(),l=c*1e3,B=n-l,R=Math.floor(B/(1e3*60*60));return R<1?{color:"text-green-600 dark:text-green-400"}:R<26?{color:"text-yellow-600 dark:text-yellow-400"}:{color:"text-red-600 dark:text-red-400"}},P=async(c,n)=>{const l=`${c.toFixed(6)}, ${n.toFixed(6)}`;await L(l)},I=(c,n)=>{const l=`https://www.google.com/maps?q=${c},${n}`;window.open(l,"_blank")},Z=async c=>{await L(c),r.value=c,setTimeout(()=>{r.value=null},2e3)},y=c=>{const n=i(c);return{bars:n.bars,color:n.color}},u=()=>g.isCompactView?"py-2 px-2":"py-4 px-3",j=()=>{s("toggle-view")},K=c=>{s("highlight-node",c)},et=c=>{s("unhighlight-node",c)},lt=c=>{s("menu-ping",c)},at=c=>{s("show-details",c)},yt=c=>{s("menu-delete",c)},nt=c=>{e.value===c?d.value=d.value==="asc"?"desc":"asc":(e.value=c,d.value=typeof g.adverts[0]?.[c]=="number"?"desc":"asc")},E=J(()=>e.value?[...g.adverts].sort((c,n)=>{const l=c[e.value],B=n[e.value];if(l==null)return 1;if(B==null)return-1;let R=0;return typeof l=="string"&&typeof B=="string"?R=l.localeCompare(B):typeof l=="number"&&typeof B=="number"?R=l-B:typeof l=="boolean"&&typeof B=="boolean"&&(R=l===B?0:l?1:-1),d.value==="asc"?R:-R}):g.adverts);return(c,n)=>(f(),$("div",Jo,[t("div",Yo,[t("div",Xo,[t("div",{class:"w-3 h-3 rounded-full border border-white/20",style:At({backgroundColor:c.color})},null,4),t("h3",tr,C(c.contactType),1),t("span",er,[tt(C(c.adverts.length)+" ",1),c.originalCount>0&&c.adverts.lengthnt("node_name")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",ir,[n[12]||(n[12]=tt(" Node Name ",-1)),e.value==="node_name"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[11]||(n[11]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[1]||(n[1]=l=>nt("pubkey")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",lr,[n[14]||(n[14]=tt(" Public Key ",-1)),e.value==="pubkey"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[13]||(n[13]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5`)},"Location",2),t("th",{class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5`)},"Distance",2),t("th",{onClick:n[2]||(n[2]=l=>nt("route_type")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",dr,[n[16]||(n[16]=tt(" Route Type ",-1)),e.value==="route_type"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[15]||(n[15]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[3]||(n[3]=l=>nt("zero_hop")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",cr,[n[18]||(n[18]=tt(" Zero Hop ",-1)),e.value==="zero_hop"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[17]||(n[17]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[4]||(n[4]=l=>nt("rssi")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",ur,[n[20]||(n[20]=tt(" RSSI ",-1)),e.value==="rssi"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[19]||(n[19]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[5]||(n[5]=l=>nt("snr")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",pr,[n[22]||(n[22]=tt(" SNR ",-1)),e.value==="snr"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[21]||(n[21]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[6]||(n[6]=l=>nt("last_seen")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",gr,[n[24]||(n[24]=tt(" Last Seen ",-1)),e.value==="last_seen"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[23]||(n[23]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[7]||(n[7]=l=>nt("first_seen")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",mr,[n[26]||(n[26]=tt(" First Seen ",-1)),e.value==="first_seen"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[25]||(n[25]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2),t("th",{onClick:n[8]||(n[8]=l=>nt("advert_count")),class:M(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${u().split(" ")[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[t("div",hr,[n[28]||(n[28]=tt(" Advert Count ",-1)),e.value==="advert_count"?(f(),$("svg",{key:0,class:M(["w-3 h-3",d.value==="asc"?"":"rotate-180"]),fill:"currentColor",viewBox:"0 0 20 20"},n[27]||(n[27]=[t("path",{"fill-rule":"evenodd",d:"M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z","clip-rule":"evenodd"},null,-1)]),2)):D("",!0)])],2)])]),t("tbody",br,[(f(!0),$(ct,null,gt(E.value,l=>(f(),$("tr",{key:l.id,class:"hover:bg-background-mute/50 dark:hover:bg-white/5 transition-colors",onMouseenter:B=>K(l.pubkey),onMouseleave:B=>et(l.pubkey)},[t("td",{class:M(u())},[it(Jt,{neighbor:l,onPing:lt,onShowDetails:at,onDelete:yt},null,8,["neighbor"])],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},C(l.node_name||"Unknown"),3),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm font-mono`)},[t("button",{onClick:B=>Z(l.pubkey),class:M(["text-content-primary dark:text-content-primary hover:text-primary-light transition-colors cursor-pointer underline underline-offset-2 decoration-gray-400 dark:decoration-white/30 hover:decoration-primary-light/60",r.value===l.pubkey?"text-green-600 dark:text-green-400 decoration-green-400/60":""]),title:r.value===l.pubkey?"Copied!":"Click to copy full public key"},[tt(C(w(l.pubkey))+" ",1),r.value===l.pubkey?(f(),$("span",vr,"✓")):D("",!0)],10,yr)],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[l.latitude!==null&&l.longitude!==null?(f(),$("div",kr,[t("span",fr,C(l.latitude.toFixed(4))+", "+C(l.longitude.toFixed(4)),1),t("div",wr,[t("button",{onClick:B=>P(l.latitude,l.longitude),class:"text-content-muted dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors cursor-pointer",title:"Copy coordinates to clipboard"},n[29]||(n[29]=[t("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"9",y:"9",width:"13",height:"13",rx:"2",ry:"2",stroke:"currentColor","stroke-width":"2"}),t("path",{d:"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1",stroke:"currentColor","stroke-width":"2"})],-1)]),8,_r),t("button",{onClick:B=>I(l.latitude,l.longitude),class:"text-white/60 hover:text-blue-600 dark:text-blue-400 transition-colors cursor-pointer",title:"Open in Google Maps"},n[30]||(n[30]=[t("svg",{width:"14",height:"14",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("path",{d:"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z",stroke:"currentColor","stroke-width":"2"}),t("circle",{cx:"12",cy:"10",r:"3",stroke:"currentColor","stroke-width":"2"})],-1)]),8,Cr)])])):(f(),$("span",$r,"Unknown"))],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},C(b(l)),3),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[t("span",{class:M(["inline-block px-2 py-1 rounded-full text-xs border transition-colors",m(l.route_type).bgColor,m(l.route_type).borderColor,m(l.route_type).textColor])},C(m(l.route_type).text),3)],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[t("span",{class:M(["inline-block px-2 py-1 rounded-full text-xs border transition-colors",l.zero_hop?"bg-green-100 dark:bg-green-500/20 border-green-500 dark:border-green-400/30 text-green-600 dark:text-green-400":"bg-orange-100 dark:bg-orange-500/20 border-orange-500 dark:border-orange-400/30 text-orange-600 dark:text-orange-400"])},C(l.zero_hop?"Zero Hop":"Multi-Hop"),3)],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[t("div",Mr,[t("div",Ar,[(f(),$(ct,null,gt(5,B=>t("div",{key:B,class:M(["w-1 transition-colors",B<=y(l.rssi).bars?y(l.rssi).color:"text-gray-600"]),style:At({height:`${4+B*2}px`})},n[31]||(n[31]=[t("div",{class:"w-full h-full bg-current rounded-sm"},null,-1)]),6)),64))]),t("span",{class:M(y(l.rssi).color)},C(v(l.rssi)),3)])],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},C(S(l.snr)),3),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[t("div",Lr,[t("div",{class:M(["w-2 h-2 rounded-full",k(l.last_seen).color==="text-green-600 dark:text-green-400"?"bg-green-400":"",k(l.last_seen).color==="text-yellow-600 dark:text-yellow-400"?"bg-yellow-400":"",k(l.last_seen).color==="text-red-600 dark:text-red-400"?"bg-red-400":""])},null,2),t("span",{class:M([k(l.last_seen).color,"cursor-help"]),title:a(l.last_seen)},C(_(l.last_seen)),11,Tr)])],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm`)},[t("span",{title:a(l.first_seen),class:"cursor-help"},C(_(l.first_seen)),9,Er)],2),t("td",{class:M(`${u()} text-content-primary dark:text-content-primary text-sm text-center`)},C(l.advert_count),3)],40,xr))),128))])])]),t("div",Sr,[(f(!0),$(ct,null,gt(E.value,l=>(f(),$("div",{key:l.id,class:"bg-surface/50 dark:bg-transparent border border-stroke-subtle dark:border-white/10 rounded-lg p-4 hover:bg-background-mute/50 dark:hover:bg-white/5 transition-colors",onClick:B=>K(l.pubkey)},[t("div",Nr,[t("div",Fr,[t("h4",Dr,C(l.node_name||"Unknown Node"),1),t("div",Pr,[t("span",{class:M(["inline-block px-2 py-1 rounded-full text-xs border",m(l.route_type).bgColor,m(l.route_type).borderColor,m(l.route_type).textColor])},C(m(l.route_type).text),3),t("span",{class:M(["inline-block px-2 py-1 rounded-full text-xs border",l.zero_hop?"bg-green-100 dark:bg-green-500/20 border-green-500 dark:border-green-400/30 text-green-600 dark:text-green-400":"bg-orange-100 dark:bg-orange-500/20 border-orange-500 dark:border-orange-400/30 text-orange-600 dark:text-orange-400"])},C(l.zero_hop?"Zero Hop":"Multi-Hop"),3)])]),it(Jt,{neighbor:l,onPing:lt,onShowDetails:at,onDelete:yt},null,8,["neighbor"])]),t("div",zr,[t("div",Rr,[t("div",null,[n[32]||(n[32]=t("div",{class:"text-content-muted text-xs mb-1"},"Public Key",-1)),t("button",{onClick:B=>Z(l.pubkey),class:M(["text-content-primary dark:text-content-primary hover:text-primary-light transition-colors cursor-pointer font-mono text-sm underline underline-offset-2 decoration-gray-400 dark:decoration-white/30 hover:decoration-primary-light/60 break-all",r.value===l.pubkey?"text-green-600 dark:text-green-400 decoration-green-400/60":""]),title:r.value===l.pubkey?"Copied!":"Click to copy full public key"},[tt(C(w(l.pubkey))+" ",1),r.value===l.pubkey?(f(),$("span",Ir,"✓")):D("",!0)],10,jr)]),t("div",null,[n[34]||(n[34]=t("div",{class:"text-content-muted text-xs mb-1"},"Signal",-1)),t("div",Ur,[t("div",Or,[(f(),$(ct,null,gt(5,B=>t("div",{key:B,class:M(["w-1.5 transition-colors",B<=y(l.rssi).bars?y(l.rssi).color:"text-gray-600"]),style:At({height:`${6+B*2}px`})},n[33]||(n[33]=[t("div",{class:"w-full h-full bg-current rounded-sm"},null,-1)]),6)),64))]),t("span",{class:M(`${y(l.rssi).color} text-sm font-medium`)},C(v(l.rssi)),3)])])]),t("div",Vr,[t("div",null,[n[35]||(n[35]=t("div",{class:"text-content-muted text-xs mb-1"},"Last Seen",-1)),t("div",Hr,[t("div",{class:M(["w-2 h-2 rounded-full",k(l.last_seen).color==="text-green-600 dark:text-green-400"?"bg-green-400":"",k(l.last_seen).color==="text-yellow-600 dark:text-yellow-400"?"bg-yellow-400":"",k(l.last_seen).color==="text-red-600 dark:text-red-400"?"bg-red-400":""])},null,2),t("span",{class:M(`${k(l.last_seen).color} text-sm`),title:a(l.last_seen)},C(_(l.last_seen)),11,Zr)])]),t("div",null,[n[36]||(n[36]=t("div",{class:"text-content-muted text-xs mb-1"},"Distance",-1)),t("span",Wr,C(b(l)),1)])]),l.latitude!==null&&l.longitude!==null?(f(),$("div",Qr,[n[39]||(n[39]=t("div",{class:"text-content-muted text-xs mb-1"},"Location",-1)),t("div",qr,[t("span",Kr,C(l.latitude.toFixed(4))+", "+C(l.longitude.toFixed(4)),1),t("div",Gr,[t("button",{onClick:B=>P(l.latitude,l.longitude),class:"text-content-muted dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors p-2 hover:bg-stroke-subtle dark:hover:bg-white/10 rounded-lg",title:"Copy coordinates"},n[37]||(n[37]=[t("svg",{width:"16",height:"16",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"9",y:"9",width:"13",height:"13",rx:"2",ry:"2",stroke:"currentColor","stroke-width":"2"}),t("path",{d:"M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1",stroke:"currentColor","stroke-width":"2"})],-1)]),8,Jr),t("button",{onClick:B=>I(l.latitude,l.longitude),class:"text-white/60 hover:text-blue-600 dark:text-blue-400 transition-colors p-2 hover:bg-white/10 rounded-lg",title:"Open in Maps"},n[38]||(n[38]=[t("svg",{width:"16",height:"16",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("path",{d:"M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z",stroke:"currentColor","stroke-width":"2"}),t("circle",{cx:"12",cy:"10",r:"3",stroke:"currentColor","stroke-width":"2"})],-1)]),8,Yr)])])])):D("",!0),t("div",Xr,[t("div",tn,[n[40]||(n[40]=t("div",{class:"text-content-muted text-xs mb-1"},"SNR",-1)),t("span",en,C(S(l.snr)),1)]),t("div",on,[n[41]||(n[41]=t("div",{class:"text-content-muted text-xs mb-1"},"Adverts",-1)),t("span",rn,C(l.advert_count),1)]),t("div",nn,[n[42]||(n[42]=t("div",{class:"text-content-muted text-xs mb-1"},"First Seen",-1)),t("span",{class:"text-content-primary dark:text-content-primary text-sm",title:a(l.first_seen)},C(_(l.first_seen)),9,sn)])])])],8,Br))),128))])]))}}),ln={class:"space-y-6"},dn={key:0,class:"flex items-center justify-center py-12"},cn={key:1,class:"bg-red-50 dark:bg-accent-red/10 border border-red-300 dark:border-accent-red/20 rounded-[15px] p-6"},un={class:"flex items-center gap-3"},pn={class:"text-red-500 dark:text-accent-red/80 text-sm"},gn={key:0,class:""},mn={class:"flex items-center justify-between"},hn={class:"flex items-center gap-3"},bn={class:"hidden lg:flex bg-background-mute dark:bg-surface-elevated/30 backdrop-blur rounded-lg border border-stroke-subtle dark:border-stroke/10 mb p-1"},xn={class:"flex items-center gap-2"},yn={key:0,class:"ml-1 bg-accent-cyan/20 text-accent-cyan border border-accent-cyan/30 text-xs px-1.5 py-0.5 rounded-full font-medium"},vn={class:"bg-background dark:bg-background/30 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4 mt-4 space-y-4"},kn={class:"grid grid-cols-1 md:grid-cols-3 gap-4"},fn={key:1,class:"text-center py-12"},wn={key:2,class:"text-center py-12"},Tn=bt({name:"NeighborsView",__name:"Neighbors",setup(A){const o=Yt(),r={0:"Unknown",1:"Chat Node",2:"Repeater",3:"Room Server",4:"Hybrid Node"},i={0:"#6b7280",1:"#60a5fa",2:"#34d399",3:"#a855f7",4:"#f59e0b"},e=F({}),d=F(!0),g=F(null),s=F(_t("neighbors_compactView",!1)),a=F(_t("neighbors_showMapLegend",typeof window<"u"?window.innerWidth>=1024:!0)),w=F(_t("neighbors_showFilters",!1)),m=F(_t("neighbors_filters",{zeroHop:"all",routeType:"all",searchText:""}));ht(s,h=>Ct("neighbors_compactView",h)),ht(a,h=>Ct("neighbors_showMapLegend",h)),ht(w,h=>Ct("neighbors_showFilters",h)),ht(m,h=>Ct("neighbors_filters",h),{deep:!0});const v=F(!1),S=F(!1),x=F(!1),b=F(null),L=F(null),_=F(null),k=F(null),P=F(!1),I=F(null),Z=J(()=>{if(!k.value)return null;const h=k.value;return{id:h.id,pubkey:h.pubkey,node_name:h.node_name,contact_type:h.contact_type,latitude:h.latitude,longitude:h.longitude,rssi:h.rssi,snr:h.snr,route_type:h.route_type,last_seen:h.last_seen,first_seen:h.first_seen,advert_count:h.advert_count,timestamp:h.timestamp,is_repeater:h.is_repeater,is_new_neighbor:h.is_new_neighbor,zero_hop:h.zero_hop}}),y=J(()=>o.stats?.config?.repeater?.latitude),u=J(()=>o.stats?.config?.repeater?.longitude),j=h=>h.filter(p=>{if(m.value.zeroHop!=="all"){const T=p.zero_hop;if(m.value.zeroHop==="true"&&!T||m.value.zeroHop==="false"&&T)return!1}if(m.value.routeType!=="all"){const T=p.route_type;if(m.value.routeType==="direct"&&T!==2||m.value.routeType==="transport_direct"&&T!==3||m.value.routeType==="flood"&&T!==1||m.value.routeType==="transport_flood"&&T!==0)return!1}if(m.value.searchText){const T=m.value.searchText.toLowerCase(),N=p.node_name?.toLowerCase()||"",U=p.pubkey.toLowerCase();if(!N.includes(T)&&!U.includes(T))return!1}return!0}),K=()=>{m.value={zeroHop:"all",routeType:"all",searchText:""}},et=J(()=>m.value.zeroHop!=="all"||m.value.routeType!=="all"||m.value.searchText!==""),lt=J(()=>{const h={};for(const[p,T]of Object.entries(e.value))h[p]=j(T);return h}),at=J(()=>Object.entries(r).filter(([h])=>lt.value[h]?.length>0).sort(([h],[p])=>parseInt(h)-parseInt(p))),yt=J(()=>Object.values(e.value).flat().filter(h=>{const p=h.latitude,T=h.longitude;return p!=null&&p!==0&&T!==null&&T!==void 0&&T!==0&&typeof p=="number"&&typeof T=="number"&&!isNaN(p)&&!isNaN(T)&&h.zero_hop===!0})),nt=async h=>{try{const p=await Et.get(`/adverts_by_contact_type?contact_type=${encodeURIComponent(h)}&hours=168`);return p.success&&Array.isArray(p.data)?p.data:[]}catch(p){return console.error(`Error fetching adverts for contact type ${h}:`,p),[]}},E=async()=>{d.value=!0,g.value=null;try{e.value={};for(const[h,p]of Object.entries(r)){const T=await nt(p);T.length>0&&(e.value[h]=T)}}catch(h){console.error("Error loading adverts:",h),g.value=h instanceof Error?h.message:"Failed to load neighbor data"}finally{d.value=!1}},c=F(),n=h=>{c.value?.highlightNode(h)},l=h=>{c.value?.unhighlightNode(h)},B=async h=>{const p=h;b.value=null,L.value=null,x.value=!0,_.value=p.node_name||"Unknown Node",S.value=!0;try{const N=`0x${parseInt(p.pubkey.substring(0,2),16).toString(16).padStart(2,"0")}`;console.log(`Pinging neighbor ${p.node_name||"Unknown"} (${N})...`);const U=await Et.pingNeighbor(N,10);U.success&&U.data?(b.value=U.data,console.log("Ping successful:",U.data)):(L.value=U.error||"Unknown error occurred",console.error("Failed to ping neighbor:",U.error))}catch(T){console.error("Error pinging neighbor:",T),L.value=T instanceof Error?T.message:"Unknown error occurred"}finally{x.value=!1}},R=()=>{S.value=!1,b.value=null,L.value=null,_.value=null},V=h=>{k.value=h,v.value=!0},G=h=>{I.value=h,P.value=!0},X=()=>{P.value=!1,I.value=null},z=()=>{v.value=!1,k.value=null},H=async h=>{try{await Et.deleteAdvert(h),await E(),z()}catch(p){console.error("Error deleting neighbor:",p)}};return Xt(async()=>{await E()}),(h,p)=>(f(),$("div",ln,[d.value?(f(),$("div",dn,p[7]||(p[7]=[t("div",{class:"text-center"},[t("div",{class:"animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"}),t("p",{class:"text-content-secondary dark:text-content-muted"},"Loading neighbor data...")],-1)]))):g.value?(f(),$("div",cn,[t("div",un,[p[9]||(p[9]=t("svg",{class:"w-5 h-5 text-red-600 dark:text-accent-red",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"})],-1)),t("div",null,[p[8]||(p[8]=t("h3",{class:"text-red-600 dark:text-accent-red font-medium"},"Error Loading Neighbors",-1)),t("p",pn,C(g.value),1)])])])):(f(),$(ct,{key:2},[it(Ko,{ref_key:"networkMapRef",ref:c,adverts:yt.value,"base-latitude":y.value,"base-longitude":u.value,"show-legend":a.value,"onUpdate:showLegend":p[0]||(p[0]=T=>a.value=T)},null,8,["adverts","base-latitude","base-longitude","show-legend"]),Object.keys(e.value).length>0?(f(),$("div",gn,[t("div",mn,[p[14]||(p[14]=t("span",{class:"text-content-primary dark:text-content-primary text-lg font-semibold"},null,-1)),t("div",hn,[t("div",bn,[t("button",{onClick:p[1]||(p[1]=T=>s.value=!1),class:M(["p-2 rounded-md transition-colors",s.value?"text-content-secondary dark:text-content-muted hover:text-primary hover:bg-primary/10":"bg-primary/20 text-primary border border-primary/30"]),title:"Comfortable view"},p[10]||(p[10]=[t("svg",{width:"18",height:"18",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"3",y:"3",width:"18",height:"6",rx:"2",stroke:"currentColor","stroke-width":"2"}),t("rect",{x:"3",y:"12",width:"18",height:"6",rx:"2",stroke:"currentColor","stroke-width":"2"})],-1)]),2),t("button",{onClick:p[2]||(p[2]=T=>s.value=!0),class:M(["p-2 rounded-md transition-colors",s.value?"bg-primary/20 text-primary border border-primary/30":"text-content-secondary dark:text-content-muted hover:text-primary hover:bg-primary/10"]),title:"Compact view"},p[11]||(p[11]=[t("svg",{width:"18",height:"18",viewBox:"0 0 24 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[t("rect",{x:"3",y:"3",width:"18",height:"4",rx:"2",stroke:"currentColor","stroke-width":"2"}),t("rect",{x:"3",y:"10",width:"18",height:"4",rx:"2",stroke:"currentColor","stroke-width":"2"}),t("rect",{x:"3",y:"17",width:"18",height:"4",rx:"2",stroke:"currentColor","stroke-width":"2"})],-1)]),2)]),t("div",xn,[t("button",{onClick:p[3]||(p[3]=T=>w.value=!w.value),class:M(["px-3 py-1.5 text-xs rounded-lg transition-colors border",et.value?"bg-primary/20 text-primary border-primary/30":"bg-background-mute dark:bg-white/10 text-content-secondary dark:text-content-primary border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/20"])},[p[12]||(p[12]=t("svg",{class:"w-4 h-4 inline mr-1",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707v6.586a1 1 0 01-1.447.894l-4-2A1 1 0 717 18.586V13.414a1 1 0 00-.293-.707L.293 6.293A1 1 0 010 5.586V3a1 1 0 011-1z"})],-1)),p[13]||(p[13]=tt(" Filters ",-1)),et.value?(f(),$("span",yn," Active ")):D("",!0)],2),et.value?(f(),$("button",{key:0,onClick:K,class:"px-3 py-1.5 text-xs rounded-lg bg-background-mute dark:bg-white/10 text-content-secondary dark:text-content-primary border border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-white/20 transition-colors"}," Clear Filters ")):D("",!0)])])]),wt(t("div",vn,[t("div",kn,[t("div",null,[p[16]||(p[16]=t("label",{class:"block text-xs font-medium text-content-secondary dark:text-content-muted mb-1"},"Zero Hop",-1)),wt(t("select",{"onUpdate:modelValue":p[4]||(p[4]=T=>m.value.zeroHop=T),class:"w-full bg-surface dark:bg-surface/50 border border-stroke-subtle dark:border-stroke/20 rounded-lg px-3 py-2 text-content-primary dark:text-content-primary text-sm focus:border-primary/50 focus:outline-none"},p[15]||(p[15]=[t("option",{value:"all"},"All Nodes",-1),t("option",{value:"true"},"Zero Hop Only",-1),t("option",{value:"false"},"Multi-Hop Only",-1)]),512),[[Wt,m.value.zeroHop]])]),t("div",null,[p[18]||(p[18]=t("label",{class:"block text-xs font-medium text-content-secondary dark:text-content-muted mb-1"},"Route Type",-1)),wt(t("select",{"onUpdate:modelValue":p[5]||(p[5]=T=>m.value.routeType=T),class:"w-full bg-surface dark:bg-surface/50 border border-stroke-subtle dark:border-stroke/20 rounded-lg px-3 py-2 text-content-primary dark:text-content-primary text-sm focus:border-primary/50 focus:outline-none"},p[17]||(p[17]=[ft('',5)]),512),[[Wt,m.value.routeType]])]),t("div",null,[p[19]||(p[19]=t("label",{class:"block text-xs font-medium text-content-secondary dark:text-content-muted mb-1"},"Search",-1)),wt(t("input",{"onUpdate:modelValue":p[6]||(p[6]=T=>m.value.searchText=T),type:"text",placeholder:"Node name or pubkey...",class:"w-full bg-surface dark:bg-surface/50 border border-stroke-subtle dark:border-stroke/20 rounded-lg px-3 py-2 text-content-primary dark:text-content-primary text-sm focus:border-primary/50 focus:outline-none placeholder-gray-400 dark:placeholder-white/40"},null,512),[[le,m.value.searchText]])])])],512),[[ie,w.value]])])):D("",!0),(f(!0),$(ct,null,gt(at.value,([T,N])=>(f(),$("div",{key:T,class:"space-y-6"},[it(an,{"contact-type":N,"contact-type-key":T,adverts:lt.value[T],"original-count":e.value[T]?.length||0,color:i[parseInt(T)],"base-latitude":y.value,"base-longitude":u.value,"is-compact-view":s.value,"is-first-table":!1,"show-view-toggle":!1,onHighlightNode:n,onUnhighlightNode:l,onMenuPing:B,onMenuDelete:V,onShowDetails:G},null,8,["contact-type","contact-type-key","adverts","original-count","color","base-latitude","base-longitude","is-compact-view"])]))),128)),at.value.length===0&&Object.keys(e.value).length===0?(f(),$("div",fn,[p[20]||(p[20]=ft('

No Neighbors Found

No mesh neighbors have been discovered in your area yet.

',3)),t("button",{onClick:E,class:"mt-4 px-4 py-2 bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-colors"}," Refresh ")])):at.value.length===0&&et.value?(f(),$("div",wn,[p[21]||(p[21]=ft('

No neighbors match your filters

Try adjusting your filter criteria to see more results.

',3)),t("button",{onClick:K,class:"px-4 py-2 bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-colors"}," Clear Filters ")])):D("",!0)],64)),it(be,{show:v.value,neighbor:Z.value,onClose:z,onDelete:H},null,8,["show","neighbor"]),it(qe,{show:S.value,"node-name":_.value,result:b.value,error:L.value,loading:x.value,onClose:R},null,8,["show","node-name","result","error","loading"]),it(zo,{"is-open":P.value,neighbor:I.value,"base-latitude":y.value,"base-longitude":u.value,onClose:X},null,8,["is-open","neighbor","base-latitude","base-longitude"])]))}});export{Tn as default}; diff --git a/repeater/web/html/assets/RoomServers-Ba2ogzkk.js b/repeater/web/html/assets/RoomServers-Ba2ogzkk.js deleted file mode 100644 index 2a8a742..0000000 --- a/repeater/web/html/assets/RoomServers-Ba2ogzkk.js +++ /dev/null @@ -1 +0,0 @@ -import{a as te,b as s,g as c,e,j as f,t as a,s as q,p as n,r as d,D as xe,o as ge,L as y,f as Z,i as G,k,F as N,h as J,w as v,v as b,X as ee}from"./index-sHch0610.js";import{g as ye,s as ke}from"./preferences-DtwbSSgO.js";import{_ as fe}from"./ConfirmDialog.vue_vue_type_script_setup_true_lang-CT6z2S3q.js";const he={class:"mb-6"},we={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},_e={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Ce={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Me={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},je={class:"flex"},Le=te({__name:"MessageDialog",props:{show:{type:Boolean},message:{},variant:{default:"success"}},emits:["close"],setup(W,{emit:B}){const x=W,i=B,M=g=>{g.target===g.currentTarget&&i("close")},j={success:"bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400",error:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},l={success:"bg-green-500 hover:bg-green-600",error:"bg-red-500 hover:bg-red-600",info:"bg-blue-500 hover:bg-blue-600"};return(g,p)=>x.show?(n(),s("div",{key:0,onClick:M,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:p[1]||(p[1]=q(()=>{},["stop"]))},[e("div",he,[e("div",{class:f(["inline-flex p-3 rounded-xl mb-4",j[x.variant]])},[x.variant==="success"?(n(),s("svg",we,p[2]||(p[2]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"},null,-1)]))):x.variant==="error"?(n(),s("svg",_e,p[3]||(p[3]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"},null,-1)]))):(n(),s("svg",Ce,p[4]||(p[4]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),e("p",Me,a(x.message),1)]),e("div",je,[e("button",{onClick:p[0]||(p[0]=L=>i("close")),class:f(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",l[x.variant]])}," OK ",2)])])])):c("",!0)}}),$e={class:"p-6 space-y-6"},Se={class:"relative overflow-hidden rounded-[20px] p-6 mb-6 glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10"},Be={class:"relative flex items-center justify-between"},Ae={key:0,class:"grid grid-cols-1 md:grid-cols-3 gap-4"},Ve={class:"group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5 hover:scale-[1.02] transition-all duration-300 cursor-pointer"},Re={class:"relative flex items-center justify-between"},ze={class:"text-3xl font-bold text-content-primary dark:text-content-primary mb-1"},De={class:"group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5 hover:scale-[1.02] transition-all duration-300 cursor-pointer"},Ee={class:"relative flex items-center justify-between"},Fe={class:"text-3xl font-bold text-primary mb-1"},Ie={class:"group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5 hover:scale-[1.02] transition-all duration-300 cursor-pointer"},Ne={class:"relative flex items-center justify-between"},Ue={key:0,class:"w-6 h-6 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},He={key:1,class:"w-6 h-6 text-accent-yellow",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Ke={class:"glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6"},Oe={key:0,class:"flex items-center justify-center py-12"},Pe={key:1,class:"flex items-center justify-center py-12"},Te={class:"text-center"},Ge={class:"text-content-secondary dark:text-content-muted text-sm mb-4"},Je={key:2,class:"space-y-4"},qe={class:"relative flex items-start justify-between"},We={class:"flex-1"},Xe={class:"flex items-center gap-3 mb-4"},Qe={class:"relative"},Ye={key:0,class:"absolute inset-0 bg-accent-green/50 rounded-full animate-ping"},Ze={class:"text-xl font-bold text-content-primary dark:text-content-primary group-hover:text-primary transition-colors"},et={key:0,class:"text-content-muted dark:text-content-muted text-sm"},tt={class:"grid grid-cols-1 md:grid-cols-2 gap-3 text-sm mb-3"},rt={class:"text-content-primary dark:text-content-primary/90 ml-2"},ot={class:"flex items-center gap-2"},st={key:0,class:"text-content-primary dark:text-content-primary/90 font-mono ml-2 text-xs"},nt={key:1,class:"text-content-muted dark:text-content-muted ml-2 text-xs"},at=["onClick"],lt={class:"text-content-primary dark:text-content-primary/90 ml-2"},dt={key:0},it={class:"text-content-primary dark:text-content-primary/90 ml-2"},ut={key:0,class:"text-accent-green"},ct={key:1,class:"text-content-muted dark:text-content-muted"},pt={key:2,class:"text-primary"},mt={key:0,class:"text-xs text-content-muted dark:text-content-muted font-mono"},vt={class:"ml-4 flex flex-wrap gap-2"},bt=["onClick","disabled","title"],xt=["onClick","disabled","title"],gt=["onClick"],yt=["onClick"],kt={key:3,class:"text-center py-12 text-content-secondary dark:text-content-muted"},ft={key:1,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"},ht={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"},wt={class:"space-y-4"},_t={class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},Ct={key:0},Mt={key:1,class:"text-content-secondary dark:text-content-muted text-sm"},jt={class:"grid grid-cols-2 gap-4"},Lt={class:"grid grid-cols-2 gap-4"},$t={key:2,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"},St={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"},Bt={class:"space-y-4"},At=["value"],Vt={class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},Rt={key:0},zt={key:1,class:"text-content-secondary dark:text-content-muted text-sm"},Dt={class:"grid grid-cols-2 gap-4"},Et={class:"grid grid-cols-2 gap-4"},Ft={key:0,class:"fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4"},It={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[20px] p-6 max-w-4xl w-full h-[85vh] flex flex-col shadow-2xl"},Nt={class:"relative overflow-hidden rounded-[15px] mb-6 p-5 bg-white/50 dark:bg-white/5 border border-stroke-subtle dark:border-white/10"},Ut={class:"relative flex items-center justify-between"},Ht={class:"flex items-center gap-4"},Kt={class:"text-content-secondary dark:text-content-muted text-sm flex items-center gap-2"},Ot={class:"text-primary font-semibold"},Pt={class:"flex items-center gap-2"},Tt={class:"bg-primary/30 px-1.5 py-0.5 rounded-full text-[10px]"},Gt={class:"flex-1 overflow-y-auto mb-4 space-y-3"},Jt={key:0,class:"flex items-center justify-center py-12"},qt={key:1,class:"flex items-center justify-center py-12"},Wt={class:"text-center"},Xt={class:"text-content-secondary dark:text-content-muted text-sm mb-4"},Qt={key:2,class:"space-y-3"},Yt={class:"relative flex items-start justify-between gap-3"},Zt={class:"flex-1 min-w-0"},er={class:"flex items-center gap-2 mb-3"},tr={class:"flex items-center gap-2 flex-wrap"},rr={key:0,class:"text-primary text-sm font-bold"},or={key:1,class:"text-primary/80 text-xs font-mono bg-primary/10 px-2 py-1 rounded-md border border-primary/20"},sr={key:2,class:"text-content-muted dark:text-content-muted text-xs"},nr={class:"text-content-secondary dark:text-content-muted text-xs flex items-center gap-1"},ar={key:3,class:"text-content-muted dark:text-content-muted/50 text-[10px] font-mono bg-background-mute dark:bg-white/5 px-1.5 py-0.5 rounded"},lr={class:"text-content-primary dark:text-content-primary/90 text-sm leading-relaxed break-words whitespace-pre-wrap bg-gray-50 dark:bg-white/5 p-3 rounded-[10px] border border-stroke-subtle dark:border-white/5"},dr=["onClick"],ir={key:0,class:"text-center pt-4"},ur={key:1,class:"text-center pt-4"},cr={key:3,class:"flex items-center justify-center h-full"},pr={class:"relative overflow-hidden rounded-[15px] border-t border-stroke-subtle dark:border-white/20 pt-4 mt-4"},mr={class:"relative space-y-3"},vr={class:"flex gap-3"},br={class:"flex-1 relative"},xr=["onKeydown"],gr=["disabled"],yr={key:1,class:"fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-[60] p-4"},kr={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-3xl w-full max-h-[80vh] flex flex-col"},fr={class:"flex items-center justify-between mb-4 pb-4 border-b border-stroke-subtle dark:border-white/10"},hr={class:"text-content-secondary dark:text-content-primary/70 text-sm mt-1"},wr={class:"text-primary"},_r={class:"flex-1 overflow-y-auto space-y-3"},Cr={key:0,class:"text-center py-12"},Mr={class:"space-y-2"},jr={class:"flex items-center justify-between"},Lr={class:"flex items-center gap-2"},$r={class:"text-content-primary dark:text-content-primary font-semibold"},Sr={class:"flex items-center gap-2"},Br={class:"text-content-secondary dark:text-content-muted text-xs"},Ar=["onClick"],Vr={class:"space-y-1 text-xs"},Rr={class:"flex items-center gap-2"},zr={class:"text-primary font-mono bg-primary/10 px-2 py-0.5 rounded"},Dr={class:"flex items-center gap-2"},Er={class:"text-primary font-mono bg-primary/10 px-2 py-0.5 rounded text-[10px] break-all"},Fr={class:"flex items-center justify-between text-xs text-content-secondary dark:text-content-muted"},Ir={class:"flex items-center gap-4"},Nr={key:0},Ur={key:1},Hr={key:0},Tr=te({name:"RoomServersView",__name:"RoomServers",setup(W){const B=d(!1),x=d(null),i=d(null),M=d(!1),j=d(!1),l=d(null),g=d(!1),p=d(!1),L=d(new Set),z=d(!1),D=d(""),U=d(!1),H=d({message:"",variant:"success"}),K=d(!1),h=d(""),E=d(""),w=d([]),A=d(!1),$=d(null),_=d(""),F=d(ye("roomServers_messagesLimit",50)),I=d(0),O=d(!0);xe(F,o=>ke("roomServers_messagesLimit",o));const S=d([]),P=d(!1),m=d({name:"",identity_key:"",type:"room_server",settings:{node_name:"",latitude:0,longitude:0,admin_password:"",guest_password:""}});ge(async()=>{await V()});async function V(){B.value=!0,x.value=null;try{const o=await y.getIdentities();o.success?i.value=o.data:x.value=o.error||"Failed to load identities"}catch(o){x.value=o instanceof Error?o.message:"Failed to load identities"}finally{B.value=!1}}async function re(){try{const o=await y.createIdentity(m.value);o.success?(M.value=!1,X(),await V(),u(o.message||"Identity created successfully!","success")):u(`Failed to create identity: ${o.error}`,"error")}catch(o){u(`Error creating identity: ${o}`,"error")}}async function oe(){try{const o=await y.updateIdentity(l.value);o.success?(j.value=!1,l.value=null,await V(),u(o.message||"Identity updated successfully!","success")):u(`Failed to update identity: ${o.error}`,"error")}catch(o){u(`Error updating identity: ${o}`,"error")}}function se(o){D.value=o,z.value=!0}async function ne(){const o=D.value;z.value=!1;try{const t=await y.deleteIdentity(o);t.success?(await V(),u(t.message||"Identity deleted successfully!","success")):u(`Failed to delete identity: ${t.error}`,"error")}catch(t){u(`Error deleting identity: ${t}`,"error")}finally{D.value=""}}function u(o,t){H.value={message:o,variant:t},U.value=!0}async function ae(o){try{const t=await y.sendRoomServerAdvert(o);t.success?u(t.message||`Advert sent for '${o}'!`,"success"):u(`Failed to send advert: ${t.error}`,"error")}catch(t){u(`Error sending advert: ${t}`,"error")}}function le(o){l.value=JSON.parse(JSON.stringify(o)),l.value.settings||(l.value.settings={}),l.value.settings.admin_password||(l.value.settings.admin_password=""),l.value.settings.guest_password||(l.value.settings.guest_password=""),p.value=!1,j.value=!0}function X(){m.value={name:"",identity_key:"",type:"room_server",settings:{node_name:"",latitude:0,longitude:0,admin_password:"",guest_password:""}},g.value=!1}function Q(){M.value=!1,j.value=!1,l.value=null,g.value=!1,p.value=!1,X()}function de(o){L.value.has(o)?L.value.delete(o):L.value.add(o)}async function ie(o){h.value=o,K.value=!0,I.value=0,O.value=!0;const t=i.value?.configured.find(r=>r.name===o);E.value=t?.hash||"",await Y(),await R(!0)}async function Y(){try{console.log("Fetching ACL clients for room:",h.value,"hash:",E.value);const o=await y.getACLClients({identity_hash:E.value,identity_name:h.value});console.log("ACL clients response:",o),o.success&&o.data&&(S.value=o.data.clients||[],console.log("ACL clients loaded:",S.value.length))}catch(o){console.error("Failed to fetch ACL clients:",o)}}async function R(o=!1){o&&(I.value=0,w.value=[]),A.value=!0,$.value=null;try{const t=await y.getRoomMessages({room_name:h.value,limit:F.value,offset:I.value});if(t.success&&t.data){const r=t.data.messages||[];o?w.value=r:w.value=[...w.value,...r],O.value=r.length===F.value}else $.value=t.error||"Failed to load messages"}catch(t){$.value=t instanceof Error?t.message:"Failed to load messages"}finally{A.value=!1}}async function ue(){I.value+=F.value,await R(!1)}async function T(){if(_.value.trim())try{const o=await y.postRoomMessage({room_name:h.value,message:_.value,author_pubkey:"server"});o.success?(_.value="",await R(!0)):u(`Failed to send message: ${o.error}`,"error")}catch(o){u(`Error sending message: ${o}`,"error")}}async function ce(o){if(confirm("Are you sure you want to delete this message?"))try{const t=await y.deleteRoomMessage({room_name:h.value,message_id:o});t.success?(await R(!0),u("Message deleted successfully","success")):u(`Failed to delete message: ${t.error}`,"error")}catch(t){u(`Error deleting message: ${t}`,"error")}}function pe(){K.value=!1,h.value="",E.value="",w.value=[],_.value="",$.value=null,S.value=[]}function me(o){return o?new Date(o*1e3).toLocaleString():"Unknown"}async function ve(o,t){if(confirm("Are you sure you want to remove this client from the ACL?"))try{const r=await y.removeACLClient({public_key:o,identity_hash:t});r.success?(await Y(),u("Client removed successfully","success")):u(`Failed to remove client: ${r.error}`,"error")}catch(r){u(`Error removing client: ${r}`,"error")}}return(o,t)=>(n(),s(N,null,[e("div",$e,[e("div",Se,[t[26]||(t[26]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-primary/20 via-secondary/10 to-accent-purple/20 opacity-50"},null,-1)),t[27]||(t[27]=e("div",{class:"absolute inset-0 bg-gradient-to-tl from-accent-green/10 via-transparent to-primary/10 animate-pulse"},null,-1)),e("div",Be,[t[25]||(t[25]=G('

Room Servers

Manage room server identities and messages

',1)),e("button",{onClick:t[0]||(t[0]=r=>M.value=!0),class:"group relative px-6 py-3 bg-gradient-to-r from-primary/30 to-secondary/30 hover:from-primary/40 hover:to-secondary/40 text-content-primary dark:text-content-primary rounded-[12px] border border-primary/50 transition-all hover:scale-105 hover:shadow-lg hover:shadow-primary/20"},t[24]||(t[24]=[e("span",{class:"flex items-center gap-2"},[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})]),k(" Add Room Server ")],-1)]))])]),i.value&&i.value.total_configured>0?(n(),s("div",Ae,[e("div",Ve,[t[30]||(t[30]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"},null,-1)),e("div",Re,[e("div",null,[t[28]||(t[28]=e("div",{class:"text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide"},"Total Configured",-1)),e("div",ze,a(i.value.total_configured),1)]),t[29]||(t[29]=e("div",{class:"bg-background-mute dark:bg-white/10 p-3 rounded-[12px] group-hover:bg-background-mute dark:group-hover:bg-stroke/20 transition-colors"},[e("svg",{class:"w-6 h-6 text-content-secondary dark:text-content-primary/70",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"})])],-1))])]),e("div",De,[t[33]||(t[33]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"},null,-1)),e("div",Ee,[e("div",null,[t[31]||(t[31]=e("div",{class:"text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide"},"Currently Registered",-1)),e("div",Fe,a(i.value.total_registered),1)]),t[32]||(t[32]=e("div",{class:"bg-primary/20 p-3 rounded-[12px] group-hover:bg-primary/30 transition-colors"},[e("svg",{class:"w-6 h-6 text-primary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"})])],-1))])]),e("div",Ie,[t[37]||(t[37]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-accent-green/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"},null,-1)),e("div",Ne,[e("div",null,[t[34]||(t[34]=e("div",{class:"text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide"},"Status",-1)),e("div",{class:f(["text-3xl font-bold",i.value.total_registered===i.value.total_configured?"text-accent-green":"text-accent-yellow"])},a(i.value.total_registered===i.value.total_configured?"Synced":"Out of Sync"),3)]),e("div",{class:f(["p-3 rounded-[12px] transition-colors",i.value.total_registered===i.value.total_configured?"bg-accent-green/20 group-hover:bg-accent-green/30":"bg-accent-yellow/20 group-hover:bg-accent-yellow/30"])},[i.value.total_registered===i.value.total_configured?(n(),s("svg",Ue,t[35]||(t[35]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)]))):(n(),s("svg",He,t[36]||(t[36]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2)])])])):c("",!0),e("div",Ke,[B.value?(n(),s("div",Oe,t[38]||(t[38]=[e("div",{class:"text-center"},[e("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-primary rounded-full mx-auto mb-4"}),e("div",{class:"text-content-secondary dark:text-content-primary/70"},"Loading room servers...")],-1)]))):x.value?(n(),s("div",Pe,[e("div",Te,[t[39]||(t[39]=e("div",{class:"text-red-600 dark:text-red-400 mb-2"},"Failed to load room servers",-1)),e("div",Ge,a(x.value),1),e("button",{onClick:V,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors"}," Retry ")])])):i.value&&i.value.configured.length>0?(n(),s("div",Je,[(n(!0),s(N,null,J(i.value.configured,r=>(n(),s("div",{key:r.name,class:"group relative overflow-hidden glass-card backdrop-blur-xl rounded-[15px] p-5 border border-stroke-subtle dark:border-white/10 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/10 transition-all duration-300"},[t[46]||(t[46]=e("div",{class:"absolute inset-0 bg-gradient-to-r from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"},null,-1)),e("div",qe,[e("div",We,[e("div",Xe,[e("div",Qe,[r.registered?(n(),s("div",Ye)):c("",!0),e("div",{class:f(["relative w-3 h-3 rounded-full",r.registered?"bg-accent-green":"bg-accent-red"])},null,2)]),e("h3",Ze,a(r.name),1),e("span",{class:f(["px-3 py-1 text-xs font-semibold rounded-full",r.registered?"bg-accent-green/20 text-accent-green border border-accent-green/30":"bg-accent-red/20 text-accent-red border border-accent-red/30"])},a(r.registered?"● Active":"○ Inactive"),3),r.hash?(n(),s("span",et,a(r.hash),1)):c("",!0)]),e("div",tt,[e("div",null,[t[40]||(t[40]=e("span",{class:"text-content-muted dark:text-content-muted"},"Node Name:",-1)),e("span",rt,a(r.settings?.node_name||"Not set"),1)]),e("div",ot,[t[41]||(t[41]=e("span",{class:"text-content-muted dark:text-content-muted"},"Identity Key:",-1)),L.value.has(r.name)?(n(),s("span",st,a(r.identity_key),1)):(n(),s("span",nt," •••••••••••••••• ")),e("button",{onClick:C=>de(r.name),class:"text-primary/70 hover:text-primary text-xs underline"},a(L.value.has(r.name)?"Hide":"Show"),9,at)]),e("div",null,[t[42]||(t[42]=e("span",{class:"text-content-muted dark:text-content-muted"},"Location:",-1)),e("span",lt,a(r.settings?.latitude||0)+", "+a(r.settings?.longitude||0),1)]),r.settings?.admin_password||r.settings?.guest_password?(n(),s("div",dt,[t[43]||(t[43]=e("span",{class:"text-content-muted dark:text-content-muted"},"Passwords:",-1)),e("span",it,[r.settings?.admin_password?(n(),s("span",ut,"Admin")):c("",!0),r.settings?.admin_password&&r.settings?.guest_password?(n(),s("span",ct," / ")):c("",!0),r.settings?.guest_password?(n(),s("span",pt,"Guest")):c("",!0)])])):c("",!0)]),r.address?(n(),s("div",mt," Address: "+a(r.address),1)):c("",!0)]),e("div",vt,[e("button",{onClick:C=>ie(r.name),disabled:!r.registered,class:f(["group px-4 py-2 rounded-[10px] text-xs font-medium transition-all duration-200 flex items-center gap-2",r.registered?"bg-secondary/20 hover:bg-secondary/30 text-secondary border border-secondary/30 hover:scale-105 hover:shadow-lg hover:shadow-secondary/20":"bg-background-mute dark:bg-white/5 text-content-muted dark:text-content-muted/60 cursor-not-allowed border border-stroke-subtle dark:border-stroke/10"]),title:r.registered?"Manage messages for this room":"Room server must be active to manage messages"},t[44]||(t[44]=[e("svg",{class:"w-4 h-4 group-hover:rotate-12 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"})],-1),k(" Messages ",-1)]),10,bt),e("button",{onClick:C=>ae(r.name),disabled:!r.registered,class:f(["group px-4 py-2 rounded-[10px] text-xs font-medium transition-all duration-200 flex items-center gap-2",r.registered?"bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/30 hover:scale-105 hover:shadow-lg hover:shadow-accent-green/20":"bg-background-mute dark:bg-white/5 text-content-muted dark:text-content-muted/60 cursor-not-allowed border border-stroke-subtle dark:border-stroke/10"]),title:r.registered?"Send advert for this room server":"Room server must be active to send advert"},t[45]||(t[45]=[e("svg",{class:"w-4 h-4 group-hover:scale-110 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 10V3L4 14h7v7l9-11h-7z"})],-1),k(" Send Advert ",-1)]),10,xt),e("button",{onClick:C=>le(r),class:"px-3 py-1 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors"}," Edit ",8,gt),e("button",{onClick:C=>se(r.name),class:"px-3 py-1 bg-accent-red/20 hover:bg-accent-red/30 text-accent-red rounded text-xs transition-colors"}," Delete ",8,yt)])])]))),128))])):(n(),s("div",kt,[t[47]||(t[47]=e("svg",{class:"w-16 h-16 mx-auto mb-4 text-content-muted dark:text-content-muted/60",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"})],-1)),t[48]||(t[48]=e("p",{class:"text-lg mb-2"},"No room servers configured",-1)),t[49]||(t[49]=e("p",{class:"text-sm mb-4"},"Add your first room server to get started",-1)),e("button",{onClick:t[1]||(t[1]=r=>M.value=!0),class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"}," + Add Room Server ")]))]),M.value?(n(),s("div",ft,[e("div",ht,[t[60]||(t[60]=e("h2",{class:"text-xl font-bold text-content-primary dark:text-content-primary mb-4"},"Add Room Server",-1)),e("div",wt,[e("div",null,[t[50]||(t[50]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Name *",-1)),v(e("input",{"onUpdate:modelValue":t[2]||(t[2]=r=>m.value.name=r),type:"text",placeholder:"e.g., MainBBS",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,m.value.name]])]),e("div",null,[e("label",_t,[t[51]||(t[51]=k(" Identity Key (Optional) ",-1)),e("button",{onClick:t[3]||(t[3]=r=>g.value=!g.value),type:"button",class:"ml-2 text-primary/70 hover:text-primary text-xs underline"},a(g.value?"Hide":"Show/Edit"),1)]),g.value?(n(),s("div",Ct,[v(e("input",{"onUpdate:modelValue":t[4]||(t[4]=r=>m.value.identity_key=r),type:"text",placeholder:"Leave empty to auto-generate",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary font-mono text-sm placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,m.value.identity_key]]),t[52]||(t[52]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Leave empty to automatically generate a secure key",-1))])):(n(),s("div",Mt," Will be auto-generated if not provided "))]),e("div",null,[t[53]||(t[53]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Node Name",-1)),v(e("input",{"onUpdate:modelValue":t[5]||(t[5]=r=>m.value.settings.node_name=r),type:"text",placeholder:"Display name for the room server",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,m.value.settings.node_name]])]),e("div",jt,[e("div",null,[t[54]||(t[54]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Latitude",-1)),v(e("input",{"onUpdate:modelValue":t[6]||(t[6]=r=>m.value.settings.latitude=r),type:"number",step:"0.000001",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,m.value.settings.latitude,void 0,{number:!0}]])]),e("div",null,[t[55]||(t[55]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Longitude",-1)),v(e("input",{"onUpdate:modelValue":t[7]||(t[7]=r=>m.value.settings.longitude=r),type:"number",step:"0.000001",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,m.value.settings.longitude,void 0,{number:!0}]])])]),e("div",Lt,[e("div",null,[t[56]||(t[56]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Admin Password (Optional)",-1)),v(e("input",{"onUpdate:modelValue":t[8]||(t[8]=r=>m.value.settings.admin_password=r),type:"password",placeholder:"Leave empty for no password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,m.value.settings.admin_password]]),t[57]||(t[57]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Full access to room server",-1))]),e("div",null,[t[58]||(t[58]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Guest Password (Optional)",-1)),v(e("input",{"onUpdate:modelValue":t[9]||(t[9]=r=>m.value.settings.guest_password=r),type:"password",placeholder:"Leave empty for no password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,m.value.settings.guest_password]]),t[59]||(t[59]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Read-only access",-1))])])]),e("div",{class:"flex justify-end gap-3 mt-6"},[e("button",{onClick:Q,class:"px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),e("button",{onClick:re,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"}," Create ")])])])):c("",!0),j.value&&l.value?(n(),s("div",$t,[e("div",St,[t[72]||(t[72]=e("h2",{class:"text-xl font-bold text-content-primary dark:text-content-primary mb-4"},"Edit Room Server",-1)),e("div",Bt,[e("div",null,[t[61]||(t[61]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Current Name",-1)),e("input",{value:l.value.name,disabled:"",type:"text",class:"w-full bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-muted dark:text-content-muted cursor-not-allowed"},null,8,At)]),e("div",null,[t[62]||(t[62]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"New Name (optional)",-1)),v(e("input",{"onUpdate:modelValue":t[10]||(t[10]=r=>l.value.new_name=r),type:"text",placeholder:"Leave empty to keep current name",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,l.value.new_name]])]),e("div",null,[e("label",Vt,[t[63]||(t[63]=k(" Identity Key (Optional) ",-1)),e("button",{onClick:t[11]||(t[11]=r=>p.value=!p.value),type:"button",class:"ml-2 text-primary/70 hover:text-primary text-xs underline"},a(p.value?"Hide":"Show/Edit"),1)]),p.value?(n(),s("div",Rt,[v(e("input",{"onUpdate:modelValue":t[12]||(t[12]=r=>l.value.identity_key=r),type:"text",placeholder:"Leave empty to keep current key",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary font-mono text-sm placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,l.value.identity_key]]),t[64]||(t[64]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Leave empty to keep the current identity key",-1))])):(n(),s("div",zt,' Click "Show/Edit" to change the identity key '))]),e("div",null,[t[65]||(t[65]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Node Name",-1)),v(e("input",{"onUpdate:modelValue":t[13]||(t[13]=r=>l.value.settings.node_name=r),type:"text",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,l.value.settings.node_name]])]),e("div",Dt,[e("div",null,[t[66]||(t[66]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Latitude",-1)),v(e("input",{"onUpdate:modelValue":t[14]||(t[14]=r=>l.value.settings.latitude=r),type:"number",step:"0.000001",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,l.value.settings.latitude,void 0,{number:!0}]])]),e("div",null,[t[67]||(t[67]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Longitude",-1)),v(e("input",{"onUpdate:modelValue":t[15]||(t[15]=r=>l.value.settings.longitude=r),type:"number",step:"0.000001",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,l.value.settings.longitude,void 0,{number:!0}]])])]),e("div",Et,[e("div",null,[t[68]||(t[68]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Admin Password",-1)),v(e("input",{"onUpdate:modelValue":t[16]||(t[16]=r=>l.value.settings.admin_password=r),type:"password",placeholder:"Leave empty for no password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,l.value.settings.admin_password]]),t[69]||(t[69]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Full access to room server",-1))]),e("div",null,[t[70]||(t[70]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Guest Password",-1)),v(e("input",{"onUpdate:modelValue":t[17]||(t[17]=r=>l.value.settings.guest_password=r),type:"password",placeholder:"Leave empty for no password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[b,l.value.settings.guest_password]]),t[71]||(t[71]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Read-only access",-1))])])]),e("div",{class:"flex justify-end gap-3 mt-6"},[e("button",{onClick:Q,class:"px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),e("button",{onClick:oe,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"}," Update ")])])])):c("",!0)]),Z(fe,{show:z.value,title:"Delete Room Server",message:`Are you sure you want to delete '${D.value}'? This action cannot be undone.`,"confirm-text":"Delete","cancel-text":"Cancel",variant:"danger",onClose:t[18]||(t[18]=r=>z.value=!1),onConfirm:ne},null,8,["show","message"]),Z(Le,{show:U.value,message:H.value.message,variant:H.value.variant,onClose:t[19]||(t[19]=r=>U.value=!1)},null,8,["show","message","variant"]),K.value?(n(),s("div",Ft,[e("div",It,[e("div",Nt,[t[79]||(t[79]=e("div",{class:"absolute inset-0 bg-gradient-to-r from-secondary/20 via-primary/20 to-accent-purple/20"},null,-1)),t[80]||(t[80]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-transparent via-white/5 to-transparent"},null,-1)),e("div",Ut,[e("div",Ht,[t[75]||(t[75]=G('
',1)),e("div",null,[t[74]||(t[74]=e("h2",{class:"text-2xl font-bold text-content-primary dark:text-content-primary mb-1"},"Room Messages",-1)),e("p",Kt,[t[73]||(t[73]=e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"})],-1)),e("span",Ot,a(h.value),1)])])]),e("div",Pt,[e("button",{onClick:t[20]||(t[20]=r=>P.value=!0),class:"group px-3 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-[10px] text-xs font-medium transition-all hover:scale-105 border border-primary/30 flex items-center gap-2",title:"View active sessions"},[t[76]||(t[76]=e("svg",{class:"w-4 h-4 group-hover:scale-110 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"})],-1)),t[77]||(t[77]=e("span",{class:"hidden sm:inline"},"Sessions",-1)),e("span",Tt,a(S.value.length),1)]),e("button",{onClick:pe,class:"p-2 text-content-secondary dark:text-content-primary/70 hover:text-content-primary dark:hover:text-content-primary hover:bg-stroke-subtle dark:hover:bg-white/10 rounded-[10px] transition-all"},t[78]||(t[78]=[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))])])]),e("div",Gt,[A.value&&w.value.length===0?(n(),s("div",Jt,t[81]||(t[81]=[e("div",{class:"text-center"},[e("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-primary rounded-full mx-auto mb-4"}),e("div",{class:"text-content-secondary dark:text-content-primary/70"},"Loading messages...")],-1)]))):$.value?(n(),s("div",qt,[e("div",Wt,[t[82]||(t[82]=e("div",{class:"text-red-600 dark:text-red-400 mb-2"},"Failed to load messages",-1)),e("div",Xt,a($.value),1),e("button",{onClick:t[21]||(t[21]=r=>R(!0)),class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors"}," Retry ")])])):w.value.length>0?(n(),s("div",Qt,[(n(!0),s(N,null,J(w.value,(r,C)=>(n(),s("div",{key:r.id||C,class:"group relative overflow-hidden glass-card backdrop-blur-xl rounded-[12px] p-4 border border-stroke-subtle dark:border-white/10 hover:border-secondary/30 transition-all duration-300 hover:shadow-lg hover:shadow-secondary/10"},[t[87]||(t[87]=e("div",{class:"absolute inset-0 bg-gradient-to-r from-secondary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"},null,-1)),e("div",Yt,[e("div",Zt,[e("div",er,[e("div",tr,[t[84]||(t[84]=e("div",{class:"w-6 h-6 rounded-full bg-gradient-to-br from-primary/30 to-secondary/30 flex items-center justify-center"},[e("svg",{class:"w-3 h-3 text-content-secondary dark:text-content-primary/70",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"})])],-1)),r.author_name?(n(),s("span",rr,a(r.author_name),1)):c("",!0),r.author_pubkey?(n(),s("span",or,a(r.author_pubkey.substring(0,8))+"... ",1)):(n(),s("span",sr," Anonymous ")),t[85]||(t[85]=e("span",{class:"text-content-muted dark:text-content-muted/60 text-xs"},"•",-1)),e("span",nr,[t[83]||(t[83]=e("svg",{class:"w-3 h-3",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"})],-1)),k(" "+a(me(r.timestamp)),1)]),r.id?(n(),s("span",ar," #"+a(r.id),1)):c("",!0)])]),e("div",lr,a(r.message_text),1)]),e("button",{onClick:be=>ce(r.id),class:"group/delete flex-shrink-0 p-2 bg-accent-red/10 hover:bg-accent-red/20 text-accent-red rounded-[8px] transition-all hover:scale-110 border border-accent-red/20",title:"Delete this message"},t[86]||(t[86]=[e("svg",{class:"w-4 h-4 group-hover/delete:rotate-12 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"})],-1)]),8,dr)])]))),128)),O.value&&!A.value?(n(),s("div",ir,[e("button",{onClick:ue,class:"group px-6 py-2.5 bg-gradient-to-r from-gray-100 dark:from-white/5 to-gray-200 dark:to-white/10 hover:from-gray-200 dark:hover:from-white/10 hover:to-gray-300 dark:hover:to-white/15 text-content-primary dark:text-content-primary rounded-[10px] transition-all hover:scale-105 text-sm font-medium border border-stroke-subtle dark:border-stroke/10 flex items-center gap-2 mx-auto"},t[88]||(t[88]=[e("svg",{class:"w-4 h-4 group-hover:translate-y-1 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M19 9l-7 7-7-7"})],-1),k(" Load More Messages ",-1)]))])):A.value?(n(),s("div",ur,t[89]||(t[89]=[e("div",{class:"flex items-center justify-center gap-2 text-content-secondary dark:text-content-muted text-sm"},[e("div",{class:"animate-spin w-4 h-4 border-2 border-stroke-subtle dark:border-stroke/20 border-t-primary rounded-full"}),k(" Loading... ")],-1)]))):c("",!0)])):(n(),s("div",cr,t[90]||(t[90]=[G('

No messages yet

Be the first to start the conversation

',1)])))]),e("div",pr,[t[93]||(t[93]=e("div",{class:"absolute inset-0 bg-gradient-to-t from-primary/5 to-transparent pointer-events-none"},null,-1)),e("div",mr,[e("div",vr,[e("div",br,[v(e("textarea",{"onUpdate:modelValue":t[22]||(t[22]=r=>_.value=r),onKeydown:[ee(q(T,["ctrl"]),["enter"]),ee(q(T,["meta"]),["enter"])],placeholder:"Type your message... (Ctrl+Enter to send)",rows:"3",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-3 text-content-primary dark:text-content-primary text-sm placeholder-gray-500 dark:placeholder-white/30 focus:outline-none focus:border-primary/50 focus:bg-white dark:focus:bg-white/10 transition-all resize-none"},null,40,xr),[[b,_.value]])]),e("button",{onClick:T,disabled:!_.value.trim(),class:f(["group px-6 py-3 rounded-[12px] transition-all duration-200 flex items-center justify-center gap-2 font-medium",_.value.trim()?"bg-gradient-to-r from-primary/30 to-secondary/30 hover:from-primary/40 hover:to-secondary/40 text-content-primary dark:text-content-primary border border-primary/50 hover:scale-105 hover:shadow-lg hover:shadow-primary/20":"bg-background-mute dark:bg-white/5 text-content-muted dark:text-content-muted/60 cursor-not-allowed border border-stroke-subtle dark:border-stroke/10"])},t[91]||(t[91]=[e("svg",{class:"w-5 h-5 group-hover:translate-x-1 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 19l9 2-9-18-9 18 9-2zm0 0v-8"})],-1),e("span",{class:"hidden sm:inline"},"Send",-1)]),10,gr)]),t[92]||(t[92]=e("p",{class:"text-content-secondary dark:text-content-muted/60 text-xs flex items-center gap-2"},[e("svg",{class:"w-3 h-3",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})]),k(" Press Ctrl+Enter to send message quickly ")],-1))])])])])):c("",!0),P.value?(n(),s("div",yr,[e("div",kr,[e("div",fr,[e("div",null,[t[95]||(t[95]=e("h2",{class:"text-xl font-bold text-content-primary dark:text-content-primary"},"Active Sessions",-1)),e("p",hr,[t[94]||(t[94]=k("Room: ",-1)),e("span",wr,a(h.value),1)])]),e("button",{onClick:t[23]||(t[23]=r=>P.value=!1),class:"text-content-secondary dark:text-content-primary/70 hover:text-content-primary dark:hover:text-content-primary transition-colors"},t[96]||(t[96]=[e("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),e("div",_r,[S.value.length===0?(n(),s("div",Cr,t[97]||(t[97]=[e("div",{class:"text-content-secondary dark:text-content-muted"},"No active sessions found",-1)]))):c("",!0),(n(!0),s(N,null,J(S.value,(r,C)=>(n(),s("div",{key:r.public_key_full||C,class:"glass-card backdrop-blur-xl rounded-[10px] p-4 border border-stroke-subtle dark:border-white/10"},[e("div",Mr,[e("div",jr,[e("div",Lr,[e("span",$r,a(r.identity_name||"Unknown"),1),e("span",{class:f(["px-2 py-0.5 text-xs font-medium rounded",r.permissions==="admin"?"bg-accent-green/20 text-accent-green":"bg-secondary/20 text-secondary"])},a(r.permissions),3)]),e("div",Sr,[e("span",Br,a(r.identity_type),1),e("button",{onClick:be=>ve(r.public_key_full,r.identity_hash),class:"px-2 py-1 bg-accent-red/20 hover:bg-accent-red/30 text-accent-red rounded text-xs transition-colors",title:"Remove client from ACL"}," Remove ",8,Ar)])]),e("div",Vr,[e("div",Rr,[t[98]||(t[98]=e("span",{class:"text-content-secondary dark:text-content-muted"},"Short Key:",-1)),e("code",zr,a(r.public_key),1)]),e("div",Dr,[t[99]||(t[99]=e("span",{class:"text-content-secondary dark:text-content-muted"},"Full Key:",-1)),e("code",Er,a(r.public_key_full),1)])]),e("div",Fr,[e("div",Ir,[r.address?(n(),s("span",Nr,"📍 "+a(r.address),1)):c("",!0),r.last_login_success?(n(),s("span",Ur,"Last Login: "+a(new Date(r.last_login_success*1e3).toLocaleString()),1)):c("",!0)]),r.last_activity?(n(),s("span",Hr,"Active: "+a(Math.floor((Date.now()/1e3-r.last_activity)/60))+"m ago",1)):c("",!0)])])]))),128))])])])):c("",!0)],64))}});export{Tr as default}; diff --git a/repeater/web/html/assets/RoomServers-V3porqGE.js b/repeater/web/html/assets/RoomServers-V3porqGE.js new file mode 100644 index 0000000..818c9f6 --- /dev/null +++ b/repeater/web/html/assets/RoomServers-V3porqGE.js @@ -0,0 +1 @@ +import{a as ve,r as d,D as xe,o as be,L as x,b as s,e,f as Q,g as u,i as G,k as b,t as a,j as h,F as I,h as J,w as m,v,X as Y,s as Z,p as n}from"./index-DyUIpN7m.js";import{g as ye,s as ge}from"./preferences-DtwbSSgO.js";import{_ as ke}from"./ConfirmDialog.vue_vue_type_script_setup_true_lang-CVxh_fqf.js";import{_ as fe}from"./MessageDialog.vue_vue_type_script_setup_true_lang-DAIhF3Fs.js";const he={class:"p-6 space-y-6"},we={class:"relative overflow-hidden rounded-[20px] p-6 mb-6 glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10"},_e={class:"relative flex items-center justify-between"},Ce={key:0,class:"grid grid-cols-1 md:grid-cols-3 gap-4"},Me={class:"group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5 hover:scale-[1.02] transition-all duration-300 cursor-pointer"},je={class:"relative flex items-center justify-between"},Le={class:"text-3xl font-bold text-content-primary dark:text-content-primary mb-1"},Se={class:"group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5 hover:scale-[1.02] transition-all duration-300 cursor-pointer"},$e={class:"relative flex items-center justify-between"},Ae={class:"text-3xl font-bold text-primary mb-1"},Ve={class:"group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5 hover:scale-[1.02] transition-all duration-300 cursor-pointer"},Be={class:"relative flex items-center justify-between"},Re={key:0,class:"w-6 h-6 text-accent-green",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},ze={key:1,class:"w-6 h-6 text-accent-yellow",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Ee={class:"glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6"},Fe={key:0,class:"flex items-center justify-center py-12"},De={key:1,class:"flex items-center justify-center py-12"},Ie={class:"text-center"},Ne={class:"text-content-secondary dark:text-content-muted text-sm mb-4"},Ue={key:2,class:"space-y-4"},He={class:"relative flex items-start justify-between"},Ke={class:"flex-1"},Oe={class:"flex items-center gap-3 mb-4"},Pe={class:"relative"},Te={key:0,class:"absolute inset-0 bg-accent-green/50 rounded-full animate-ping"},Ge={class:"text-xl font-bold text-content-primary dark:text-content-primary group-hover:text-primary transition-colors"},Je={key:0,class:"text-content-muted dark:text-content-muted text-sm"},qe={class:"grid grid-cols-1 md:grid-cols-2 gap-3 text-sm mb-3"},We={class:"text-content-primary dark:text-content-primary/90 ml-2"},Xe={class:"flex items-center gap-2"},Qe={key:0,class:"text-content-primary dark:text-content-primary/90 font-mono ml-2 text-xs"},Ye={key:1,class:"text-content-muted dark:text-content-muted ml-2 text-xs"},Ze=["onClick"],et={class:"text-content-primary dark:text-content-primary/90 ml-2"},tt={key:0},rt={class:"text-content-primary dark:text-content-primary/90 ml-2"},ot={key:0,class:"text-accent-green"},st={key:1,class:"text-content-muted dark:text-content-muted"},nt={key:2,class:"text-primary"},at={key:0,class:"text-xs text-content-muted dark:text-content-muted font-mono"},lt={class:"ml-4 flex flex-wrap gap-2"},dt=["onClick","disabled","title"],it=["onClick","disabled","title"],ut=["onClick"],ct=["onClick"],pt={key:3,class:"text-center py-12 text-content-secondary dark:text-content-muted"},mt={key:1,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"},vt={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"},xt={class:"space-y-4"},bt={class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},yt={key:0},gt={key:1,class:"text-content-secondary dark:text-content-muted text-sm"},kt={class:"grid grid-cols-2 gap-4"},ft={class:"grid grid-cols-2 gap-4"},ht={key:2,class:"fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"},wt={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto"},_t={class:"space-y-4"},Ct=["value"],Mt={class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},jt={key:0},Lt={key:1,class:"text-content-secondary dark:text-content-muted text-sm"},St={class:"grid grid-cols-2 gap-4"},$t={class:"grid grid-cols-2 gap-4"},At={key:0,class:"fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4"},Vt={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[20px] p-6 max-w-4xl w-full h-[85vh] flex flex-col shadow-2xl"},Bt={class:"relative overflow-hidden rounded-[15px] mb-6 p-5 bg-white/50 dark:bg-white/5 border border-stroke-subtle dark:border-white/10"},Rt={class:"relative flex items-center justify-between"},zt={class:"flex items-center gap-4"},Et={class:"text-content-secondary dark:text-content-muted text-sm flex items-center gap-2"},Ft={class:"text-primary font-semibold"},Dt={class:"flex items-center gap-2"},It={class:"bg-primary/30 px-1.5 py-0.5 rounded-full text-[10px]"},Nt={class:"flex-1 overflow-y-auto mb-4 space-y-3"},Ut={key:0,class:"flex items-center justify-center py-12"},Ht={key:1,class:"flex items-center justify-center py-12"},Kt={class:"text-center"},Ot={class:"text-content-secondary dark:text-content-muted text-sm mb-4"},Pt={key:2,class:"space-y-3"},Tt={class:"relative flex items-start justify-between gap-3"},Gt={class:"flex-1 min-w-0"},Jt={class:"flex items-center gap-2 mb-3"},qt={class:"flex items-center gap-2 flex-wrap"},Wt={key:0,class:"text-primary text-sm font-bold"},Xt={key:1,class:"text-primary/80 text-xs font-mono bg-primary/10 px-2 py-1 rounded-md border border-primary/20"},Qt={key:2,class:"text-content-muted dark:text-content-muted text-xs"},Yt={class:"text-content-secondary dark:text-content-muted text-xs flex items-center gap-1"},Zt={key:3,class:"text-content-muted dark:text-content-muted/50 text-[10px] font-mono bg-background-mute dark:bg-white/5 px-1.5 py-0.5 rounded"},er={class:"text-content-primary dark:text-content-primary/90 text-sm leading-relaxed break-words whitespace-pre-wrap bg-gray-50 dark:bg-white/5 p-3 rounded-[10px] border border-stroke-subtle dark:border-white/5"},tr=["onClick"],rr={key:0,class:"text-center pt-4"},or={key:1,class:"text-center pt-4"},sr={key:3,class:"flex items-center justify-center h-full"},nr={class:"relative overflow-hidden rounded-[15px] border-t border-stroke-subtle dark:border-white/20 pt-4 mt-4"},ar={class:"relative space-y-3"},lr={class:"flex gap-3"},dr={class:"flex-1 relative"},ir=["onKeydown"],ur=["disabled"],cr={key:1,class:"fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-[60] p-4"},pr={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-3xl w-full max-h-[80vh] flex flex-col"},mr={class:"flex items-center justify-between mb-4 pb-4 border-b border-stroke-subtle dark:border-white/10"},vr={class:"text-content-secondary dark:text-content-primary/70 text-sm mt-1"},xr={class:"text-primary"},br={class:"flex-1 overflow-y-auto space-y-3"},yr={key:0,class:"text-center py-12"},gr={class:"space-y-2"},kr={class:"flex items-center justify-between"},fr={class:"flex items-center gap-2"},hr={class:"text-content-primary dark:text-content-primary font-semibold"},wr={class:"flex items-center gap-2"},_r={class:"text-content-secondary dark:text-content-muted text-xs"},Cr=["onClick"],Mr={class:"space-y-1 text-xs"},jr={class:"flex items-center gap-2"},Lr={class:"text-primary font-mono bg-primary/10 px-2 py-0.5 rounded"},Sr={class:"flex items-center gap-2"},$r={class:"text-primary font-mono bg-primary/10 px-2 py-0.5 rounded text-[10px] break-all"},Ar={class:"flex items-center justify-between text-xs text-content-secondary dark:text-content-muted"},Vr={class:"flex items-center gap-4"},Br={key:0},Rr={key:1},zr={key:0},Ur=ve({name:"RoomServersView",__name:"RoomServers",setup(Er){const N=d(!1),j=d(null),c=d(null),L=d(!1),B=d(!1),l=d(null),w=d(!1),_=d(!1),S=d(new Set),R=d(!1),z=d(""),U=d(!1),H=d({message:"",variant:"success"}),K=d(!1),y=d(""),E=d(""),g=d([]),$=d(!1),C=d(null),k=d(""),F=d(ye("roomServers_messagesLimit",50)),D=d(0),O=d(!0);xe(F,o=>ge("roomServers_messagesLimit",o));const M=d([]),P=d(!1),p=d({name:"",identity_key:"",type:"room_server",settings:{node_name:"",latitude:0,longitude:0,admin_password:"",guest_password:""}});be(async()=>{await A()});async function A(){N.value=!0,j.value=null;try{const o=await x.getIdentities();o.success?c.value=o.data:j.value=o.error||"Failed to load identities"}catch(o){j.value=o instanceof Error?o.message:"Failed to load identities"}finally{N.value=!1}}async function ee(){try{const o=await x.createIdentity(p.value);o.success?(L.value=!1,q(),await A(),i(o.message||"Identity created successfully!","success")):i(`Failed to create identity: ${o.error}`,"error")}catch(o){i(`Error creating identity: ${o}`,"error")}}async function te(){try{const o=await x.updateIdentity(l.value);o.success?(B.value=!1,l.value=null,await A(),i(o.message||"Identity updated successfully!","success")):i(`Failed to update identity: ${o.error}`,"error")}catch(o){i(`Error updating identity: ${o}`,"error")}}function re(o){z.value=o,R.value=!0}async function oe(){const o=z.value;R.value=!1;try{const t=await x.deleteIdentity(o);t.success?(await A(),i(t.message||"Identity deleted successfully!","success")):i(`Failed to delete identity: ${t.error}`,"error")}catch(t){i(`Error deleting identity: ${t}`,"error")}finally{z.value=""}}function i(o,t){H.value={message:o,variant:t},U.value=!0}async function se(o){try{const t=await x.sendRoomServerAdvert(o);t.success?i(t.message||`Advert sent for '${o}'!`,"success"):i(`Failed to send advert: ${t.error}`,"error")}catch(t){i(`Error sending advert: ${t}`,"error")}}function ne(o){l.value=JSON.parse(JSON.stringify(o)),l.value.settings||(l.value.settings={}),l.value.settings.admin_password||(l.value.settings.admin_password=""),l.value.settings.guest_password||(l.value.settings.guest_password=""),_.value=!1,B.value=!0}function q(){p.value={name:"",identity_key:"",type:"room_server",settings:{node_name:"",latitude:0,longitude:0,admin_password:"",guest_password:""}},w.value=!1}function W(){L.value=!1,B.value=!1,l.value=null,w.value=!1,_.value=!1,q()}function ae(o){S.value.has(o)?S.value.delete(o):S.value.add(o)}async function le(o){y.value=o,K.value=!0,D.value=0,O.value=!0;const t=c.value?.configured.find(r=>r.name===o);E.value=t?.hash||"",await X(),await V(!0)}async function X(){try{console.log("Fetching ACL clients for room:",y.value,"hash:",E.value);const o=await x.getACLClients({identity_hash:E.value,identity_name:y.value});console.log("ACL clients response:",o),o.success&&o.data&&(M.value=o.data.clients||[],console.log("ACL clients loaded:",M.value.length))}catch(o){console.error("Failed to fetch ACL clients:",o)}}async function V(o=!1){o&&(D.value=0,g.value=[]),$.value=!0,C.value=null;try{const t=await x.getRoomMessages({room_name:y.value,limit:F.value,offset:D.value});if(t.success&&t.data){const r=t.data.messages||[];o?g.value=r:g.value=[...g.value,...r],O.value=r.length===F.value}else C.value=t.error||"Failed to load messages"}catch(t){C.value=t instanceof Error?t.message:"Failed to load messages"}finally{$.value=!1}}async function de(){D.value+=F.value,await V(!1)}async function T(){if(k.value.trim())try{const o=await x.postRoomMessage({room_name:y.value,message:k.value,author_pubkey:"server"});o.success?(k.value="",await V(!0)):i(`Failed to send message: ${o.error}`,"error")}catch(o){i(`Error sending message: ${o}`,"error")}}async function ie(o){if(confirm("Are you sure you want to delete this message?"))try{const t=await x.deleteRoomMessage({room_name:y.value,message_id:o});t.success?(await V(!0),i("Message deleted successfully","success")):i(`Failed to delete message: ${t.error}`,"error")}catch(t){i(`Error deleting message: ${t}`,"error")}}function ue(){K.value=!1,y.value="",E.value="",g.value=[],k.value="",C.value=null,M.value=[]}function ce(o){return o?new Date(o*1e3).toLocaleString():"Unknown"}async function pe(o,t){if(confirm("Are you sure you want to remove this client from the ACL?"))try{const r=await x.removeACLClient({public_key:o,identity_hash:t});r.success?(await X(),i("Client removed successfully","success")):i(`Failed to remove client: ${r.error}`,"error")}catch(r){i(`Error removing client: ${r}`,"error")}}return(o,t)=>(n(),s(I,null,[e("div",he,[e("div",we,[t[26]||(t[26]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-primary/20 via-secondary/10 to-accent-purple/20 opacity-50"},null,-1)),t[27]||(t[27]=e("div",{class:"absolute inset-0 bg-gradient-to-tl from-accent-green/10 via-transparent to-primary/10 animate-pulse"},null,-1)),e("div",_e,[t[25]||(t[25]=G('

Room Servers

Manage room server identities and messages

',1)),e("button",{onClick:t[0]||(t[0]=r=>L.value=!0),class:"group relative px-6 py-3 bg-gradient-to-r from-primary/30 to-secondary/30 hover:from-primary/40 hover:to-secondary/40 text-content-primary dark:text-content-primary rounded-[12px] border border-primary/50 transition-all hover:scale-105 hover:shadow-lg hover:shadow-primary/20"},t[24]||(t[24]=[e("span",{class:"flex items-center gap-2"},[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4v16m8-8H4"})]),b(" Add Room Server ")],-1)]))])]),c.value&&c.value.total_configured>0?(n(),s("div",Ce,[e("div",Me,[t[30]||(t[30]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"},null,-1)),e("div",je,[e("div",null,[t[28]||(t[28]=e("div",{class:"text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide"},"Total Configured",-1)),e("div",Le,a(c.value.total_configured),1)]),t[29]||(t[29]=e("div",{class:"bg-background-mute dark:bg-white/10 p-3 rounded-[12px] group-hover:bg-background-mute dark:group-hover:bg-stroke/20 transition-colors"},[e("svg",{class:"w-6 h-6 text-content-secondary dark:text-content-primary/70",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"})])],-1))])]),e("div",Se,[t[33]||(t[33]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"},null,-1)),e("div",$e,[e("div",null,[t[31]||(t[31]=e("div",{class:"text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide"},"Currently Registered",-1)),e("div",Ae,a(c.value.total_registered),1)]),t[32]||(t[32]=e("div",{class:"bg-primary/20 p-3 rounded-[12px] group-hover:bg-primary/30 transition-colors"},[e("svg",{class:"w-6 h-6 text-primary",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"})])],-1))])]),e("div",Ve,[t[37]||(t[37]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-accent-green/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"},null,-1)),e("div",Be,[e("div",null,[t[34]||(t[34]=e("div",{class:"text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide"},"Status",-1)),e("div",{class:h(["text-3xl font-bold",c.value.total_registered===c.value.total_configured?"text-accent-green":"text-accent-yellow"])},a(c.value.total_registered===c.value.total_configured?"Synced":"Out of Sync"),3)]),e("div",{class:h(["p-3 rounded-[12px] transition-colors",c.value.total_registered===c.value.total_configured?"bg-accent-green/20 group-hover:bg-accent-green/30":"bg-accent-yellow/20 group-hover:bg-accent-yellow/30"])},[c.value.total_registered===c.value.total_configured?(n(),s("svg",Re,t[35]||(t[35]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)]))):(n(),s("svg",ze,t[36]||(t[36]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2)])])])):u("",!0),e("div",Ee,[N.value?(n(),s("div",Fe,t[38]||(t[38]=[e("div",{class:"text-center"},[e("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-primary rounded-full mx-auto mb-4"}),e("div",{class:"text-content-secondary dark:text-content-primary/70"},"Loading room servers...")],-1)]))):j.value?(n(),s("div",De,[e("div",Ie,[t[39]||(t[39]=e("div",{class:"text-red-600 dark:text-red-400 mb-2"},"Failed to load room servers",-1)),e("div",Ne,a(j.value),1),e("button",{onClick:A,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors"}," Retry ")])])):c.value&&c.value.configured.length>0?(n(),s("div",Ue,[(n(!0),s(I,null,J(c.value.configured,r=>(n(),s("div",{key:r.name,class:"group relative overflow-hidden glass-card backdrop-blur-xl rounded-[15px] p-5 border border-stroke-subtle dark:border-white/10 hover:border-primary/30 hover:shadow-lg hover:shadow-primary/10 transition-all duration-300"},[t[46]||(t[46]=e("div",{class:"absolute inset-0 bg-gradient-to-r from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"},null,-1)),e("div",He,[e("div",Ke,[e("div",Oe,[e("div",Pe,[r.registered?(n(),s("div",Te)):u("",!0),e("div",{class:h(["relative w-3 h-3 rounded-full",r.registered?"bg-accent-green":"bg-accent-red"])},null,2)]),e("h3",Ge,a(r.name),1),e("span",{class:h(["px-3 py-1 text-xs font-semibold rounded-full",r.registered?"bg-accent-green/20 text-accent-green border border-accent-green/30":"bg-accent-red/20 text-accent-red border border-accent-red/30"])},a(r.registered?"● Active":"○ Inactive"),3),r.hash?(n(),s("span",Je,a(r.hash),1)):u("",!0)]),e("div",qe,[e("div",null,[t[40]||(t[40]=e("span",{class:"text-content-muted dark:text-content-muted"},"Node Name:",-1)),e("span",We,a(r.settings?.node_name||"Not set"),1)]),e("div",Xe,[t[41]||(t[41]=e("span",{class:"text-content-muted dark:text-content-muted"},"Identity Key:",-1)),S.value.has(r.name)?(n(),s("span",Qe,a(r.identity_key),1)):(n(),s("span",Ye," •••••••••••••••• ")),e("button",{onClick:f=>ae(r.name),class:"text-primary/70 hover:text-primary text-xs underline"},a(S.value.has(r.name)?"Hide":"Show"),9,Ze)]),e("div",null,[t[42]||(t[42]=e("span",{class:"text-content-muted dark:text-content-muted"},"Location:",-1)),e("span",et,a(r.settings?.latitude||0)+", "+a(r.settings?.longitude||0),1)]),r.settings?.admin_password||r.settings?.guest_password?(n(),s("div",tt,[t[43]||(t[43]=e("span",{class:"text-content-muted dark:text-content-muted"},"Passwords:",-1)),e("span",rt,[r.settings?.admin_password?(n(),s("span",ot,"Admin")):u("",!0),r.settings?.admin_password&&r.settings?.guest_password?(n(),s("span",st," / ")):u("",!0),r.settings?.guest_password?(n(),s("span",nt,"Guest")):u("",!0)])])):u("",!0)]),r.address?(n(),s("div",at," Address: "+a(r.address),1)):u("",!0)]),e("div",lt,[e("button",{onClick:f=>le(r.name),disabled:!r.registered,class:h(["group px-4 py-2 rounded-[10px] text-xs font-medium transition-all duration-200 flex items-center gap-2",r.registered?"bg-secondary/20 hover:bg-secondary/30 text-secondary border border-secondary/30 hover:scale-105 hover:shadow-lg hover:shadow-secondary/20":"bg-background-mute dark:bg-white/5 text-content-muted dark:text-content-muted/60 cursor-not-allowed border border-stroke-subtle dark:border-stroke/10"]),title:r.registered?"Manage messages for this room":"Room server must be active to manage messages"},t[44]||(t[44]=[e("svg",{class:"w-4 h-4 group-hover:rotate-12 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"})],-1),b(" Messages ",-1)]),10,dt),e("button",{onClick:f=>se(r.name),disabled:!r.registered,class:h(["group px-4 py-2 rounded-[10px] text-xs font-medium transition-all duration-200 flex items-center gap-2",r.registered?"bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border border-accent-green/30 hover:scale-105 hover:shadow-lg hover:shadow-accent-green/20":"bg-background-mute dark:bg-white/5 text-content-muted dark:text-content-muted/60 cursor-not-allowed border border-stroke-subtle dark:border-stroke/10"]),title:r.registered?"Send advert for this room server":"Room server must be active to send advert"},t[45]||(t[45]=[e("svg",{class:"w-4 h-4 group-hover:scale-110 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 10V3L4 14h7v7l9-11h-7z"})],-1),b(" Send Advert ",-1)]),10,it),e("button",{onClick:f=>ne(r),class:"px-3 py-1 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors"}," Edit ",8,ut),e("button",{onClick:f=>re(r.name),class:"px-3 py-1 bg-accent-red/20 hover:bg-accent-red/30 text-accent-red rounded text-xs transition-colors"}," Delete ",8,ct)])])]))),128))])):(n(),s("div",pt,[t[47]||(t[47]=e("svg",{class:"w-16 h-16 mx-auto mb-4 text-content-muted dark:text-content-muted/60",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"})],-1)),t[48]||(t[48]=e("p",{class:"text-lg mb-2"},"No room servers configured",-1)),t[49]||(t[49]=e("p",{class:"text-sm mb-4"},"Add your first room server to get started",-1)),e("button",{onClick:t[1]||(t[1]=r=>L.value=!0),class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"}," + Add Room Server ")]))]),L.value?(n(),s("div",mt,[e("div",vt,[t[60]||(t[60]=e("h2",{class:"text-xl font-bold text-content-primary dark:text-content-primary mb-4"},"Add Room Server",-1)),e("div",xt,[e("div",null,[t[50]||(t[50]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Name *",-1)),m(e("input",{"onUpdate:modelValue":t[2]||(t[2]=r=>p.value.name=r),type:"text",placeholder:"e.g., MainBBS",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,p.value.name]])]),e("div",null,[e("label",bt,[t[51]||(t[51]=b(" Identity Key (Optional) ",-1)),e("button",{onClick:t[3]||(t[3]=r=>w.value=!w.value),type:"button",class:"ml-2 text-primary/70 hover:text-primary text-xs underline"},a(w.value?"Hide":"Show/Edit"),1)]),w.value?(n(),s("div",yt,[m(e("input",{"onUpdate:modelValue":t[4]||(t[4]=r=>p.value.identity_key=r),type:"text",placeholder:"Leave empty to auto-generate",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary font-mono text-sm placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,p.value.identity_key]]),t[52]||(t[52]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Leave empty to automatically generate a secure key",-1))])):(n(),s("div",gt," Will be auto-generated if not provided "))]),e("div",null,[t[53]||(t[53]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Node Name",-1)),m(e("input",{"onUpdate:modelValue":t[5]||(t[5]=r=>p.value.settings.node_name=r),type:"text",placeholder:"Display name for the room server",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,p.value.settings.node_name]])]),e("div",kt,[e("div",null,[t[54]||(t[54]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Latitude",-1)),m(e("input",{"onUpdate:modelValue":t[6]||(t[6]=r=>p.value.settings.latitude=r),type:"number",step:"0.000001",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,p.value.settings.latitude,void 0,{number:!0}]])]),e("div",null,[t[55]||(t[55]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Longitude",-1)),m(e("input",{"onUpdate:modelValue":t[7]||(t[7]=r=>p.value.settings.longitude=r),type:"number",step:"0.000001",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,p.value.settings.longitude,void 0,{number:!0}]])])]),e("div",ft,[e("div",null,[t[56]||(t[56]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Admin Password (Optional)",-1)),m(e("input",{"onUpdate:modelValue":t[8]||(t[8]=r=>p.value.settings.admin_password=r),type:"password",placeholder:"Leave empty for no password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,p.value.settings.admin_password]]),t[57]||(t[57]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Full access to room server",-1))]),e("div",null,[t[58]||(t[58]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Guest Password (Optional)",-1)),m(e("input",{"onUpdate:modelValue":t[9]||(t[9]=r=>p.value.settings.guest_password=r),type:"password",placeholder:"Leave empty for no password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,p.value.settings.guest_password]]),t[59]||(t[59]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Read-only access",-1))])])]),e("div",{class:"flex justify-end gap-3 mt-6"},[e("button",{onClick:W,class:"px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),e("button",{onClick:ee,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"}," Create ")])])])):u("",!0),B.value&&l.value?(n(),s("div",ht,[e("div",wt,[t[72]||(t[72]=e("h2",{class:"text-xl font-bold text-content-primary dark:text-content-primary mb-4"},"Edit Room Server",-1)),e("div",_t,[e("div",null,[t[61]||(t[61]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Current Name",-1)),e("input",{value:l.value.name,disabled:"",type:"text",class:"w-full bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-muted dark:text-content-muted cursor-not-allowed"},null,8,Ct)]),e("div",null,[t[62]||(t[62]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"New Name (optional)",-1)),m(e("input",{"onUpdate:modelValue":t[10]||(t[10]=r=>l.value.new_name=r),type:"text",placeholder:"Leave empty to keep current name",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,l.value.new_name]])]),e("div",null,[e("label",Mt,[t[63]||(t[63]=b(" Identity Key (Optional) ",-1)),e("button",{onClick:t[11]||(t[11]=r=>_.value=!_.value),type:"button",class:"ml-2 text-primary/70 hover:text-primary text-xs underline"},a(_.value?"Hide":"Show/Edit"),1)]),_.value?(n(),s("div",jt,[m(e("input",{"onUpdate:modelValue":t[12]||(t[12]=r=>l.value.identity_key=r),type:"text",placeholder:"Leave empty to keep current key",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary font-mono text-sm placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,l.value.identity_key]]),t[64]||(t[64]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Leave empty to keep the current identity key",-1))])):(n(),s("div",Lt,' Click "Show/Edit" to change the identity key '))]),e("div",null,[t[65]||(t[65]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Node Name",-1)),m(e("input",{"onUpdate:modelValue":t[13]||(t[13]=r=>l.value.settings.node_name=r),type:"text",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,l.value.settings.node_name]])]),e("div",St,[e("div",null,[t[66]||(t[66]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Latitude",-1)),m(e("input",{"onUpdate:modelValue":t[14]||(t[14]=r=>l.value.settings.latitude=r),type:"number",step:"0.000001",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,l.value.settings.latitude,void 0,{number:!0}]])]),e("div",null,[t[67]||(t[67]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Longitude",-1)),m(e("input",{"onUpdate:modelValue":t[15]||(t[15]=r=>l.value.settings.longitude=r),type:"number",step:"0.000001",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,l.value.settings.longitude,void 0,{number:!0}]])])]),e("div",$t,[e("div",null,[t[68]||(t[68]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Admin Password",-1)),m(e("input",{"onUpdate:modelValue":t[16]||(t[16]=r=>l.value.settings.admin_password=r),type:"password",placeholder:"Leave empty for no password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,l.value.settings.admin_password]]),t[69]||(t[69]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Full access to room server",-1))]),e("div",null,[t[70]||(t[70]=e("label",{class:"block text-content-secondary dark:text-content-primary/70 text-sm mb-2"},"Guest Password",-1)),m(e("input",{"onUpdate:modelValue":t[17]||(t[17]=r=>l.value.settings.guest_password=r),type:"password",placeholder:"Leave empty for no password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50 transition-colors"},null,512),[[v,l.value.settings.guest_password]]),t[71]||(t[71]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-1"},"Read-only access",-1))])])]),e("div",{class:"flex justify-end gap-3 mt-6"},[e("button",{onClick:W,class:"px-4 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary rounded-lg transition-colors"}," Cancel "),e("button",{onClick:te,class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors"}," Update ")])])])):u("",!0)]),Q(ke,{show:R.value,title:"Delete Room Server",message:`Are you sure you want to delete '${z.value}'? This action cannot be undone.`,"confirm-text":"Delete","cancel-text":"Cancel",variant:"danger",onClose:t[18]||(t[18]=r=>R.value=!1),onConfirm:oe},null,8,["show","message"]),Q(fe,{show:U.value,message:H.value.message,variant:H.value.variant,onClose:t[19]||(t[19]=r=>U.value=!1)},null,8,["show","message","variant"]),K.value?(n(),s("div",At,[e("div",Vt,[e("div",Bt,[t[79]||(t[79]=e("div",{class:"absolute inset-0 bg-gradient-to-r from-secondary/20 via-primary/20 to-accent-purple/20"},null,-1)),t[80]||(t[80]=e("div",{class:"absolute inset-0 bg-gradient-to-br from-transparent via-white/5 to-transparent"},null,-1)),e("div",Rt,[e("div",zt,[t[75]||(t[75]=G('
',1)),e("div",null,[t[74]||(t[74]=e("h2",{class:"text-2xl font-bold text-content-primary dark:text-content-primary mb-1"},"Room Messages",-1)),e("p",Et,[t[73]||(t[73]=e("svg",{class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z"})],-1)),e("span",Ft,a(y.value),1)])])]),e("div",Dt,[e("button",{onClick:t[20]||(t[20]=r=>P.value=!0),class:"group px-3 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-[10px] text-xs font-medium transition-all hover:scale-105 border border-primary/30 flex items-center gap-2",title:"View active sessions"},[t[76]||(t[76]=e("svg",{class:"w-4 h-4 group-hover:scale-110 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"})],-1)),t[77]||(t[77]=e("span",{class:"hidden sm:inline"},"Sessions",-1)),e("span",It,a(M.value.length),1)]),e("button",{onClick:ue,class:"p-2 text-content-secondary dark:text-content-primary/70 hover:text-content-primary dark:hover:text-content-primary hover:bg-stroke-subtle dark:hover:bg-white/10 rounded-[10px] transition-all"},t[78]||(t[78]=[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))])])]),e("div",Nt,[$.value&&g.value.length===0?(n(),s("div",Ut,t[81]||(t[81]=[e("div",{class:"text-center"},[e("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-primary rounded-full mx-auto mb-4"}),e("div",{class:"text-content-secondary dark:text-content-primary/70"},"Loading messages...")],-1)]))):C.value?(n(),s("div",Ht,[e("div",Kt,[t[82]||(t[82]=e("div",{class:"text-red-600 dark:text-red-400 mb-2"},"Failed to load messages",-1)),e("div",Ot,a(C.value),1),e("button",{onClick:t[21]||(t[21]=r=>V(!0)),class:"px-4 py-2 bg-primary/20 hover:bg-primary/30 text-content-primary dark:text-content-primary rounded-lg border border-primary/50 transition-colors"}," Retry ")])])):g.value.length>0?(n(),s("div",Pt,[(n(!0),s(I,null,J(g.value,(r,f)=>(n(),s("div",{key:r.id||f,class:"group relative overflow-hidden glass-card backdrop-blur-xl rounded-[12px] p-4 border border-stroke-subtle dark:border-white/10 hover:border-secondary/30 transition-all duration-300 hover:shadow-lg hover:shadow-secondary/10"},[t[87]||(t[87]=e("div",{class:"absolute inset-0 bg-gradient-to-r from-secondary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity"},null,-1)),e("div",Tt,[e("div",Gt,[e("div",Jt,[e("div",qt,[t[84]||(t[84]=e("div",{class:"w-6 h-6 rounded-full bg-gradient-to-br from-primary/30 to-secondary/30 flex items-center justify-center"},[e("svg",{class:"w-3 h-3 text-content-secondary dark:text-content-primary/70",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"})])],-1)),r.author_name?(n(),s("span",Wt,a(r.author_name),1)):u("",!0),r.author_pubkey?(n(),s("span",Xt,a(r.author_pubkey.substring(0,8))+"... ",1)):(n(),s("span",Qt," Anonymous ")),t[85]||(t[85]=e("span",{class:"text-content-muted dark:text-content-muted/60 text-xs"},"•",-1)),e("span",Yt,[t[83]||(t[83]=e("svg",{class:"w-3 h-3",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"})],-1)),b(" "+a(ce(r.timestamp)),1)]),r.id?(n(),s("span",Zt," #"+a(r.id),1)):u("",!0)])]),e("div",er,a(r.message_text),1)]),e("button",{onClick:me=>ie(r.id),class:"group/delete flex-shrink-0 p-2 bg-accent-red/10 hover:bg-accent-red/20 text-accent-red rounded-[8px] transition-all hover:scale-110 border border-accent-red/20",title:"Delete this message"},t[86]||(t[86]=[e("svg",{class:"w-4 h-4 group-hover/delete:rotate-12 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"})],-1)]),8,tr)])]))),128)),O.value&&!$.value?(n(),s("div",rr,[e("button",{onClick:de,class:"group px-6 py-2.5 bg-gradient-to-r from-gray-100 dark:from-white/5 to-gray-200 dark:to-white/10 hover:from-gray-200 dark:hover:from-white/10 hover:to-gray-300 dark:hover:to-white/15 text-content-primary dark:text-content-primary rounded-[10px] transition-all hover:scale-105 text-sm font-medium border border-stroke-subtle dark:border-stroke/10 flex items-center gap-2 mx-auto"},t[88]||(t[88]=[e("svg",{class:"w-4 h-4 group-hover:translate-y-1 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M19 9l-7 7-7-7"})],-1),b(" Load More Messages ",-1)]))])):$.value?(n(),s("div",or,t[89]||(t[89]=[e("div",{class:"flex items-center justify-center gap-2 text-content-secondary dark:text-content-muted text-sm"},[e("div",{class:"animate-spin w-4 h-4 border-2 border-stroke-subtle dark:border-stroke/20 border-t-primary rounded-full"}),b(" Loading... ")],-1)]))):u("",!0)])):(n(),s("div",sr,t[90]||(t[90]=[G('

No messages yet

Be the first to start the conversation

',1)])))]),e("div",nr,[t[93]||(t[93]=e("div",{class:"absolute inset-0 bg-gradient-to-t from-primary/5 to-transparent pointer-events-none"},null,-1)),e("div",ar,[e("div",lr,[e("div",dr,[m(e("textarea",{"onUpdate:modelValue":t[22]||(t[22]=r=>k.value=r),onKeydown:[Y(Z(T,["ctrl"]),["enter"]),Y(Z(T,["meta"]),["enter"])],placeholder:"Type your message... (Ctrl+Enter to send)",rows:"3",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-3 text-content-primary dark:text-content-primary text-sm placeholder-gray-500 dark:placeholder-white/30 focus:outline-none focus:border-primary/50 focus:bg-white dark:focus:bg-white/10 transition-all resize-none"},null,40,ir),[[v,k.value]])]),e("button",{onClick:T,disabled:!k.value.trim(),class:h(["group px-6 py-3 rounded-[12px] transition-all duration-200 flex items-center justify-center gap-2 font-medium",k.value.trim()?"bg-gradient-to-r from-primary/30 to-secondary/30 hover:from-primary/40 hover:to-secondary/40 text-content-primary dark:text-content-primary border border-primary/50 hover:scale-105 hover:shadow-lg hover:shadow-primary/20":"bg-background-mute dark:bg-white/5 text-content-muted dark:text-content-muted/60 cursor-not-allowed border border-stroke-subtle dark:border-stroke/10"])},t[91]||(t[91]=[e("svg",{class:"w-5 h-5 group-hover:translate-x-1 transition-transform",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 19l9 2-9-18-9 18 9-2zm0 0v-8"})],-1),e("span",{class:"hidden sm:inline"},"Send",-1)]),10,ur)]),t[92]||(t[92]=e("p",{class:"text-content-secondary dark:text-content-muted/60 text-xs flex items-center gap-2"},[e("svg",{class:"w-3 h-3",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"})]),b(" Press Ctrl+Enter to send message quickly ")],-1))])])])])):u("",!0),P.value?(n(),s("div",cr,[e("div",pr,[e("div",mr,[e("div",null,[t[95]||(t[95]=e("h2",{class:"text-xl font-bold text-content-primary dark:text-content-primary"},"Active Sessions",-1)),e("p",vr,[t[94]||(t[94]=b("Room: ",-1)),e("span",xr,a(y.value),1)])]),e("button",{onClick:t[23]||(t[23]=r=>P.value=!1),class:"text-content-secondary dark:text-content-primary/70 hover:text-content-primary dark:hover:text-content-primary transition-colors"},t[96]||(t[96]=[e("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),e("div",br,[M.value.length===0?(n(),s("div",yr,t[97]||(t[97]=[e("div",{class:"text-content-secondary dark:text-content-muted"},"No active sessions found",-1)]))):u("",!0),(n(!0),s(I,null,J(M.value,(r,f)=>(n(),s("div",{key:r.public_key_full||f,class:"glass-card backdrop-blur-xl rounded-[10px] p-4 border border-stroke-subtle dark:border-white/10"},[e("div",gr,[e("div",kr,[e("div",fr,[e("span",hr,a(r.identity_name||"Unknown"),1),e("span",{class:h(["px-2 py-0.5 text-xs font-medium rounded",r.permissions==="admin"?"bg-accent-green/20 text-accent-green":"bg-secondary/20 text-secondary"])},a(r.permissions),3)]),e("div",wr,[e("span",_r,a(r.identity_type),1),e("button",{onClick:me=>pe(r.public_key_full,r.identity_hash),class:"px-2 py-1 bg-accent-red/20 hover:bg-accent-red/30 text-accent-red rounded text-xs transition-colors",title:"Remove client from ACL"}," Remove ",8,Cr)])]),e("div",Mr,[e("div",jr,[t[98]||(t[98]=e("span",{class:"text-content-secondary dark:text-content-muted"},"Short Key:",-1)),e("code",Lr,a(r.public_key),1)]),e("div",Sr,[t[99]||(t[99]=e("span",{class:"text-content-secondary dark:text-content-muted"},"Full Key:",-1)),e("code",$r,a(r.public_key_full),1)])]),e("div",Ar,[e("div",Vr,[r.address?(n(),s("span",Br,"📍 "+a(r.address),1)):u("",!0),r.last_login_success?(n(),s("span",Rr,"Last Login: "+a(new Date(r.last_login_success*1e3).toLocaleString()),1)):u("",!0)]),r.last_activity?(n(),s("span",zr,"Active: "+a(Math.floor((Date.now()/1e3-r.last_activity)/60))+"m ago",1)):u("",!0)])])]))),128))])])])):u("",!0)],64))}});export{Ur as default}; diff --git a/repeater/web/html/assets/Sessions-CkbrSNoL.js b/repeater/web/html/assets/Sessions-C6MSx2tq.js similarity index 99% rename from repeater/web/html/assets/Sessions-CkbrSNoL.js rename to repeater/web/html/assets/Sessions-C6MSx2tq.js index a253f50..95ab446 100644 --- a/repeater/web/html/assets/Sessions-CkbrSNoL.js +++ b/repeater/web/html/assets/Sessions-C6MSx2tq.js @@ -1 +1 @@ -import{a as I,r as d,o as N,L as b,c as S,b as o,e as t,g as f,t as n,F as m,h as y,w as R,q as V,j as i,k as B,p as r}from"./index-sHch0610.js";const z={class:"p-6 space-y-6"},D={key:0,class:"grid grid-cols-1 md:grid-cols-4 gap-4"},F={class:"glass-card rounded-[15px] p-4"},T={class:"text-2xl font-bold text-content-primary dark:text-content-primary"},$={class:"glass-card rounded-[15px] p-4"},E={class:"text-2xl font-bold text-cyan-500 dark:text-primary"},H={class:"glass-card rounded-[15px] p-4"},P={class:"text-2xl font-bold text-green-700 dark:text-green-500 dark:text-accent-green"},G={class:"glass-card rounded-[15px] p-4"},O={class:"text-2xl font-bold text-yellow-500 dark:text-secondary"},q={class:"glass-card rounded-[15px] p-6"},U={class:"flex flex-wrap border-b border-stroke-subtle dark:border-stroke/10 mb-6"},J=["onClick"],K={class:"flex items-center gap-2"},Q={key:0,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},W={key:1,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},X={key:2,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Y={class:"min-h-[400px]"},Z={key:0,class:"flex items-center justify-center py-12"},tt={key:1,class:"flex items-center justify-center py-12"},et={class:"text-center"},st={class:"text-content-secondary dark:text-content-muted text-sm mb-4"},nt={key:2,class:"space-y-4"},ot={key:0,class:"text-center py-12 text-content-secondary dark:text-content-muted"},rt={key:1,class:"space-y-4"},at={class:"flex items-start justify-between"},dt={class:"flex-1"},it={class:"flex items-center gap-3 mb-2"},lt={class:"text-lg font-semibold text-content-primary dark:text-content-primary"},ct={class:"text-content-muted dark:text-content-muted text-sm"},xt={class:"grid grid-cols-2 md:grid-cols-4 gap-4 mt-4"},ut={class:"text-content-primary dark:text-content-primary font-medium"},mt={class:"text-cyan-500 dark:text-primary font-medium"},yt={class:"mt-3 flex items-center gap-2"},vt={key:3,class:"space-y-4"},kt={key:0,class:"text-center py-12 text-content-secondary dark:text-content-muted"},pt={key:1,class:"overflow-x-auto"},bt={class:"w-full"},_t={class:"py-3"},gt={class:"font-mono text-sm text-content-primary dark:text-content-primary"},ht={class:"py-3"},ft={class:"font-mono text-xs text-content-secondary dark:text-content-muted"},wt={class:"py-3"},Ct={class:"text-sm text-content-primary dark:text-content-primary"},At={class:"text-xs text-content-muted dark:text-content-muted"},Lt={class:"py-3"},St={class:"py-3"},Mt={class:"text-sm text-content-secondary dark:text-content-muted"},jt={class:"py-3"},It=["onClick"],Nt={key:4,class:"space-y-4"},Rt={class:"mb-4"},Vt=["value"],Bt={key:0,class:"text-center py-12 text-content-secondary dark:text-content-muted"},zt={key:1,class:"grid grid-cols-1 gap-4"},Dt={class:"flex items-start justify-between"},Ft={class:"flex-1"},Tt={class:"flex items-center gap-3 mb-3"},$t={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Et={class:"grid grid-cols-1 md:grid-cols-2 gap-3 text-sm"},Ht={class:"text-content-primary dark:text-content-primary/90 font-mono ml-2"},Pt={class:"text-content-primary dark:text-content-primary/90 ml-2"},Gt={class:"text-content-primary dark:text-content-primary/90 ml-2"},Ot={class:"text-content-primary dark:text-content-primary/90 ml-2"},qt=["onClick"],Ut={class:"flex justify-end"},Jt=["disabled"],Wt=I({name:"SessionsView",__name:"Sessions",setup(Kt){const c=d("overview"),w=d(!1),x=d(!1),v=d(null),_=d(null),u=d([]),l=d(null),k=d(null),M=[{id:"overview",label:"Overview",icon:"overview"},{id:"clients",label:"Authenticated Clients",icon:"clients"},{id:"identities",label:"By Identity",icon:"identities"}];N(async()=>{await p(),w.value=!0});async function p(){x.value=!0,v.value=null;try{const a=await b.getACLInfo();a.success&&(_.value=a.data);const s=await b.getACLClients();s.success&&s.data&&(u.value=s.data.clients||[]);const e=await b.getACLStats();e.success&&(l.value=e.data)}catch(a){v.value=a instanceof Error?a.message:"Failed to load ACL data",console.error("Error fetching ACL data:",a)}finally{x.value=!1}}async function C(a,s){if(confirm("Are you sure you want to remove this client from the ACL?"))try{const e=await b.removeACLClient({public_key:a,identity_hash:s});e.success?await p():alert(`Failed to remove client: ${e.error}`)}catch(e){alert(`Error removing client: ${e}`)}}function g(a){return a?new Date(a*1e3).toLocaleString():"Never"}function j(a){c.value=a}const A=S(()=>k.value?u.value.filter(a=>a.identity_name===k.value):u.value),h=S(()=>_.value?_.value.acls||[]:[]);return(a,s)=>(r(),o("div",z,[s[22]||(s[22]=t("div",null,[t("h1",{class:"text-2xl font-bold text-content-primary dark:text-content-primary"},"Sessions & Access Control"),t("p",{class:"text-content-secondary dark:text-content-muted mt-2"},"Manage authenticated clients and access control lists")],-1)),l.value?(r(),o("div",D,[t("div",F,[s[1]||(s[1]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-1"},"Total Identities",-1)),t("div",T,n(l.value.total_identities),1)]),t("div",$,[s[2]||(s[2]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-1"},"Authenticated Clients",-1)),t("div",E,n(l.value.total_clients),1)]),t("div",H,[s[3]||(s[3]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-1"},"Admin Clients",-1)),t("div",P,n(l.value.admin_clients),1)]),t("div",G,[s[4]||(s[4]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-1"},"Guest Clients",-1)),t("div",O,n(l.value.guest_clients),1)])])):f("",!0),t("div",q,[t("div",U,[(r(),o(m,null,y(M,e=>t("button",{key:e.id,onClick:L=>j(e.id),class:i(["px-4 py-2 text-sm font-medium transition-colors duration-200 border-b-2 mr-6 mb-2",c.value===e.id?"text-cyan-500 dark:text-primary border-cyan-500 dark:border-primary":"text-content-secondary dark:text-content-muted border-transparent hover:text-content-primary dark:hover:text-content-primary hover:border-stroke-subtle dark:hover:border-stroke/30"])},[t("div",K,[e.icon==="overview"?(r(),o("svg",Q,s[5]||(s[5]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"},null,-1)]))):e.icon==="clients"?(r(),o("svg",W,s[6]||(s[6]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"},null,-1)]))):e.icon==="identities"?(r(),o("svg",X,s[7]||(s[7]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"},null,-1)]))):f("",!0),B(" "+n(e.label),1)])],10,J)),64))]),t("div",Y,[x.value&&!w.value?(r(),o("div",Z,s[8]||(s[8]=[t("div",{class:"text-center"},[t("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-cyan-500 dark:border-t-primary rounded-full mx-auto mb-4"}),t("div",{class:"text-content-secondary dark:text-content-muted"},"Loading ACL data...")],-1)]))):v.value?(r(),o("div",tt,[t("div",et,[s[9]||(s[9]=t("div",{class:"text-red-500 dark:text-red-400 mb-2"},"Failed to load ACL data",-1)),t("div",st,n(v.value),1),t("button",{onClick:p,class:"px-4 py-2 bg-cyan-500/20 dark:bg-primary/20 hover:bg-cyan-500/30 dark:hover:bg-primary/30 text-cyan-900 dark:text-white rounded-lg border border-cyan-500/50 dark:border-primary/50 transition-colors"}," Retry ")])])):c.value==="overview"?(r(),o("div",nt,[h.value.length===0?(r(),o("div",ot," No identities configured ")):(r(),o("div",rt,[(r(!0),o(m,null,y(h.value,e=>(r(),o("div",{key:e.hash,class:"glass-card rounded-[10px] p-4 border border-stroke-subtle dark:border-white/10 hover:border-cyan-400 dark:hover:border-primary/30 transition-colors"},[t("div",at,[t("div",dt,[t("div",it,[t("h3",lt,n(e.name),1),t("span",{class:i(["px-2 py-1 text-xs font-medium rounded",e.type==="repeater"?"bg-cyan-500/20 dark:bg-primary/20 text-cyan-700 dark:text-primary":"bg-yellow-100 dark:bg-yellow-500/20 dark:bg-secondary/20 text-yellow-700 dark:text-secondary"])},n(e.type),3),t("span",ct,n(e.hash),1)]),t("div",xt,[t("div",null,[s[10]||(s[10]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Max Clients",-1)),t("div",ut,n(e.max_clients),1)]),t("div",null,[s[11]||(s[11]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Authenticated",-1)),t("div",mt,n(e.authenticated_clients),1)]),t("div",null,[s[12]||(s[12]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Admin Password",-1)),t("div",{class:i(e.has_admin_password?"text-green-700 dark:text-green-500 dark:text-accent-green":"text-red-500 dark:text-accent-red")},n(e.has_admin_password?"✓ Set":"✗ Not Set"),3)]),t("div",null,[s[13]||(s[13]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Guest Password",-1)),t("div",{class:i(e.has_guest_password?"text-green-700 dark:text-green-500 dark:text-accent-green":"text-red-500 dark:text-accent-red")},n(e.has_guest_password?"✓ Set":"✗ Not Set"),3)])]),t("div",yt,[s[14]||(s[14]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs"},"Read-Only Access:",-1)),t("span",{class:i(e.allow_read_only?"text-green-700 dark:text-green-500 dark:text-accent-green":"text-red-500 dark:text-accent-red")},n(e.allow_read_only?"Allowed":"Disabled"),3)])])])]))),128))]))])):c.value==="clients"?(r(),o("div",vt,[u.value.length===0?(r(),o("div",kt," No authenticated clients ")):(r(),o("div",pt,[t("table",bt,[s[15]||(s[15]=t("thead",null,[t("tr",{class:"border-b border-stroke-subtle dark:border-stroke/10"},[t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Client"),t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Address"),t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Identity"),t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Permissions"),t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Last Activity"),t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Actions")])],-1)),t("tbody",null,[(r(!0),o(m,null,y(u.value,e=>(r(),o("tr",{key:e.public_key_full,class:"border-b border-stroke-subtle dark:border-white/5 hover:bg-gray-100/50 dark:hover:bg-white/5 transition-colors"},[t("td",_t,[t("div",gt,n(e.public_key),1)]),t("td",ht,[t("div",ft,n(e.address),1)]),t("td",wt,[t("div",Ct,n(e.identity_name),1),t("div",At,n(e.identity_hash),1)]),t("td",Lt,[t("span",{class:i(["px-2 py-1 text-xs font-medium rounded",e.permissions==="admin"?"bg-green-100 dark:bg-green-500/20 dark:bg-accent-green/20 text-green-700 dark:text-accent-green":"bg-yellow-100 dark:bg-yellow-500/20 dark:bg-secondary/20 text-yellow-700 dark:text-secondary"])},n(e.permissions),3)]),t("td",St,[t("div",Mt,n(g(e.last_activity)),1)]),t("td",jt,[t("button",{onClick:L=>C(e.public_key_full,e.identity_hash),class:"px-3 py-1 bg-red-100 dark:bg-red-500/20 dark:bg-accent-red/20 hover:bg-red-500/30 dark:hover:bg-accent-red/30 text-red-600 dark:text-accent-red rounded text-xs transition-colors"}," Remove ",8,It)])]))),128))])])]))])):c.value==="identities"?(r(),o("div",Nt,[t("div",Rt,[s[17]||(s[17]=t("label",{class:"block text-content-secondary dark:text-content-muted text-sm mb-2"},"Filter by Identity",-1)),R(t("select",{"onUpdate:modelValue":s[0]||(s[0]=e=>k.value=e),class:"bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-cyan-500 dark:focus:border-primary/50 transition-colors"},[s[16]||(s[16]=t("option",{value:null},"All Identities",-1)),(r(!0),o(m,null,y(h.value,e=>(r(),o("option",{key:e.name,value:e.name},n(e.name)+" ("+n(e.authenticated_clients)+" clients) ",9,Vt))),128))],512),[[V,k.value]])]),A.value.length===0?(r(),o("div",Bt," No clients for selected identity ")):(r(),o("div",zt,[(r(!0),o(m,null,y(A.value,e=>(r(),o("div",{key:e.public_key_full,class:"glass-card rounded-[10px] p-4 border border-stroke-subtle dark:border-white/10"},[t("div",Dt,[t("div",Ft,[t("div",Tt,[t("span",{class:i(["px-2 py-1 text-xs font-medium rounded",e.permissions==="admin"?"bg-green-100 dark:bg-green-500/20 dark:bg-accent-green/20 text-green-700 dark:text-accent-green":"bg-yellow-100 dark:bg-yellow-500/20 dark:bg-secondary/20 text-yellow-700 dark:text-secondary"])},n(e.permissions),3),t("span",$t,n(e.public_key),1)]),t("div",Et,[t("div",null,[s[18]||(s[18]=t("span",{class:"text-content-secondary dark:text-content-muted"},"Address:",-1)),t("span",Ht,n(e.address),1)]),t("div",null,[s[19]||(s[19]=t("span",{class:"text-content-secondary dark:text-content-muted"},"Identity:",-1)),t("span",Pt,n(e.identity_name)+" ("+n(e.identity_hash)+")",1)]),t("div",null,[s[20]||(s[20]=t("span",{class:"text-content-secondary dark:text-content-muted"},"Last Activity:",-1)),t("span",Gt,n(g(e.last_activity)),1)]),t("div",null,[s[21]||(s[21]=t("span",{class:"text-content-secondary dark:text-content-muted"},"Last Login:",-1)),t("span",Ot,n(g(e.last_login_success)),1)])])]),t("button",{onClick:L=>C(e.public_key_full,e.identity_hash),class:"ml-4 px-3 py-1 bg-red-100 dark:bg-red-500/20 dark:bg-accent-red/20 hover:bg-red-500/30 dark:hover:bg-accent-red/30 text-red-600 dark:text-accent-red rounded text-xs transition-colors"}," Remove ",8,qt)])]))),128))]))])):f("",!0)])]),t("div",Ut,[t("button",{onClick:p,disabled:x.value,class:"px-4 py-2 bg-cyan-500/20 dark:bg-primary/20 hover:bg-cyan-500/30 dark:hover:bg-primary/30 text-cyan-900 dark:text-primary rounded-lg border border-cyan-500/50 dark:border-primary/50 transition-colors disabled:opacity-50"},n(x.value?"Refreshing...":"Refresh Data"),9,Jt)])]))}});export{Wt as default}; +import{a as I,r as d,o as N,L as b,c as S,b as o,e as t,g as f,t as n,F as m,h as y,w as R,q as V,j as i,k as B,p as r}from"./index-DyUIpN7m.js";const z={class:"p-6 space-y-6"},D={key:0,class:"grid grid-cols-1 md:grid-cols-4 gap-4"},F={class:"glass-card rounded-[15px] p-4"},T={class:"text-2xl font-bold text-content-primary dark:text-content-primary"},$={class:"glass-card rounded-[15px] p-4"},E={class:"text-2xl font-bold text-cyan-500 dark:text-primary"},H={class:"glass-card rounded-[15px] p-4"},P={class:"text-2xl font-bold text-green-700 dark:text-green-500 dark:text-accent-green"},G={class:"glass-card rounded-[15px] p-4"},O={class:"text-2xl font-bold text-yellow-500 dark:text-secondary"},q={class:"glass-card rounded-[15px] p-6"},U={class:"flex flex-wrap border-b border-stroke-subtle dark:border-stroke/10 mb-6"},J=["onClick"],K={class:"flex items-center gap-2"},Q={key:0,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},W={key:1,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},X={key:2,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},Y={class:"min-h-[400px]"},Z={key:0,class:"flex items-center justify-center py-12"},tt={key:1,class:"flex items-center justify-center py-12"},et={class:"text-center"},st={class:"text-content-secondary dark:text-content-muted text-sm mb-4"},nt={key:2,class:"space-y-4"},ot={key:0,class:"text-center py-12 text-content-secondary dark:text-content-muted"},rt={key:1,class:"space-y-4"},at={class:"flex items-start justify-between"},dt={class:"flex-1"},it={class:"flex items-center gap-3 mb-2"},lt={class:"text-lg font-semibold text-content-primary dark:text-content-primary"},ct={class:"text-content-muted dark:text-content-muted text-sm"},xt={class:"grid grid-cols-2 md:grid-cols-4 gap-4 mt-4"},ut={class:"text-content-primary dark:text-content-primary font-medium"},mt={class:"text-cyan-500 dark:text-primary font-medium"},yt={class:"mt-3 flex items-center gap-2"},vt={key:3,class:"space-y-4"},kt={key:0,class:"text-center py-12 text-content-secondary dark:text-content-muted"},pt={key:1,class:"overflow-x-auto"},bt={class:"w-full"},_t={class:"py-3"},gt={class:"font-mono text-sm text-content-primary dark:text-content-primary"},ht={class:"py-3"},ft={class:"font-mono text-xs text-content-secondary dark:text-content-muted"},wt={class:"py-3"},Ct={class:"text-sm text-content-primary dark:text-content-primary"},At={class:"text-xs text-content-muted dark:text-content-muted"},Lt={class:"py-3"},St={class:"py-3"},Mt={class:"text-sm text-content-secondary dark:text-content-muted"},jt={class:"py-3"},It=["onClick"],Nt={key:4,class:"space-y-4"},Rt={class:"mb-4"},Vt=["value"],Bt={key:0,class:"text-center py-12 text-content-secondary dark:text-content-muted"},zt={key:1,class:"grid grid-cols-1 gap-4"},Dt={class:"flex items-start justify-between"},Ft={class:"flex-1"},Tt={class:"flex items-center gap-3 mb-3"},$t={class:"text-content-primary dark:text-content-primary font-mono text-sm"},Et={class:"grid grid-cols-1 md:grid-cols-2 gap-3 text-sm"},Ht={class:"text-content-primary dark:text-content-primary/90 font-mono ml-2"},Pt={class:"text-content-primary dark:text-content-primary/90 ml-2"},Gt={class:"text-content-primary dark:text-content-primary/90 ml-2"},Ot={class:"text-content-primary dark:text-content-primary/90 ml-2"},qt=["onClick"],Ut={class:"flex justify-end"},Jt=["disabled"],Wt=I({name:"SessionsView",__name:"Sessions",setup(Kt){const c=d("overview"),w=d(!1),x=d(!1),v=d(null),_=d(null),u=d([]),l=d(null),k=d(null),M=[{id:"overview",label:"Overview",icon:"overview"},{id:"clients",label:"Authenticated Clients",icon:"clients"},{id:"identities",label:"By Identity",icon:"identities"}];N(async()=>{await p(),w.value=!0});async function p(){x.value=!0,v.value=null;try{const a=await b.getACLInfo();a.success&&(_.value=a.data);const s=await b.getACLClients();s.success&&s.data&&(u.value=s.data.clients||[]);const e=await b.getACLStats();e.success&&(l.value=e.data)}catch(a){v.value=a instanceof Error?a.message:"Failed to load ACL data",console.error("Error fetching ACL data:",a)}finally{x.value=!1}}async function C(a,s){if(confirm("Are you sure you want to remove this client from the ACL?"))try{const e=await b.removeACLClient({public_key:a,identity_hash:s});e.success?await p():alert(`Failed to remove client: ${e.error}`)}catch(e){alert(`Error removing client: ${e}`)}}function g(a){return a?new Date(a*1e3).toLocaleString():"Never"}function j(a){c.value=a}const A=S(()=>k.value?u.value.filter(a=>a.identity_name===k.value):u.value),h=S(()=>_.value?_.value.acls||[]:[]);return(a,s)=>(r(),o("div",z,[s[22]||(s[22]=t("div",null,[t("h1",{class:"text-2xl font-bold text-content-primary dark:text-content-primary"},"Sessions & Access Control"),t("p",{class:"text-content-secondary dark:text-content-muted mt-2"},"Manage authenticated clients and access control lists")],-1)),l.value?(r(),o("div",D,[t("div",F,[s[1]||(s[1]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-1"},"Total Identities",-1)),t("div",T,n(l.value.total_identities),1)]),t("div",$,[s[2]||(s[2]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-1"},"Authenticated Clients",-1)),t("div",E,n(l.value.total_clients),1)]),t("div",H,[s[3]||(s[3]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-1"},"Admin Clients",-1)),t("div",P,n(l.value.admin_clients),1)]),t("div",G,[s[4]||(s[4]=t("div",{class:"text-content-secondary dark:text-content-muted text-sm mb-1"},"Guest Clients",-1)),t("div",O,n(l.value.guest_clients),1)])])):f("",!0),t("div",q,[t("div",U,[(r(),o(m,null,y(M,e=>t("button",{key:e.id,onClick:L=>j(e.id),class:i(["px-4 py-2 text-sm font-medium transition-colors duration-200 border-b-2 mr-6 mb-2",c.value===e.id?"text-cyan-500 dark:text-primary border-cyan-500 dark:border-primary":"text-content-secondary dark:text-content-muted border-transparent hover:text-content-primary dark:hover:text-content-primary hover:border-stroke-subtle dark:hover:border-stroke/30"])},[t("div",K,[e.icon==="overview"?(r(),o("svg",Q,s[5]||(s[5]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"},null,-1)]))):e.icon==="clients"?(r(),o("svg",W,s[6]||(s[6]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"},null,-1)]))):e.icon==="identities"?(r(),o("svg",X,s[7]||(s[7]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M10 6H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V8a2 2 0 00-2-2h-5m-4 0V5a2 2 0 114 0v1m-4 0a2 2 0 104 0m-5 8a2 2 0 100-4 2 2 0 000 4zm0 0c1.306 0 2.417.835 2.83 2M9 14a3.001 3.001 0 00-2.83 2M15 11h3m-3 4h2"},null,-1)]))):f("",!0),B(" "+n(e.label),1)])],10,J)),64))]),t("div",Y,[x.value&&!w.value?(r(),o("div",Z,s[8]||(s[8]=[t("div",{class:"text-center"},[t("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-cyan-500 dark:border-t-primary rounded-full mx-auto mb-4"}),t("div",{class:"text-content-secondary dark:text-content-muted"},"Loading ACL data...")],-1)]))):v.value?(r(),o("div",tt,[t("div",et,[s[9]||(s[9]=t("div",{class:"text-red-500 dark:text-red-400 mb-2"},"Failed to load ACL data",-1)),t("div",st,n(v.value),1),t("button",{onClick:p,class:"px-4 py-2 bg-cyan-500/20 dark:bg-primary/20 hover:bg-cyan-500/30 dark:hover:bg-primary/30 text-cyan-900 dark:text-white rounded-lg border border-cyan-500/50 dark:border-primary/50 transition-colors"}," Retry ")])])):c.value==="overview"?(r(),o("div",nt,[h.value.length===0?(r(),o("div",ot," No identities configured ")):(r(),o("div",rt,[(r(!0),o(m,null,y(h.value,e=>(r(),o("div",{key:e.hash,class:"glass-card rounded-[10px] p-4 border border-stroke-subtle dark:border-white/10 hover:border-cyan-400 dark:hover:border-primary/30 transition-colors"},[t("div",at,[t("div",dt,[t("div",it,[t("h3",lt,n(e.name),1),t("span",{class:i(["px-2 py-1 text-xs font-medium rounded",e.type==="repeater"?"bg-cyan-500/20 dark:bg-primary/20 text-cyan-700 dark:text-primary":"bg-yellow-100 dark:bg-yellow-500/20 dark:bg-secondary/20 text-yellow-700 dark:text-secondary"])},n(e.type),3),t("span",ct,n(e.hash),1)]),t("div",xt,[t("div",null,[s[10]||(s[10]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Max Clients",-1)),t("div",ut,n(e.max_clients),1)]),t("div",null,[s[11]||(s[11]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Authenticated",-1)),t("div",mt,n(e.authenticated_clients),1)]),t("div",null,[s[12]||(s[12]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Admin Password",-1)),t("div",{class:i(e.has_admin_password?"text-green-700 dark:text-green-500 dark:text-accent-green":"text-red-500 dark:text-accent-red")},n(e.has_admin_password?"✓ Set":"✗ Not Set"),3)]),t("div",null,[s[13]||(s[13]=t("div",{class:"text-content-secondary dark:text-content-muted text-xs mb-1"},"Guest Password",-1)),t("div",{class:i(e.has_guest_password?"text-green-700 dark:text-green-500 dark:text-accent-green":"text-red-500 dark:text-accent-red")},n(e.has_guest_password?"✓ Set":"✗ Not Set"),3)])]),t("div",yt,[s[14]||(s[14]=t("span",{class:"text-content-secondary dark:text-content-muted text-xs"},"Read-Only Access:",-1)),t("span",{class:i(e.allow_read_only?"text-green-700 dark:text-green-500 dark:text-accent-green":"text-red-500 dark:text-accent-red")},n(e.allow_read_only?"Allowed":"Disabled"),3)])])])]))),128))]))])):c.value==="clients"?(r(),o("div",vt,[u.value.length===0?(r(),o("div",kt," No authenticated clients ")):(r(),o("div",pt,[t("table",bt,[s[15]||(s[15]=t("thead",null,[t("tr",{class:"border-b border-stroke-subtle dark:border-stroke/10"},[t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Client"),t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Address"),t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Identity"),t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Permissions"),t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Last Activity"),t("th",{class:"text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3"},"Actions")])],-1)),t("tbody",null,[(r(!0),o(m,null,y(u.value,e=>(r(),o("tr",{key:e.public_key_full,class:"border-b border-stroke-subtle dark:border-white/5 hover:bg-gray-100/50 dark:hover:bg-white/5 transition-colors"},[t("td",_t,[t("div",gt,n(e.public_key),1)]),t("td",ht,[t("div",ft,n(e.address),1)]),t("td",wt,[t("div",Ct,n(e.identity_name),1),t("div",At,n(e.identity_hash),1)]),t("td",Lt,[t("span",{class:i(["px-2 py-1 text-xs font-medium rounded",e.permissions==="admin"?"bg-green-100 dark:bg-green-500/20 dark:bg-accent-green/20 text-green-700 dark:text-accent-green":"bg-yellow-100 dark:bg-yellow-500/20 dark:bg-secondary/20 text-yellow-700 dark:text-secondary"])},n(e.permissions),3)]),t("td",St,[t("div",Mt,n(g(e.last_activity)),1)]),t("td",jt,[t("button",{onClick:L=>C(e.public_key_full,e.identity_hash),class:"px-3 py-1 bg-red-100 dark:bg-red-500/20 dark:bg-accent-red/20 hover:bg-red-500/30 dark:hover:bg-accent-red/30 text-red-600 dark:text-accent-red rounded text-xs transition-colors"}," Remove ",8,It)])]))),128))])])]))])):c.value==="identities"?(r(),o("div",Nt,[t("div",Rt,[s[17]||(s[17]=t("label",{class:"block text-content-secondary dark:text-content-muted text-sm mb-2"},"Filter by Identity",-1)),R(t("select",{"onUpdate:modelValue":s[0]||(s[0]=e=>k.value=e),class:"bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-2 text-content-primary dark:text-content-primary focus:outline-none focus:border-cyan-500 dark:focus:border-primary/50 transition-colors"},[s[16]||(s[16]=t("option",{value:null},"All Identities",-1)),(r(!0),o(m,null,y(h.value,e=>(r(),o("option",{key:e.name,value:e.name},n(e.name)+" ("+n(e.authenticated_clients)+" clients) ",9,Vt))),128))],512),[[V,k.value]])]),A.value.length===0?(r(),o("div",Bt," No clients for selected identity ")):(r(),o("div",zt,[(r(!0),o(m,null,y(A.value,e=>(r(),o("div",{key:e.public_key_full,class:"glass-card rounded-[10px] p-4 border border-stroke-subtle dark:border-white/10"},[t("div",Dt,[t("div",Ft,[t("div",Tt,[t("span",{class:i(["px-2 py-1 text-xs font-medium rounded",e.permissions==="admin"?"bg-green-100 dark:bg-green-500/20 dark:bg-accent-green/20 text-green-700 dark:text-accent-green":"bg-yellow-100 dark:bg-yellow-500/20 dark:bg-secondary/20 text-yellow-700 dark:text-secondary"])},n(e.permissions),3),t("span",$t,n(e.public_key),1)]),t("div",Et,[t("div",null,[s[18]||(s[18]=t("span",{class:"text-content-secondary dark:text-content-muted"},"Address:",-1)),t("span",Ht,n(e.address),1)]),t("div",null,[s[19]||(s[19]=t("span",{class:"text-content-secondary dark:text-content-muted"},"Identity:",-1)),t("span",Pt,n(e.identity_name)+" ("+n(e.identity_hash)+")",1)]),t("div",null,[s[20]||(s[20]=t("span",{class:"text-content-secondary dark:text-content-muted"},"Last Activity:",-1)),t("span",Gt,n(g(e.last_activity)),1)]),t("div",null,[s[21]||(s[21]=t("span",{class:"text-content-secondary dark:text-content-muted"},"Last Login:",-1)),t("span",Ot,n(g(e.last_login_success)),1)])])]),t("button",{onClick:L=>C(e.public_key_full,e.identity_hash),class:"ml-4 px-3 py-1 bg-red-100 dark:bg-red-500/20 dark:bg-accent-red/20 hover:bg-red-500/30 dark:hover:bg-accent-red/30 text-red-600 dark:text-accent-red rounded text-xs transition-colors"}," Remove ",8,qt)])]))),128))]))])):f("",!0)])]),t("div",Ut,[t("button",{onClick:p,disabled:x.value,class:"px-4 py-2 bg-cyan-500/20 dark:bg-primary/20 hover:bg-cyan-500/30 dark:hover:bg-primary/30 text-cyan-900 dark:text-primary rounded-lg border border-cyan-500/50 dark:border-primary/50 transition-colors disabled:opacity-50"},n(x.value?"Refreshing...":"Refresh Data"),9,Jt)])]))}});export{Wt as default}; diff --git a/repeater/web/html/assets/Setup-CLJIlSKT.js b/repeater/web/html/assets/Setup-CLJIlSKT.js new file mode 100644 index 0000000..3ec84b4 --- /dev/null +++ b/repeater/web/html/assets/Setup-CLJIlSKT.js @@ -0,0 +1 @@ +import{d as A,r as l,c as P,a as W,o as I,b as a,e,f as B,_ as Y,t as i,u as o,n as J,g as k,F as N,h as z,i as K,w as h,v as C,j as _,k as V,l as q,T,m as Q,p as n,q as E,s as X,x as Z}from"./index-DyUIpN7m.js";const ee=A("setup",()=>{const m=l(1),r=l(5),y=l(`pyRpt${Math.floor(Math.random()*1e4).toString().padStart(4,"0")}`),b=l(null),v=l(null),x=l(""),f=l(""),w=l(!1),c=l({frequency:"915.0",spreading_factor:"7",bandwidth:"125",coding_rate:"5"}),R=l([]),j=l([]),g=l(!1),S=l(!1),u=l(null),t=P(()=>{switch(m.value){case 1:return!0;case 2:return y.value.trim().length>0;case 3:return b.value!==null;case 4:return w.value?c.value.frequency&&c.value.spreading_factor&&c.value.bandwidth&&c.value.coding_rate:v.value!==null;case 5:return x.value.length>=6&&x.value===f.value;default:return!1}}),s=P(()=>m.value>1),M=P(()=>m.value===r.value);async function F(){g.value=!0,u.value=null;try{const p=await(await fetch("/api/hardware_options")).json();if(p.error)throw new Error(p.error);R.value=p.hardware||[]}catch(d){u.value=d instanceof Error?d.message:"Failed to load hardware options",console.error("Error fetching hardware options:",d)}finally{g.value=!1}}async function H(){g.value=!0,u.value=null;try{const p=await(await fetch("/api/radio_presets")).json();if(p.error)throw new Error(p.error);j.value=p.presets||[]}catch(d){u.value=d instanceof Error?d.message:"Failed to load radio presets",console.error("Error fetching radio presets:",d)}finally{g.value=!1}}async function U(){if(!t.value)return{success:!1,error:"Please complete all required fields"};S.value=!0,u.value=null;try{const d=w.value?{title:"Custom Configuration",description:"Custom radio settings",frequency:c.value.frequency,spreading_factor:c.value.spreading_factor,bandwidth:c.value.bandwidth,coding_rate:c.value.coding_rate}:v.value,L=await(await fetch("/api/setup_wizard",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({node_name:y.value.trim(),hardware_key:b.value?.key,radio_preset:d,admin_password:x.value})})).json();if(!L.success)throw new Error(L.error||"Setup failed");return{success:!0,data:L}}catch(d){const p=d instanceof Error?d.message:"Failed to complete setup";return u.value=p,{success:!1,error:p}}finally{S.value=!1}}function O(){t.value&&m.value=1&&d<=r.value&&(m.value=d)}function D(){m.value=1,y.value=`pyRpt${Math.floor(Math.random()*1e4).toString().padStart(4,"0")}`,b.value=null,v.value=null,w.value=!1,c.value={frequency:"915.0",spreading_factor:"7",bandwidth:"125",coding_rate:"5"},x.value="",f.value="",u.value=null}return{currentStep:m,totalSteps:r,nodeName:y,selectedHardware:b,selectedRadioPreset:v,useCustomRadio:w,customRadio:c,adminPassword:x,confirmPassword:f,hardwareOptions:R,radioPresets:j,isLoading:g,isSubmitting:S,error:u,canGoNext:t,canGoBack:s,isLastStep:M,fetchHardwareOptions:F,fetchRadioPresets:H,completeSetup:U,nextStep:O,previousStep:$,goToStep:G,reset:D}}),te={class:"min-h-screen bg-background dark:bg-background overflow-hidden relative flex items-center justify-center p-4"},re={class:"absolute top-4 right-4 z-20"},oe={class:"w-full max-w-4xl relative z-10"},se={class:"mb-8"},ae={class:"flex justify-between mb-2"},ne={class:"text-content-secondary dark:text-content-muted text-sm"},de={class:"text-content-secondary dark:text-content-muted text-sm"},ie={class:"h-2 bg-stroke-subtle dark:bg-stroke/10 rounded-full overflow-hidden"},le={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[20px] p-6 sm:p-8 md:p-12"},ue={class:"flex justify-center mb-8"},ce={class:"flex gap-2"},pe={class:"mb-8"},me={class:"text-2xl sm:text-3xl font-bold text-content-primary dark:text-content-primary mb-2 text-center"},be={key:0,class:"space-y-6 mt-8"},fe={key:1,class:"space-y-6 mt-8"},xe={class:"max-w-md mx-auto"},ke={key:2,class:"space-y-6 mt-8"},ve={key:0,class:"text-center text-content-secondary dark:text-content-muted"},ge={key:1,class:"text-center text-content-secondary dark:text-content-muted"},ye={key:2,class:"grid grid-cols-1 md:grid-cols-2 gap-4 max-w-3xl mx-auto"},he=["onClick"],we={class:"font-medium text-content-primary dark:text-content-primary mb-1"},_e={class:"text-sm text-content-secondary dark:text-content-muted"},Se={key:3,class:"space-y-6 mt-8"},Ce={key:0,class:"text-center text-content-secondary dark:text-content-muted"},Re={key:1,class:"text-center text-content-secondary dark:text-content-muted"},je={key:2,class:"max-w-5xl mx-auto"},Pe={class:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4"},Me=["onClick"],Le={class:"relative z-10"},Be={class:"font-medium text-content-primary dark:text-content-primary mb-1 flex items-start justify-between gap-2"},Ne={class:"flex items-center gap-2"},ze={class:"text-2xl"},Ve={key:0,class:"text-primary flex-shrink-0"},qe={class:"text-xs text-content-secondary dark:text-content-muted mb-3"},Te={class:"grid grid-cols-2 gap-2 text-xs"},Ee={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},Fe={class:"text-content-primary dark:text-content-primary/80 font-medium"},He={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},Ue={class:"text-content-primary dark:text-content-primary/80 font-medium"},Oe={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},$e={class:"text-content-primary dark:text-content-primary/80 font-medium"},Ge={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},De={class:"text-content-primary dark:text-content-primary/80 font-medium"},Ae={class:"border-t border-stroke-subtle dark:border-stroke/10 pt-6"},We={class:"flex items-center justify-between mb-2"},Ie={key:0,class:"text-primary"},Ye={key:0,class:"mt-4 grid grid-cols-2 gap-4"},Je={key:4,class:"space-y-6 mt-8"},Ke={class:"max-w-md mx-auto space-y-4"},Qe={key:0,class:"text-red-600 dark:text-red-400 text-sm"},Xe={key:0,class:"mb-6 bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-600 dark:text-red-200"},Ze={class:"flex justify-between gap-4"},et={key:1},tt=["disabled"],rt={key:0,class:"w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"},ot={key:1},st={key:2},at={key:3},nt={key:4,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},dt={class:"flex justify-center mb-6"},it={key:0,class:"w-16 h-16 rounded-full bg-green-100 dark:bg-green-500/20 flex items-center justify-center"},lt={key:1,class:"w-16 h-16 rounded-full bg-red-100 dark:bg-red-500/20 flex items-center justify-center"},ut={class:"text-2xl font-bold text-content-primary dark:text-content-primary text-center mb-4"},ct={class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"},pt=W({name:"SetupView",__name:"Setup",setup(m){const r=ee(),y=Q(),b=l(!1),v=l(""),x=l(""),f=l("success"),w=u=>{const t=u.toLowerCase();return t.includes("australia")?"🇦🇺":t.includes("eu")||t.includes("uk")?"🇪🇺":t.includes("czech")?"🇨🇿":t.includes("new zealand")?"🇳🇿":t.includes("portugal")?"🇵🇹":t.includes("switzerland")?"🇨🇭":t.includes("usa")||t.includes("canada")?"🇺🇸":t.includes("vietnam")?"🇻🇳":"🌍"};I(async()=>{await Promise.all([r.fetchHardwareOptions(),r.fetchRadioPresets()])});const c=P(()=>r.currentStep/r.totalSteps*100);async function R(){if(r.isLastStep){const u=await r.completeSetup();u.success?(f.value="success",v.value="Setup Complete!",x.value="Your repeater has been configured successfully. The service is restarting now...",b.value=!0,setTimeout(()=>{b.value=!1,y.push("/login")},5e3)):(f.value="error",v.value="Setup Failed",x.value=u.error||"An unknown error occurred",b.value=!0)}else r.nextStep()}function j(){r.previousStep()}function g(){b.value=!1,f.value==="success"&&y.push("/login")}const S=["Welcome","Repeater Name","Hardware Selection","Radio Configuration","Security Setup"];return(u,t)=>(n(),a("div",te,[e("div",re,[B(Y)]),t[36]||(t[36]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slow -top-[79px] left-[575px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[37]||(t[37]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-75 animate-pulse-slower -top-[94px] -left-[92px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[38]||(t[38]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slowest top-[373px] left-[246px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),e("div",oe,[e("div",se,[e("div",ae,[e("span",ne,"Step "+i(o(r).currentStep)+" of "+i(o(r).totalSteps),1),e("span",de,i(Math.round(c.value))+"% Complete",1)]),e("div",ie,[e("div",{class:"h-full bg-gradient-to-r from-primary to-primary/80 transition-all duration-500",style:J({width:`${c.value}%`})},null,4)])]),e("div",le,[e("div",ue,[e("div",ce,[(n(!0),a(N,null,z(o(r).totalSteps,s=>(n(),a("div",{key:s,class:_(["w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-all",s===o(r).currentStep?"bg-primary text-white":s

Welcome to your pyMC Repeater! Let's get you set up in just a few steps.

You'll configure:

  • Repeater name and identification
  • Hardware board selection
  • Radio frequency and settings
  • Admin password for secure access
',1)]))):o(r).currentStep===2?(n(),a("div",fe,[t[12]||(t[12]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Choose a unique name for your repeater. This will be used for identification on the mesh network. ",-1)),e("div",xe,[t[10]||(t[10]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Repeater Name",-1)),h(e("input",{"onUpdate:modelValue":t[0]||(t[0]=s=>o(r).nodeName=s),type:"text",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"e.g., pyRpt0001",maxlength:"32"},null,512),[[C,o(r).nodeName]]),t[11]||(t[11]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-2"}," Use letters, numbers, hyphens, or underscores (3-32 characters) ",-1))])])):o(r).currentStep===3?(n(),a("div",ke,[t[13]||(t[13]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Select your hardware board type ",-1)),o(r).isLoading?(n(),a("div",ve," Loading hardware options... ")):o(r).hardwareOptions.length===0?(n(),a("div",ge," No hardware options available ")):(n(),a("div",ye,[(n(!0),a(N,null,z(o(r).hardwareOptions,s=>(n(),a("button",{key:s.key,onClick:M=>o(r).selectedHardware=s,class:_(["p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm",o(r).selectedHardware?.key===s.key?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",we,i(s.name),1),e("div",_e,i(s.description||s.key),1)],10,he))),128))]))])):o(r).currentStep===4?(n(),a("div",Se,[t[28]||(t[28]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Choose a radio configuration preset for your region or create a custom configuration ",-1)),o(r).isLoading?(n(),a("div",Ce," Loading radio presets... ")):o(r).radioPresets.length===0?(n(),a("div",Re," No radio presets available ")):(n(),a("div",je,[e("div",Pe,[(n(!0),a(N,null,z(o(r).radioPresets,s=>(n(),a("button",{key:s.title,onClick:M=>{o(r).selectedRadioPreset=s,o(r).useCustomRadio=!1},class:_(["p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm relative overflow-hidden",!o(r).useCustomRadio&&o(r).selectedRadioPreset?.title===s.title?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",Le,[e("div",Be,[e("span",Ne,[e("span",ze,i(w(s.title)),1),e("span",null,i(s.title),1)]),!o(r).useCustomRadio&&o(r).selectedRadioPreset?.title===s.title?(n(),a("div",Ve,t[14]||(t[14]=[e("svg",{class:"w-5 h-5",fill:"currentColor",viewBox:"0 0 20 20"},[e("path",{"fill-rule":"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z","clip-rule":"evenodd"})],-1)]))):k("",!0)]),e("div",qe,i(s.description),1),e("div",Te,[e("div",Ee,[t[15]||(t[15]=e("div",{class:"text-content-muted dark:text-content-muted"},"Freq",-1)),e("div",Fe,i(s.frequency),1)]),e("div",He,[t[16]||(t[16]=e("div",{class:"text-content-muted dark:text-content-muted"},"BW",-1)),e("div",Ue,i(s.bandwidth),1)]),e("div",Oe,[t[17]||(t[17]=e("div",{class:"text-content-muted dark:text-content-muted"},"SF",-1)),e("div",$e,i(s.spreading_factor),1)]),e("div",Ge,[t[18]||(t[18]=e("div",{class:"text-content-muted dark:text-content-muted"},"CR",-1)),e("div",De,i(s.coding_rate),1)])])])],10,Me))),128))]),e("div",Ae,[e("button",{onClick:t[1]||(t[1]=s=>{o(r).useCustomRadio=!o(r).useCustomRadio,o(r).useCustomRadio&&(o(r).selectedRadioPreset=null)}),class:_(["w-full p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm",o(r).useCustomRadio?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",We,[t[20]||(t[20]=e("div",{class:"font-medium text-content-primary dark:text-content-primary flex items-center gap-2"},[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"})]),V(" Custom Configuration ")],-1)),o(r).useCustomRadio?(n(),a("div",Ie,t[19]||(t[19]=[e("svg",{class:"w-5 h-5",fill:"currentColor",viewBox:"0 0 20 20"},[e("path",{"fill-rule":"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z","clip-rule":"evenodd"})],-1)]))):k("",!0)]),t[21]||(t[21]=e("div",{class:"text-xs text-content-secondary dark:text-content-muted"},"Manually configure frequency, bandwidth, spreading factor, and coding rate",-1))],2),B(T,{name:"slide"},{default:q(()=>[o(r).useCustomRadio?(n(),a("div",Ye,[e("div",null,[t[22]||(t[22]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Frequency (MHz)",-1)),h(e("input",{"onUpdate:modelValue":t[2]||(t[2]=s=>o(r).customRadio.frequency=s),type:"number",step:"0.1",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all",placeholder:"915.0"},null,512),[[C,o(r).customRadio.frequency]])]),e("div",null,[t[23]||(t[23]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Bandwidth (kHz)",-1)),h(e("input",{"onUpdate:modelValue":t[3]||(t[3]=s=>o(r).customRadio.bandwidth=s),type:"number",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all",placeholder:"125"},null,512),[[C,o(r).customRadio.bandwidth]])]),e("div",null,[t[25]||(t[25]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Spreading Factor",-1)),h(e("select",{"onUpdate:modelValue":t[4]||(t[4]=s=>o(r).customRadio.spreading_factor=s),class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all"},t[24]||(t[24]=[e("option",{value:"7"},"7",-1),e("option",{value:"8"},"8",-1),e("option",{value:"9"},"9",-1),e("option",{value:"10"},"10",-1),e("option",{value:"11"},"11",-1),e("option",{value:"12"},"12",-1)]),512),[[E,o(r).customRadio.spreading_factor]])]),e("div",null,[t[27]||(t[27]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Coding Rate",-1)),h(e("select",{"onUpdate:modelValue":t[5]||(t[5]=s=>o(r).customRadio.coding_rate=s),class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all"},t[26]||(t[26]=[e("option",{value:"5"},"4/5",-1),e("option",{value:"6"},"4/6",-1),e("option",{value:"7"},"4/7",-1),e("option",{value:"8"},"4/8",-1)]),512),[[E,o(r).customRadio.coding_rate]])])])):k("",!0)]),_:1})])]))])):o(r).currentStep===5?(n(),a("div",Je,[t[32]||(t[32]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Set a secure admin password to protect your repeater ",-1)),e("div",Ke,[e("div",null,[t[29]||(t[29]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Admin Password",-1)),h(e("input",{"onUpdate:modelValue":t[6]||(t[6]=s=>o(r).adminPassword=s),type:"password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"Enter password (min 6 characters)",minlength:"6"},null,512),[[C,o(r).adminPassword]])]),e("div",null,[t[30]||(t[30]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Confirm Password",-1)),h(e("input",{"onUpdate:modelValue":t[7]||(t[7]=s=>o(r).confirmPassword=s),type:"password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"Confirm password"},null,512),[[C,o(r).confirmPassword]])]),o(r).adminPassword&&o(r).confirmPassword&&o(r).adminPassword!==o(r).confirmPassword?(n(),a("div",Qe," Passwords do not match ")):k("",!0),t[31]||(t[31]=e("div",{class:"bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3 text-sm text-yellow-800 dark:text-yellow-200"},[e("strong",null,"Important:"),V(" Remember this password - you'll need it to access the dashboard. ")],-1))])])):k("",!0)]),o(r).error?(n(),a("div",Xe,i(o(r).error),1)):k("",!0),e("div",Ze,[o(r).canGoBack?(n(),a("button",{key:0,onClick:j,class:"px-6 py-3 rounded-[12px] bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 text-content-primary dark:text-content-primary hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20 transition-all duration-300 font-medium"}," Back ")):(n(),a("div",et)),e("button",{onClick:R,disabled:!o(r).canGoNext||o(r).isSubmitting,class:_(["px-8 py-3 rounded-[12px] font-semibold transition-all duration-300 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed",o(r).canGoNext&&!o(r).isSubmitting?"bg-gradient-to-r from-primary/20 to-primary/10 hover:from-primary/30 hover:to-primary/20 text-white border border-primary/30 hover:border-primary/50":"bg-background-mute dark:bg-stroke/5 text-content-muted dark:text-content-muted border border-stroke-subtle dark:border-stroke/10"])},[o(r).isSubmitting?(n(),a("div",rt)):k("",!0),o(r).isSubmitting?(n(),a("span",ot,"Setting up...")):o(r).isLastStep?(n(),a("span",st,"Complete Setup")):(n(),a("span",at,"Next")),!o(r).isSubmitting&&!o(r).isLastStep?(n(),a("svg",nt,t[33]||(t[33]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 5l7 7-7 7"},null,-1)]))):k("",!0)],10,tt)])])]),B(T,{name:"modal"},{default:q(()=>[b.value?(n(),a("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm",onClick:g},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl max-w-md w-full p-8 rounded-[24px] border border-stroke-subtle dark:border-white/20 shadow-[0_8px_32px_0_rgba(0,0,0,0.37)]",onClick:t[8]||(t[8]=X(()=>{},["stop"]))},[e("div",dt,[f.value==="success"?(n(),a("div",it,t[34]||(t[34]=[e("svg",{class:"w-8 h-8 text-green-600 dark:text-green-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"})],-1)]))):(n(),a("div",lt,t[35]||(t[35]=[e("svg",{class:"w-8 h-8 text-red-600 dark:text-red-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])))]),e("h3",ut,i(v.value),1),e("p",ct,i(x.value),1),e("button",{onClick:g,class:_(["w-full px-6 py-3 rounded-lg font-medium transition-all",f.value==="success"?"bg-gradient-to-r from-primary/20 to-primary/10 hover:from-primary/30 hover:to-primary/20 text-white":"bg-gradient-to-r from-red-500/20 to-red-500/10 hover:from-red-500/30 hover:to-red-500/20 text-white"])},i(f.value==="success"?"Continue to Login":"Close"),3)])])):k("",!0)]),_:1})]))}}),bt=Z(pt,[["__scopeId","data-v-20a8772f"]]);export{bt as default}; diff --git a/repeater/web/html/assets/Setup-CSawSnc5.js b/repeater/web/html/assets/Setup-CSawSnc5.js deleted file mode 100644 index 6ce3729..0000000 --- a/repeater/web/html/assets/Setup-CSawSnc5.js +++ /dev/null @@ -1 +0,0 @@ -import{d as A,r as l,c as P,a as W,o as I,b as a,e,f as B,_ as Y,t as i,u as o,n as J,g as k,F as N,h as z,i as K,w as h,v as C,j as _,k as V,l as q,T,m as Q,p as n,q as E,s as X,x as Z}from"./index-sHch0610.js";const ee=A("setup",()=>{const m=l(1),r=l(5),y=l(`pyRpt${Math.floor(Math.random()*1e4).toString().padStart(4,"0")}`),b=l(null),v=l(null),x=l(""),f=l(""),w=l(!1),c=l({frequency:"915.0",spreading_factor:"7",bandwidth:"125",coding_rate:"5"}),R=l([]),j=l([]),g=l(!1),S=l(!1),u=l(null),kp=l("/dev/ttyUSB0"),kb=l("115200"),t=P(()=>{switch(m.value){case 1:return!0;case 2:return y.value.trim().length>0;case 3:return b.value!==null&&(b.value?.key!=="kiss"||(kp.value&&String(kp.value).trim().length>0));case 4:return w.value?c.value.frequency&&c.value.spreading_factor&&c.value.bandwidth&&c.value.coding_rate:v.value!==null;case 5:return x.value.length>=6&&x.value===f.value;default:return!1}}),s=P(()=>m.value>1),M=P(()=>m.value===r.value);async function F(){g.value=!0,u.value=null;try{const p=await(await fetch("/api/hardware_options")).json();if(p.error)throw new Error(p.error);R.value=p.hardware||[]}catch(d){u.value=d instanceof Error?d.message:"Failed to load hardware options",console.error("Error fetching hardware options:",d)}finally{g.value=!1}}async function H(){g.value=!0,u.value=null;try{const p=await(await fetch("/api/radio_presets")).json();if(p.error)throw new Error(p.error);j.value=p.presets||[]}catch(d){u.value=d instanceof Error?d.message:"Failed to load radio presets",console.error("Error fetching radio presets:",d)}finally{g.value=!1}}async function U(){if(!t.value)return{success:!1,error:"Please complete all required fields"};S.value=!0,u.value=null;try{const d=w.value?{title:"Custom Configuration",description:"Custom radio settings",frequency:c.value.frequency,spreading_factor:c.value.spreading_factor,bandwidth:c.value.bandwidth,coding_rate:c.value.coding_rate}:v.value;const payload={node_name:y.value.trim(),hardware_key:b.value?.key,radio_preset:d,admin_password:x.value};if(b.value?.key==="kiss"){payload.kiss_port=kp.value||"/dev/ttyUSB0";payload.kiss_baud_rate=Number(kb.value)||115200;}const L=await(await fetch("/api/setup_wizard",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(payload)})).json();if(!L.success)throw new Error(L.error||"Setup failed");return{success:!0,data:L}}catch(d){const p=d instanceof Error?d.message:"Failed to complete setup";return u.value=p,{success:!1,error:p}}finally{S.value=!1}}function O(){t.value&&m.value=1&&d<=r.value&&(m.value=d)}function D(){m.value=1,y.value=`pyRpt${Math.floor(Math.random()*1e4).toString().padStart(4,"0")}`,b.value=null,v.value=null,w.value=!1,c.value={frequency:"915.0",spreading_factor:"7",bandwidth:"125",coding_rate:"5"},x.value="",f.value="",u.value=null,kp.value="/dev/ttyUSB0",kb.value="115200"}return{currentStep:m,totalSteps:r,nodeName:y,selectedHardware:b,selectedRadioPreset:v,useCustomRadio:w,customRadio:c,adminPassword:x,confirmPassword:f,hardwareOptions:R,radioPresets:j,isLoading:g,isSubmitting:S,error:u,canGoNext:t,canGoBack:s,isLastStep:M,fetchHardwareOptions:F,fetchRadioPresets:H,completeSetup:U,nextStep:O,previousStep:$,goToStep:G,reset:D,kissPort:kp,kissBaud:kb}}),te={class:"min-h-screen bg-background dark:bg-background overflow-hidden relative flex items-center justify-center p-4"},re={class:"absolute top-4 right-4 z-20"},oe={class:"w-full max-w-4xl relative z-10"},se={class:"mb-8"},ae={class:"flex justify-between mb-2"},ne={class:"text-content-secondary dark:text-content-muted text-sm"},de={class:"text-content-secondary dark:text-content-muted text-sm"},ie={class:"h-2 bg-stroke-subtle dark:bg-stroke/10 rounded-full overflow-hidden"},le={class:"bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[20px] p-6 sm:p-8 md:p-12"},ue={class:"flex justify-center mb-8"},ce={class:"flex gap-2"},pe={class:"mb-8"},me={class:"text-2xl sm:text-3xl font-bold text-content-primary dark:text-content-primary mb-2 text-center"},be={key:0,class:"space-y-6 mt-8"},fe={key:1,class:"space-y-6 mt-8"},xe={class:"max-w-md mx-auto"},ke={key:2,class:"space-y-6 mt-8"},ve={key:0,class:"text-center text-content-secondary dark:text-content-muted"},ge={key:1,class:"text-center text-content-secondary dark:text-content-muted"},ye={key:2,class:"grid grid-cols-1 md:grid-cols-2 gap-4 max-w-3xl mx-auto"},he=["onClick"],we={class:"font-medium text-content-primary dark:text-content-primary mb-1"},_e={class:"text-sm text-content-secondary dark:text-content-muted"},Se={key:3,class:"space-y-6 mt-8"},Ce={key:0,class:"text-center text-content-secondary dark:text-content-muted"},Re={key:1,class:"text-center text-content-secondary dark:text-content-muted"},je={key:2,class:"max-w-5xl mx-auto"},Pe={class:"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4"},Me=["onClick"],Le={class:"relative z-10"},Be={class:"font-medium text-content-primary dark:text-content-primary mb-1 flex items-start justify-between gap-2"},Ne={class:"flex items-center gap-2"},ze={class:"text-2xl"},Ve={key:0,class:"text-primary flex-shrink-0"},qe={class:"text-xs text-content-secondary dark:text-content-muted mb-3"},Te={class:"grid grid-cols-2 gap-2 text-xs"},Ee={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},Fe={class:"text-content-primary dark:text-content-primary/80 font-medium"},He={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},Ue={class:"text-content-primary dark:text-content-primary/80 font-medium"},Oe={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},$e={class:"text-content-primary dark:text-content-primary/80 font-medium"},Ge={class:"bg-gray-50 dark:bg-white/5 rounded px-2 py-1"},De={class:"text-content-primary dark:text-content-primary/80 font-medium"},Ae={class:"border-t border-stroke-subtle dark:border-stroke/10 pt-6"},We={class:"flex items-center justify-between mb-2"},Ie={key:0,class:"text-primary"},Ye={key:0,class:"mt-4 grid grid-cols-2 gap-4"},Je={key:4,class:"space-y-6 mt-8"},Ke={class:"max-w-md mx-auto space-y-4"},Qe={key:0,class:"text-red-600 dark:text-red-400 text-sm"},Xe={key:0,class:"mb-6 bg-red-500/10 border border-red-500/30 rounded-lg p-4 text-red-600 dark:text-red-200"},Ze={class:"flex justify-between gap-4"},et={key:1},tt=["disabled"],rt={key:0,class:"w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"},ot={key:1},st={key:2},at={key:3},nt={key:4,class:"w-4 h-4",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},dt={class:"flex justify-center mb-6"},it={key:0,class:"w-16 h-16 rounded-full bg-green-100 dark:bg-green-500/20 flex items-center justify-center"},lt={key:1,class:"w-16 h-16 rounded-full bg-red-100 dark:bg-red-500/20 flex items-center justify-center"},ut={class:"text-2xl font-bold text-content-primary dark:text-content-primary text-center mb-4"},ct={class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"},pt=W({name:"SetupView",__name:"Setup",setup(m){const r=ee(),y=Q(),b=l(!1),v=l(""),x=l(""),f=l("success"),w=u=>{const t=u.toLowerCase();return t.includes("australia")?"🇦🇺":t.includes("eu")||t.includes("uk")?"🇪🇺":t.includes("czech")?"🇨🇿":t.includes("new zealand")?"🇳🇿":t.includes("portugal")?"🇵🇹":t.includes("switzerland")?"🇨🇭":t.includes("usa")||t.includes("canada")?"🇺🇸":t.includes("vietnam")?"🇻🇳":"🌍"};I(async()=>{await Promise.all([r.fetchHardwareOptions(),r.fetchRadioPresets()])});const c=P(()=>r.currentStep/r.totalSteps*100);async function R(){if(r.isLastStep){const u=await r.completeSetup();u.success?(f.value="success",v.value="Setup Complete!",x.value="Your repeater has been configured successfully. The service is restarting now...",b.value=!0,setTimeout(()=>{b.value=!1,y.push("/login")},5e3)):(f.value="error",v.value="Setup Failed",x.value=u.error||"An unknown error occurred",b.value=!0)}else r.nextStep()}function j(){r.previousStep()}function g(){b.value=!1,f.value==="success"&&y.push("/login")}const S=["Welcome","Repeater Name","Hardware Selection","Radio Configuration","Security Setup"];return(u,t)=>(n(),a("div",te,[e("div",re,[B(Y)]),t[36]||(t[36]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slow -top-[79px] left-[575px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[37]||(t[37]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-75 animate-pulse-slower -top-[94px] -left-[92px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),t[38]||(t[38]=e("div",{class:"bg-gradient-light dark:bg-gradient-dark absolute rounded-full -rotate-[24.22deg] w-[705px] h-[512px] blur-[120px] opacity-80 animate-pulse-slowest top-[373px] left-[246px] mix-blend-multiply dark:mix-blend-screen pointer-events-none"},null,-1)),e("div",oe,[e("div",se,[e("div",ae,[e("span",ne,"Step "+i(o(r).currentStep)+" of "+i(o(r).totalSteps),1),e("span",de,i(Math.round(c.value))+"% Complete",1)]),e("div",ie,[e("div",{class:"h-full bg-gradient-to-r from-primary to-primary/80 transition-all duration-500",style:J({width:`${c.value}%`})},null,4)])]),e("div",le,[e("div",ue,[e("div",ce,[(n(!0),a(N,null,z(o(r).totalSteps,s=>(n(),a("div",{key:s,class:_(["w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-all",s===o(r).currentStep?"bg-primary text-white":s

Welcome to your pyMC Repeater! Let's get you set up in just a few steps.

You'll configure:

  • Repeater name and identification
  • Hardware board selection
  • Radio frequency and settings
  • Admin password for secure access
',1)]))):o(r).currentStep===2?(n(),a("div",fe,[t[12]||(t[12]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Choose a unique name for your repeater. This will be used for identification on the mesh network. ",-1)),e("div",xe,[t[10]||(t[10]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Repeater Name",-1)),h(e("input",{"onUpdate:modelValue":t[0]||(t[0]=s=>o(r).nodeName=s),type:"text",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"e.g., pyRpt0001",maxlength:"32"},null,512),[[C,o(r).nodeName]]),t[11]||(t[11]=e("p",{class:"text-content-secondary dark:text-content-muted text-xs mt-2"}," Use letters, numbers, hyphens, or underscores (3-32 characters) ",-1))])])):o(r).currentStep===3?(n(),a("div",ke,[t[13]||(t[13]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Select your hardware board type ",-1)),o(r).isLoading?(n(),a("div",ve," Loading hardware options... ")):o(r).hardwareOptions.length===0?(n(),a("div",ge," No hardware options available ")):(n(),a("div",ye,[(n(!0),a(N,null,z(o(r).hardwareOptions,s=>(n(),a("button",{key:s.key,onClick:M=>o(r).selectedHardware=s,class:_(["p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm",o(r).selectedHardware?.key===s.key?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",we,i(s.name),1),e("div",_e,i(s.description||s.key),1)],10,he))),128))])),o(r).selectedHardware?.key==="kiss"?(n(),a("div",{class:"mt-6 max-w-md mx-auto space-y-4 border-t border-stroke-subtle dark:border-stroke/10 pt-6"},[e("div",null,[e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"MeshCore KISS modem – Serial port",-1),h(e("input",{"onUpdate:modelValue":M=>o(r).kissPort=M,class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"/dev/ttyUSB0"},null,512),[[C,o(r).kissPort]])]),e("div",null,[e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Baud rate",-1),h(e("input",{"onUpdate:modelValue":M=>o(r).kissBaud=M,type:"number",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"115200"},null,512),[[C,o(r).kissBaud]])]) ])):k("",!0)])):o(r).currentStep===4?(n(),a("div",Se,[t[28]||(t[28]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Choose a radio configuration preset for your region or create a custom configuration ",-1)),o(r).isLoading?(n(),a("div",Ce," Loading radio presets... ")):o(r).radioPresets.length===0?(n(),a("div",Re," No radio presets available ")):(n(),a("div",je,[e("div",Pe,[(n(!0),a(N,null,z(o(r).radioPresets,s=>(n(),a("button",{key:s.title,onClick:M=>{o(r).selectedRadioPreset=s,o(r).useCustomRadio=!1},class:_(["p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm relative overflow-hidden",!o(r).useCustomRadio&&o(r).selectedRadioPreset?.title===s.title?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",Le,[e("div",Be,[e("span",Ne,[e("span",ze,i(w(s.title)),1),e("span",null,i(s.title),1)]),!o(r).useCustomRadio&&o(r).selectedRadioPreset?.title===s.title?(n(),a("div",Ve,t[14]||(t[14]=[e("svg",{class:"w-5 h-5",fill:"currentColor",viewBox:"0 0 20 20"},[e("path",{"fill-rule":"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z","clip-rule":"evenodd"})],-1)]))):k("",!0)]),e("div",qe,i(s.description),1),e("div",Te,[e("div",Ee,[t[15]||(t[15]=e("div",{class:"text-content-muted dark:text-content-muted"},"Freq",-1)),e("div",Fe,i(s.frequency),1)]),e("div",He,[t[16]||(t[16]=e("div",{class:"text-content-muted dark:text-content-muted"},"BW",-1)),e("div",Ue,i(s.bandwidth),1)]),e("div",Oe,[t[17]||(t[17]=e("div",{class:"text-content-muted dark:text-content-muted"},"SF",-1)),e("div",$e,i(s.spreading_factor),1)]),e("div",Ge,[t[18]||(t[18]=e("div",{class:"text-content-muted dark:text-content-muted"},"CR",-1)),e("div",De,i(s.coding_rate),1)])])])],10,Me))),128))]),e("div",Ae,[e("button",{onClick:t[1]||(t[1]=s=>{o(r).useCustomRadio=!o(r).useCustomRadio,o(r).useCustomRadio&&(o(r).selectedRadioPreset=null)}),class:_(["w-full p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm",o(r).useCustomRadio?"bg-gradient-to-r from-primary/20 to-primary/10 border-primary/50 shadow-lg shadow-primary/20":"bg-background-mute dark:bg-white/5 border-stroke-subtle dark:border-stroke/10 hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20"])},[e("div",We,[t[20]||(t[20]=e("div",{class:"font-medium text-content-primary dark:text-content-primary flex items-center gap-2"},[e("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"})]),V(" Custom Configuration ")],-1)),o(r).useCustomRadio?(n(),a("div",Ie,t[19]||(t[19]=[e("svg",{class:"w-5 h-5",fill:"currentColor",viewBox:"0 0 20 20"},[e("path",{"fill-rule":"evenodd",d:"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z","clip-rule":"evenodd"})],-1)]))):k("",!0)]),t[21]||(t[21]=e("div",{class:"text-xs text-content-secondary dark:text-content-muted"},"Manually configure frequency, bandwidth, spreading factor, and coding rate",-1))],2),B(T,{name:"slide"},{default:q(()=>[o(r).useCustomRadio?(n(),a("div",Ye,[e("div",null,[t[22]||(t[22]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Frequency (MHz)",-1)),h(e("input",{"onUpdate:modelValue":t[2]||(t[2]=s=>o(r).customRadio.frequency=s),type:"number",step:"0.1",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all",placeholder:"915.0"},null,512),[[C,o(r).customRadio.frequency]])]),e("div",null,[t[23]||(t[23]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Bandwidth (kHz)",-1)),h(e("input",{"onUpdate:modelValue":t[3]||(t[3]=s=>o(r).customRadio.bandwidth=s),type:"number",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all",placeholder:"125"},null,512),[[C,o(r).customRadio.bandwidth]])]),e("div",null,[t[25]||(t[25]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Spreading Factor",-1)),h(e("select",{"onUpdate:modelValue":t[4]||(t[4]=s=>o(r).customRadio.spreading_factor=s),class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all"},t[24]||(t[24]=[e("option",{value:"7"},"7",-1),e("option",{value:"8"},"8",-1),e("option",{value:"9"},"9",-1),e("option",{value:"10"},"10",-1),e("option",{value:"11"},"11",-1),e("option",{value:"12"},"12",-1)]),512),[[E,o(r).customRadio.spreading_factor]])]),e("div",null,[t[27]||(t[27]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Coding Rate",-1)),h(e("select",{"onUpdate:modelValue":t[5]||(t[5]=s=>o(r).customRadio.coding_rate=s),class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-[12px] px-4 py-2.5 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-transparent transition-all"},t[26]||(t[26]=[e("option",{value:"5"},"4/5",-1),e("option",{value:"6"},"4/6",-1),e("option",{value:"7"},"4/7",-1),e("option",{value:"8"},"4/8",-1)]),512),[[E,o(r).customRadio.coding_rate]])])])):k("",!0)]),_:1})])]))])):o(r).currentStep===5?(n(),a("div",Je,[t[32]||(t[32]=e("p",{class:"text-content-secondary dark:text-content-primary/70 text-center mb-6"}," Set a secure admin password to protect your repeater ",-1)),e("div",Ke,[e("div",null,[t[29]||(t[29]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Admin Password",-1)),h(e("input",{"onUpdate:modelValue":t[6]||(t[6]=s=>o(r).adminPassword=s),type:"password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"Enter password (min 6 characters)",minlength:"6"},null,512),[[C,o(r).adminPassword]])]),e("div",null,[t[30]||(t[30]=e("label",{class:"block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2"},"Confirm Password",-1)),h(e("input",{"onUpdate:modelValue":t[7]||(t[7]=s=>o(r).confirmPassword=s),type:"password",class:"w-full bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-4 py-3 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent",placeholder:"Confirm password"},null,512),[[C,o(r).confirmPassword]])]),o(r).adminPassword&&o(r).confirmPassword&&o(r).adminPassword!==o(r).confirmPassword?(n(),a("div",Qe," Passwords do not match ")):k("",!0),t[31]||(t[31]=e("div",{class:"bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3 text-sm text-yellow-800 dark:text-yellow-200"},[e("strong",null,"Important:"),V(" Remember this password - you'll need it to access the dashboard. ")],-1))])])):k("",!0)]),o(r).error?(n(),a("div",Xe,i(o(r).error),1)):k("",!0),e("div",Ze,[o(r).canGoBack?(n(),a("button",{key:0,onClick:j,class:"px-6 py-3 rounded-[12px] bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 text-content-primary dark:text-content-primary hover:bg-stroke-subtle dark:hover:bg-white/10 hover:border-stroke dark:hover:border-stroke/20 transition-all duration-300 font-medium"}," Back ")):(n(),a("div",et)),e("button",{onClick:R,disabled:!o(r).canGoNext||o(r).isSubmitting,class:_(["px-8 py-3 rounded-[12px] font-semibold transition-all duration-300 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed",o(r).canGoNext&&!o(r).isSubmitting?"bg-gradient-to-r from-primary/20 to-primary/10 hover:from-primary/30 hover:to-primary/20 text-white border border-primary/30 hover:border-primary/50":"bg-background-mute dark:bg-stroke/5 text-content-muted dark:text-content-muted border border-stroke-subtle dark:border-stroke/10"])},[o(r).isSubmitting?(n(),a("div",rt)):k("",!0),o(r).isSubmitting?(n(),a("span",ot,"Setting up...")):o(r).isLastStep?(n(),a("span",st,"Complete Setup")):(n(),a("span",at,"Next")),!o(r).isSubmitting&&!o(r).isLastStep?(n(),a("svg",nt,t[33]||(t[33]=[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M9 5l7 7-7 7"},null,-1)]))):k("",!0)],10,tt)])])]),B(T,{name:"modal"},{default:q(()=>[b.value?(n(),a("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm",onClick:g},[e("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl max-w-md w-full p-8 rounded-[24px] border border-stroke-subtle dark:border-white/20 shadow-[0_8px_32px_0_rgba(0,0,0,0.37)]",onClick:t[8]||(t[8]=X(()=>{},["stop"]))},[e("div",dt,[f.value==="success"?(n(),a("div",it,t[34]||(t[34]=[e("svg",{class:"w-8 h-8 text-green-600 dark:text-green-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"})],-1)]))):(n(),a("div",lt,t[35]||(t[35]=[e("svg",{class:"w-8 h-8 text-red-600 dark:text-red-400",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[e("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)])))]),e("h3",ut,i(v.value),1),e("p",ct,i(x.value),1),e("button",{onClick:g,class:_(["w-full px-6 py-3 rounded-lg font-medium transition-all",f.value==="success"?"bg-gradient-to-r from-primary/20 to-primary/10 hover:from-primary/30 hover:to-primary/20 text-white":"bg-gradient-to-r from-red-500/20 to-red-500/10 hover:from-red-500/30 hover:to-red-500/20 text-white"])},i(f.value==="success"?"Continue to Login":"Close"),3)])])):k("",!0)]),_:1})]))}}),bt=Z(pt,[["__scopeId","data-v-20a8772f"]]);export{bt as default}; diff --git a/repeater/web/html/assets/Statistics-BQdSlYZ9.js b/repeater/web/html/assets/Statistics-BQdSlYZ9.js deleted file mode 100644 index e2351cf..0000000 --- a/repeater/web/html/assets/Statistics-BQdSlYZ9.js +++ /dev/null @@ -1 +0,0 @@ -import{a as Ae,J as Ne,K as Oe,r as u,D as ue,c as pe,o as Le,E as me,R as M,H as ze,b as h,e as a,g as B,w as He,q as Je,F as ve,h as fe,f as te,u as ge,i as Ue,t as X,L as W,I as ae,n as je,p as k,x as $e}from"./index-sHch0610.js";import{S as se}from"./chartjs-adapter-date-fns.esm-BnFZGz19.js";import{g as Ke,s as Ve}from"./preferences-DtwbSSgO.js";import{C as G,a as Ie,L as Xe,P as We,b as Ge,c as Ye,B as Ze,D as qe,S as Qe,p as et,d as tt,e as at,A as st,f as rt,i as ot,T as lt}from"./chart-B185MtDy.js";import{P as H}from"./plotly.min-DO11Gp-n.js";import"./_commonjsHelpers-CqkleIqs.js";const nt={class:"p-3 sm:p-6 space-y-4 sm:space-y-6"},it={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3"},ct={class:"flex items-center gap-2 sm:gap-3"},dt=["value"],ut={class:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"},pt={class:"glass-card rounded-[15px] p-3 sm:p-6"},mt={class:"relative h-40 sm:h-48 rounded-lg p-2 sm:p-4"},vt={key:0,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-xs z-20"},ft={key:1,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 z-20"},gt={class:"grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 items-stretch"},xt={class:"glass-card rounded-[15px] p-3 sm:p-6 flex flex-col"},bt={class:"relative flex-1 min-h-[12rem] sm:min-h-[16rem] rounded-lg"},yt={key:0,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-xs z-20"},ht={class:"glass-card rounded-[15px] p-3 sm:p-6 flex flex-col"},kt={class:"flex-1 flex flex-col justify-evenly"},Ct={key:0,class:"flex items-center justify-center flex-1"},_t={key:1,class:"flex items-center justify-center flex-1"},wt={class:"w-28 sm:w-32 text-sm text-content-primary dark:text-content-primary truncate"},St={class:"flex-1 h-12 bg-background-mute dark:bg-stroke/10 rounded overflow-hidden"},Tt={class:"w-20 text-sm text-content-secondary dark:text-content-muted text-right tabular-nums"},Rt={key:0,class:"glass-card rounded-[15px] p-6 sm:p-8 text-center"},Dt={key:1,class:"glass-card rounded-[15px] p-6 sm:p-8 text-center"},Ft={class:"text-content-secondary dark:text-content-muted text-sm"},Et=Ae({name:"StatisticsView",__name:"Statistics",setup(Mt){G.register(Ie,Xe,We,Ge,Ye,Ze,qe,Qe,et,tt,at,st,rt,ot,lt);const A=Ne(),Y=Oe(),N=u(null),Z=u(!1),re=()=>{N.value||Y.isConnected||(N.value=window.setInterval(V,3e4))},oe=()=>{N.value&&(clearInterval(N.value),N.value=null)},T=()=>{const t=document.documentElement.classList.contains("dark");return{gridColor:t?"rgba(255, 255, 255, 0.1)":"rgba(0, 0, 0, 0.1)",tickColor:t?"rgba(255, 255, 255, 0.7)":"rgba(0, 0, 0, 0.7)",legendColor:t?"rgba(255, 255, 255, 0.8)":"rgba(0, 0, 0, 0.8)",titleColor:t?"rgba(255, 255, 255, 0.8)":"rgba(0, 0, 0, 0.8)"}},g=u(Ke("statistics_selectedHours",24)),xe=[{value:1,label:"1 Hour"},{value:6,label:"6 Hours"},{value:12,label:"12 Hours"},{value:24,label:"24 Hours"},{value:48,label:"2 Days"},{value:168,label:"1 Week"}];ue(g,t=>Ve("statistics_selectedHours",t));const R=u(null),O=u(null),J=u([]),D=u(null),q=u([]),U=u(!0),j=u(null),S=u({packetRate:!0,packetType:!0,noiseFloor:!1,routePie:!0,sparklines:!0}),L=u(!1),$=u(!1),z=u(!1),C=u(null),_=u(null),y=u(null),K=u(null),Q=u(null),ee=u(null),F=u(null),le=pe(()=>{const t=A.packetStats;return t?{totalRx:t.total_packets||0,totalTx:t.transmitted_packets||0}:{totalRx:0,totalTx:0}}),ne=(t,e)=>{if(t.length===0)return[];const i=Math.round(e*60*60*1e3/72),r=new Map;return t.forEach(([n,v])=>{let f=n;n>1e15?f=n/1e3:n>1e9&&n<1e12&&(f=n*1e3);const w=Math.floor(f/i)*i;r.has(w)||r.set(w,[]),r.get(w).push(v)}),Array.from(r.entries()).sort((n,v)=>n[0]-v[0]).map(([,n])=>n.reduce((v,f)=>v+f,0)/n.length)},ie=pe(()=>{let t=[],e=[];if(R.value?.series){const s=R.value.series.find(r=>r.type==="rx_count"),i=R.value.series.find(r=>r.type==="tx_count");s?.data&&(t=ne(s.data,g.value)),i?.data&&(e=ne(i.data,g.value))}return{totalPackets:t,transmittedPackets:e,droppedPackets:[]}}),V=async()=>{try{U.value=!0,j.value=null,await Promise.all([A.fetchPacketStats({hours:g.value}),A.fetchSystemStats()]),U.value=!1,be()}catch(t){j.value=t instanceof Error?t.message:"Failed to fetch data",U.value=!1}},be=async()=>{S.value={packetRate:!0,packetType:!0,noiseFloor:!0,routePie:!0,sparklines:!0};const t=[ye(),he(),ke(),Ce()];try{await Promise.allSettled(t),await me(),!K.value||!Q.value?setTimeout(()=>{ce()},100):ce()}catch(e){console.error("Error loading chart data:",e)}},ye=async()=>{try{const t=await W.get("/metrics_graph_data",{hours:g.value,resolution:"average",metrics:"rx_count,tx_count"});t?.success&&(R.value=t.data)}catch{R.value=null}},he=async()=>{try{const t=await W.get("/packet_type_graph_data",{hours:g.value,resolution:"average",types:"all"});if(t?.success&&t.data){const e=t.data;J.value=e.series||[]}}catch{J.value=[]}},ke=async()=>{try{const t=await W.get("/route_stats",{hours:g.value});t?.success&&t.data&&(D.value=t.data)}catch{D.value=null}},Ce=async()=>{try{const t={hours:g.value},e=await W.get("/noise_floor_history",t);if(e.success&&e.data){const i=e.data.history||[];Array.isArray(i)&&i.length>0&&(O.value={chart_data:i.map(r=>({timestamp:r.timestamp||Date.now()/1e3,noise_floor_dbm:r.noise_floor_dbm||r.noise_floor||-120}))},we())}}catch{O.value={chart_data:[]}}},_e=()=>{S.value={packetRate:!0,packetType:!0,noiseFloor:!0,routePie:!0,sparklines:!0},de(),L.value=!1,$.value=!1,z.value=!1,V()},we=()=>{if(q.value=[],O.value?.chart_data&&O.value.chart_data.length>0){const t=O.value.chart_data;q.value=t.map(e=>({timestamp:e.timestamp*1e3,snr:null,rssi:null,noiseFloor:e.noise_floor_dbm}))}},ce=()=>{if(!Z.value){Z.value=!0;try{Se(),Te(),Re(),De(),setTimeout(()=>{S.value={packetRate:!1,packetType:!1,noiseFloor:!1,routePie:!1,sparklines:!1},setTimeout(()=>{const t=M(C.value),e=M(_.value),s=M(y.value);t&&t.update("none"),e&&e.update("none"),s&&s.update("none")},50)},100)}catch(t){console.error("Error creating/updating charts:",t),de()}finally{Z.value=!1}}},de=()=>{try{C.value&&(C.value.destroy(),C.value=null),_.value&&(_.value.destroy(),_.value=null),y.value&&(y.value.destroy(),y.value=null),F.value&&H.purge(F.value)}catch(t){console.error("Error destroying charts:",t)}},Se=()=>{if(!K.value)return;const t=K.value.getContext("2d");if(!t)return;let e=[],s=[];if(R.value?.series){const p=R.value.series.find(c=>c.type==="rx_count"),b=R.value.series.find(c=>c.type==="tx_count");p?.data&&(e=p.data.map(([c,d])=>{let l=c;return c>1e15?l=c/1e3:c>1e12?l=c:c>1e9?l=c*1e3:l=Date.now(),{x:l,y:d}})),b?.data&&(s=b.data.map(([c,d])=>{let l=c;return c>1e15?l=c/1e3:c>1e12?l=c:c>1e9?l=c*1e3:l=Date.now(),{x:l,y:d}}))}if(e.length===0&&s.length===0){L.value=!0;return}L.value=!1,C.value&&(C.value.destroy(),C.value=null);const r=Math.round(g.value*60*60*1e3/72),n=p=>{if(p.length===0)return[];const b=new Map;return p.forEach(d=>{const l=Math.floor(d.x/r)*r;b.has(l)||b.set(l,[]),b.get(l).push(d.y)}),Array.from(b.entries()).map(([d,l])=>({x:d,y:l.reduce((E,I)=>E+I,0)/l.length})).sort((d,l)=>d.x-l.x)},v=(p,b=3)=>{if(p.lengthPe+Be.y,0)/I.length;c.push({x:p[d].x,y:Me})}return c},f=v(n(e)),w=v(n(s)),P=[...f.map(p=>p.y),...w.map(p=>p.y)],o=Math.min(...P),m=Math.max(...P),x=m-o||m*.1||.001,Fe=Math.max(0,o-x*.05),Ee=m+x*.05;try{const p=JSON.parse(JSON.stringify(f)),b=JSON.parse(JSON.stringify(w)),c=new G(t,{type:"line",data:{datasets:[{label:"TX/hr",data:b,borderColor:"#F59E0B",backgroundColor:"#F59E0B",borderWidth:2,fill:"origin",tension:.4,pointRadius:0,pointHoverRadius:3,order:1},{label:"RX/hr",data:p,borderColor:"#C084FC",backgroundColor:"#C084FC",borderWidth:2,fill:"origin",tension:.4,pointRadius:0,pointHoverRadius:3,order:2}]},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},interaction:{mode:"index",intersect:!1},plugins:{legend:{display:!1},title:{display:!1},tooltip:{enabled:!0,backgroundColor:"rgba(0, 0, 0, 0.8)",titleColor:"rgba(255, 255, 255, 0.9)",bodyColor:"rgba(255, 255, 255, 0.8)",borderColor:"rgba(255, 255, 255, 0.2)",borderWidth:1,padding:12,displayColors:!0,callbacks:{title:function(d){const l=d[0]?.parsed?.x;return l==null?"":new Date(l).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})},label:function(d){const l=d.dataset?.label||"",E=d.parsed?.y;return E==null?l:`${l}: ${E.toFixed(3)}`}}}},scales:{x:{type:"time",time:{unit:"hour",displayFormats:{hour:"HH:mm"}},min:Date.now()-g.value*3600*1e3,max:Date.now(),grid:{color:T().gridColor},ticks:{color:T().tickColor,maxTicksLimit:8}},y:{beginAtZero:!1,grid:{color:T().gridColor},ticks:{color:T().tickColor,callback:function(d){return typeof d=="number"?d.toFixed(3):d}},min:Fe,max:Ee}}}});C.value=ae(c)}catch(p){console.error("Error creating packet rate chart:",p),L.value=!0}},Te=()=>{if(!Q.value)return;const t=Q.value.getContext("2d");if(!t)return;const e=[],s=[],i=["#60A5FA","#34D399","#FBBF24","#A78BFA","#F87171","#06B6D4","#84CC16","#F472B6","#10B981"];if(J.value.length>0)J.value.forEach(r=>{const n=r.data?r.data.reduce((v,f)=>v+f[1],0):0;n>0&&(e.push(r.name.replace(/\([^)]*\)/g,"").trim()),s.push(n))});else{$.value=!0;return}$.value=!1,_.value&&(_.value.destroy(),_.value=null);try{const r=JSON.parse(JSON.stringify(e)),n=JSON.parse(JSON.stringify(s)),v=new G(t,{type:"bar",data:{labels:r,datasets:[{data:n,backgroundColor:i.slice(0,n.length),borderRadius:8,borderSkipped:!1}]},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},plugins:{legend:{display:!1}},scales:{x:{grid:{display:!1},ticks:{color:"rgba(255, 255, 255, 0.7)",font:{size:10}}},y:{beginAtZero:!0,grid:{color:"rgba(255, 255, 255, 0.1)"},ticks:{color:"rgba(255, 255, 255, 0.7)"}}}}});_.value=ae(v)}catch(r){console.error("Error creating packet type chart:",r),$.value=!0}},Re=()=>{if(!ee.value)return;const t=ee.value.getContext("2d");if(!t)return;const e=q.value.map(o=>({x:o.timestamp,y:o.noiseFloor})).filter(o=>o.y!==null&&o.y!==void 0),s=e.map(o=>o.y),i=s.length>0?Math.min(...s):-120,r=s.length>0?Math.max(...s):-110,n=r-i||1,v=i-n*.05,f=r+n*.05;if(y.value)try{const o=M(y.value),m=JSON.parse(JSON.stringify(e));o.data.datasets[0]&&(o.data.datasets[0].data=m),o.options?.scales?.x&&(o.options.scales.x.min=Date.now()-g.value*3600*1e3,o.options.scales.x.max=Date.now()),o.update("active");return}catch{y.value.destroy(),y.value=null}const w=JSON.parse(JSON.stringify(e)),P=new G(t,{type:"scatter",data:{datasets:[{label:"Noise Floor (dBm)",data:w,borderWidth:0,backgroundColor:"rgba(245, 158, 11, 0.8)",pointRadius:3,pointHoverRadius:5,pointStyle:"circle"}]},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},interaction:{mode:"index",intersect:!1},plugins:{legend:{display:!0,position:"top",labels:{color:T().legendColor,usePointStyle:!0,padding:20}},tooltip:{enabled:!0,backgroundColor:"rgba(0, 0, 0, 0.8)",titleColor:"rgba(255, 255, 255, 0.9)",bodyColor:"rgba(255, 255, 255, 0.8)",borderColor:"rgba(255, 255, 255, 0.2)",borderWidth:1,padding:12,displayColors:!0,callbacks:{title:function(o){const m=o[0]?.parsed?.x;return m==null?"":new Date(m).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})},label:function(o){const m=o.dataset?.label||"",x=o.parsed?.y;return x==null?m:`${m}: ${x.toFixed(1)} dBm`}}}},scales:{x:{type:"time",time:{unit:"hour",displayFormats:{hour:"HH:mm"}},min:Date.now()-g.value*3600*1e3,max:Date.now(),grid:{color:T().gridColor},ticks:{color:T().tickColor,maxTicksLimit:8}},y:{type:"linear",display:!0,title:{display:!0,text:"Noise Floor (dBm)",color:T().titleColor},grid:{color:"rgba(245, 158, 11, 0.2)"},ticks:{color:"#F59E0B",callback:function(o){return typeof o=="number"?o.toFixed(1):o}},min:v,max:f}}}});y.value=ae(P)},De=()=>{if(!F.value)return;if(!D.value||!D.value.route_totals){z.value=!0;return}z.value=!1;const t=D.value.route_totals,e=Object.keys(t),s=Object.values(t),i=["#3B82F6","#10B981","#F59E0B","#A78BFA","#F87171"];try{const r=JSON.parse(JSON.stringify(e)),n=JSON.parse(JSON.stringify(s)),v=n.reduce((m,x)=>m+x,0),f=n.map(m=>m/v*100),w=r.map((m,x)=>({type:"bar",name:m,x:[f[x]],y:[""],orientation:"h",marker:{color:i[x%i.length]},text:f[x]>=5?`${m} ${f[x].toFixed(0)}%`:"",textposition:"inside",textfont:{color:"white",size:11},hoverinfo:"none",insidetextanchor:"middle"})),P={paper_bgcolor:"rgba(0,0,0,0)",plot_bgcolor:"rgba(0,0,0,0)",font:{color:"rgba(255, 255, 255, 0.8)",size:11},margin:{t:10,b:60,l:10,r:10},barmode:"stack",showlegend:!0,legend:{orientation:"h",x:0,y:-.3,xanchor:"left",font:{color:"rgba(255, 255, 255, 0.8)",size:10}},xaxis:{showgrid:!1,showticklabels:!1,zeroline:!1,range:[0,100]},yaxis:{showgrid:!1,showticklabels:!1,zeroline:!1},hovermode:!1,bargap:0},o={responsive:!0,displayModeBar:!1,staticPlot:!0};H.newPlot(F.value,w,P,o)}catch(r){console.error("Error creating route treemap chart:",r),z.value=!0}};return Le(async()=>{await me(),V(),Y.isConnected||re(),ue(()=>Y.isConnected,t=>{t?oe():re()}),window.addEventListener("resize",()=>{setTimeout(()=>{M(C.value)?.resize(),M(_.value)?.resize(),M(y.value)?.resize(),F.value&&H.Plots&&H.Plots.resize(F.value)},100)})}),ze(()=>{oe(),C.value?.destroy(),_.value?.destroy(),y.value?.destroy(),F.value&&H.purge(F.value),window.removeEventListener("resize",()=>{})}),(t,e)=>(k(),h("div",nt,[a("div",it,[e[2]||(e[2]=a("h2",{class:"text-xl sm:text-2xl font-bold text-content-primary dark:text-content-primary"},"Statistics",-1)),a("div",ct,[e[1]||(e[1]=a("label",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Time Range:",-1)),He(a("select",{"onUpdate:modelValue":e[0]||(e[0]=s=>g.value=s),onChange:_e,class:"bg-white dark:bg-white/10 border border-stroke-subtle dark:border-stroke/20 rounded-lg px-2 sm:px-3 py-1.5 sm:py-2 text-content-primary dark:text-content-primary text-xs sm:text-sm focus:outline-hidden focus:border-primary dark:focus:border-accent-purple/50 transition-colors"},[(k(),h(ve,null,fe(xe,s=>a("option",{key:s.value,value:s.value,class:"bg-white dark:bg-gray-800 text-content-primary dark:text-content-primary"},X(s.label),9,dt)),64))],544),[[Je,g.value]])])]),a("div",ut,[te(se,{title:"Total RX",value:le.value.totalRx,color:"#AAE8E8",data:ie.value.totalPackets,loading:S.value.sparklines,variant:"classic"},null,8,["value","data","loading"]),te(se,{title:"Total TX",value:le.value.totalTx,color:"#FFC246",data:ie.value.transmittedPackets,loading:S.value.sparklines,variant:"classic"},null,8,["value","data","loading"]),te(se,{title:"Packet Hash Cache",value:ge(A).systemStats?.duplicate_cache_size??0,color:"#9F7AEA",data:[],loading:!1,variant:"smooth",subtitle:`Entries expire after ${(()=>{const s=ge(A).systemStats?.cache_ttl??3600,i=Math.floor(s/60);return i>=60?`${Math.floor(i/60)}h`:`${i}m`})()}`},null,8,["value","subtitle"])]),a("div",pt,[e[6]||(e[6]=a("h3",{class:"text-content-primary dark:text-content-primary text-lg sm:text-xl font-semibold mb-3 sm:mb-4"},"Performance Metrics",-1)),a("div",null,[e[5]||(e[5]=Ue('

Packet Rate (RX/TX PER HOUR)

RX/hr
TX/hr
',2)),a("div",mt,[a("canvas",{ref_key:"packetRateCanvasRef",ref:K,class:"w-full h-full relative z-10"},null,512),S.value.packetRate?(k(),h("div",vt,e[3]||(e[3]=[a("div",{class:"text-center"},[a("div",{class:"animate-spin w-6 h-6 sm:w-8 sm:h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-purple-600 dark:border-t-purple-400 rounded-full mx-auto mb-2"}),a("div",{class:"text-content-secondary dark:text-content-muted text-[10px] sm:text-xs"},"Loading packet rate data...")],-1)]))):B("",!0),L.value&&!S.value.packetRate?(k(),h("div",ft,e[4]||(e[4]=[a("div",{class:"text-center"},[a("div",{class:"text-red-700 dark:text-red-400 text-sm font-semibold mb-1"},"No Data Available"),a("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Packet rate data not found")],-1)]))):B("",!0)])])]),a("div",gt,[a("div",xt,[e[8]||(e[8]=a("h3",{class:"text-content-primary dark:text-content-primary text-lg sm:text-xl font-semibold mb-3 sm:mb-4"}," Noise Floor Over Time ",-1)),a("div",bt,[a("canvas",{ref_key:"signalMetricsCanvasRef",ref:ee,class:"w-full h-full"},null,512),S.value.noiseFloor?(k(),h("div",yt,e[7]||(e[7]=[a("div",{class:"text-center"},[a("div",{class:"animate-spin w-6 h-6 sm:w-8 sm:h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-amber-600 dark:border-t-amber-400 rounded-full mx-auto mb-2"}),a("div",{class:"text-content-secondary dark:text-content-muted text-[10px] sm:text-xs"},"Loading noise floor data...")],-1)]))):B("",!0)])]),a("div",ht,[e[11]||(e[11]=a("h3",{class:"text-content-primary dark:text-content-primary text-lg sm:text-xl font-semibold mb-3 sm:mb-4"},"Route Distribution",-1)),a("div",kt,[S.value.routePie?(k(),h("div",Ct,e[9]||(e[9]=[a("div",{class:"text-center"},[a("div",{class:"animate-spin w-6 h-6 border-2 border-stroke-subtle dark:border-stroke/20 border-t-green-600 dark:border-t-green-400 rounded-full mx-auto mb-2"}),a("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Loading route data...")],-1)]))):z.value?(k(),h("div",_t,e[10]||(e[10]=[a("div",{class:"text-center"},[a("div",{class:"text-red-700 dark:text-red-400 text-sm font-semibold mb-1"},"No Data Available"),a("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Route statistics not found")],-1)]))):D.value?.route_totals?(k(!0),h(ve,{key:2},fe(D.value.route_totals,(s,i,r)=>(k(),h("div",{key:i,class:"flex items-center gap-3"},[a("div",wt,X(i),1),a("div",St,[a("div",{class:"h-full rounded transition-all duration-300",style:je({width:`${s/Math.max(...Object.values(D.value.route_totals))*100}%`,backgroundColor:["#3B82F6","#10B981","#F59E0B","#A78BFA","#F87171"][r%5]})},null,4)]),a("div",Tt,X(s.toLocaleString()),1)]))),128)):B("",!0)])])]),U.value?(k(),h("div",Rt,e[12]||(e[12]=[a("div",{class:"text-content-secondary dark:text-content-muted mb-2 text-sm"},"Loading statistics...",-1),a("div",{class:"animate-spin w-6 h-6 sm:w-8 sm:h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-content-primary dark:border-t-white/70 rounded-full mx-auto"},null,-1)]))):B("",!0),j.value?(k(),h("div",Dt,[e[13]||(e[13]=a("div",{class:"text-red-700 dark:text-red-400 mb-2 text-sm font-semibold"},"Failed to load statistics",-1)),a("p",Ft,X(j.value),1),a("button",{onClick:V,class:"mt-4 px-4 py-2 bg-primary hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 text-white font-medium rounded-lg border border-primary/20 dark:border-primary/30 transition-colors shadow-sm"}," Retry ")])):B("",!0)]))}}),zt=$e(Et,[["__scopeId","data-v-c51a7a30"]]);export{zt as default}; diff --git a/repeater/web/html/assets/Statistics-C56LjnFt.css b/repeater/web/html/assets/Statistics-C56LjnFt.css new file mode 100644 index 0000000..07df351 --- /dev/null +++ b/repeater/web/html/assets/Statistics-C56LjnFt.css @@ -0,0 +1 @@ +.plotly-chart[data-v-8daccd7e]{background:transparent!important} diff --git a/repeater/web/html/assets/Statistics-G140biph.css b/repeater/web/html/assets/Statistics-G140biph.css deleted file mode 100644 index e6a3c28..0000000 --- a/repeater/web/html/assets/Statistics-G140biph.css +++ /dev/null @@ -1 +0,0 @@ -.plotly-chart[data-v-c51a7a30]{background:transparent!important} diff --git a/repeater/web/html/assets/Statistics-JA9qMWm0.js b/repeater/web/html/assets/Statistics-JA9qMWm0.js new file mode 100644 index 0000000..3e530d9 --- /dev/null +++ b/repeater/web/html/assets/Statistics-JA9qMWm0.js @@ -0,0 +1 @@ +import{a as Oe,J as Le,K as ze,r as u,D as pe,c as me,o as He,E as ve,R as M,H as Je,b as h,e as a,g as B,w as Ue,q as je,F as ge,h as fe,f as G,u as xe,i as $e,t as Y,L as H,I as le,n as Ke,p as k,x as Ve}from"./index-DyUIpN7m.js";import{S as Z}from"./chartjs-adapter-date-fns.esm-BYg_FBhT.js";import{g as Ie,s as Xe}from"./preferences-DtwbSSgO.js";import{C as q,a as We,L as Ge,P as Ye,b as Ze,c as qe,B as Qe,D as et,S as tt,p as at,d as st,e as rt,A as ot,f as lt,i as nt,T as it}from"./chart-B185MtDy.js";import{P as J}from"./plotly.min-DO11Gp-n.js";import"./_commonjsHelpers-CqkleIqs.js";const ct={class:"p-3 sm:p-6 space-y-4 sm:space-y-6"},dt={class:"flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3"},ut={class:"flex items-center gap-2 sm:gap-3"},pt=["value"],mt={class:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"},vt={class:"glass-card rounded-[15px] p-3 sm:p-6"},gt={class:"relative h-40 sm:h-48 rounded-lg p-2 sm:p-4"},ft={key:0,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-xs z-20"},xt={key:1,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 z-20"},bt={class:"grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 items-stretch"},yt={class:"glass-card rounded-[15px] p-3 sm:p-6 flex flex-col"},ht={class:"relative flex-1 min-h-[12rem] sm:min-h-[16rem] rounded-lg"},kt={key:0,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-xs z-20"},Ct={class:"glass-card rounded-[15px] p-3 sm:p-6 flex flex-col"},_t={class:"flex-1 flex flex-col justify-evenly"},wt={key:0,class:"flex items-center justify-center flex-1"},St={key:1,class:"flex items-center justify-center flex-1"},Tt={class:"w-28 sm:w-32 text-sm text-content-primary dark:text-content-primary truncate"},Rt={class:"flex-1 h-12 bg-background-mute dark:bg-stroke/10 rounded overflow-hidden"},Dt={class:"w-20 text-sm text-content-secondary dark:text-content-muted text-right tabular-nums"},Et={key:0,class:"glass-card rounded-[15px] p-6 sm:p-8 text-center"},Ft={key:1,class:"glass-card rounded-[15px] p-6 sm:p-8 text-center"},Mt={class:"text-content-secondary dark:text-content-muted text-sm"},Pt=Oe({name:"StatisticsView",__name:"Statistics",setup(Bt){q.register(We,Ge,Ye,Ze,qe,Qe,et,tt,at,st,rt,ot,lt,nt,it);const A=Le(),Q=ze(),N=u(null),ee=u(!1),ne=()=>{N.value||Q.isConnected||(N.value=window.setInterval(X,3e4))},ie=()=>{N.value&&(clearInterval(N.value),N.value=null)},T=()=>{const e=document.documentElement.classList.contains("dark");return{gridColor:e?"rgba(255, 255, 255, 0.1)":"rgba(0, 0, 0, 0.1)",tickColor:e?"rgba(255, 255, 255, 0.7)":"rgba(0, 0, 0, 0.7)",legendColor:e?"rgba(255, 255, 255, 0.8)":"rgba(0, 0, 0, 0.8)",titleColor:e?"rgba(255, 255, 255, 0.8)":"rgba(0, 0, 0, 0.8)"}},v=u(Ie("statistics_selectedHours",24)),be=[{value:1,label:"1 Hour"},{value:6,label:"6 Hours"},{value:12,label:"12 Hours"},{value:24,label:"24 Hours"},{value:48,label:"2 Days"},{value:168,label:"1 Week"}];pe(v,e=>Xe("statistics_selectedHours",e));const R=u(null),O=u(null),U=u([]),D=u(null),te=u([]),j=u([]),$=u(!0),K=u(null),C=u({packetRate:!0,packetType:!0,noiseFloor:!1,routePie:!0,sparklines:!0}),L=u(!1),V=u(!1),z=u(!1),_=u(null),w=u(null),y=u(null),I=u(null),ae=u(null),se=u(null),E=u(null),ce=me(()=>{const e=A.packetStats;return e?{totalRx:e.total_packets||0,totalTx:e.transmitted_packets||0}:{totalRx:0,totalTx:0}}),re=(e,t)=>{if(e.length===0)return[];const o=Math.round(t*60*60*1e3/72),r=new Map;return e.forEach(([i,g])=>{let f=i;i>1e15?f=i/1e3:i>1e9&&i<1e12&&(f=i*1e3);const S=Math.floor(f/o)*o;r.has(S)||r.set(S,[]),r.get(S).push(g)}),Array.from(r.entries()).sort((i,g)=>i[0]-g[0]).map(([,i])=>i.reduce((g,f)=>g+f,0)/i.length)},oe=me(()=>{let e=[],t=[];if(R.value?.series){const s=R.value.series.find(r=>r.type==="rx_count"),o=R.value.series.find(r=>r.type==="tx_count");s?.data&&(e=re(s.data,v.value)),o?.data&&(t=re(o.data,v.value))}return{totalPackets:e,transmittedPackets:t,droppedPackets:[],crcErrors:re(j.value.map(s=>[s.timestamp>1e12?s.timestamp:s.timestamp*1e3,s.count]),v.value)}}),X=async()=>{try{$.value=!0,K.value=null,await Promise.all([A.fetchPacketStats({hours:v.value}),A.fetchSystemStats()]),$.value=!1,ye()}catch(e){K.value=e instanceof Error?e.message:"Failed to fetch data",$.value=!1}},ye=async()=>{C.value={packetRate:!0,packetType:!0,noiseFloor:!0,routePie:!0,sparklines:!0};const e=[he(),ke(),Ce(),_e(),we()];try{await Promise.allSettled(e),await ve(),!I.value||!ae.value?setTimeout(()=>{de()},100):de()}catch(t){console.error("Error loading chart data:",t)}},he=async()=>{try{const e=await H.get("/metrics_graph_data",{hours:v.value,resolution:"average",metrics:"rx_count,tx_count"});e?.success&&(R.value=e.data)}catch{R.value=null}},ke=async()=>{try{const e=await H.get("/packet_type_graph_data",{hours:v.value,resolution:"average",types:"all"});if(e?.success&&e.data){const t=e.data;U.value=t.series||[]}}catch{U.value=[]}},Ce=async()=>{try{const e=await H.get("/route_stats",{hours:v.value});e?.success&&e.data&&(D.value=e.data)}catch{D.value=null}},_e=async()=>{try{const e={hours:v.value},t=await H.get("/noise_floor_history",e);if(t.success&&t.data){const o=t.data.history||[];Array.isArray(o)&&o.length>0&&(O.value={chart_data:o.map(r=>({timestamp:r.timestamp||Date.now()/1e3,noise_floor_dbm:r.noise_floor_dbm||r.noise_floor||-120}))},Te())}}catch{O.value={chart_data:[]}}},we=async()=>{try{const e=await H.get("/crc_error_history",{hours:v.value});if(e?.success&&e.data){const t=e.data;j.value=t.history||[]}}catch{j.value=[]}},Se=()=>{C.value={packetRate:!0,packetType:!0,noiseFloor:!0,routePie:!0,sparklines:!0},ue(),L.value=!1,V.value=!1,z.value=!1,X()},Te=()=>{if(te.value=[],O.value?.chart_data&&O.value.chart_data.length>0){const e=O.value.chart_data;te.value=e.map(t=>({timestamp:t.timestamp*1e3,snr:null,rssi:null,noiseFloor:t.noise_floor_dbm}))}},de=()=>{if(!ee.value){ee.value=!0;try{Re(),De(),Ee(),Fe(),setTimeout(()=>{C.value={packetRate:!1,packetType:!1,noiseFloor:!1,routePie:!1,sparklines:!1},setTimeout(()=>{const e=M(_.value),t=M(w.value),s=M(y.value);e&&e.update("none"),t&&t.update("none"),s&&s.update("none")},50)},100)}catch(e){console.error("Error creating/updating charts:",e),ue()}finally{ee.value=!1}}},ue=()=>{try{_.value&&(_.value.destroy(),_.value=null),w.value&&(w.value.destroy(),w.value=null),y.value&&(y.value.destroy(),y.value=null),E.value&&J.purge(E.value)}catch(e){console.error("Error destroying charts:",e)}},Re=()=>{if(!I.value)return;const e=I.value.getContext("2d");if(!e)return;let t=[],s=[];if(R.value?.series){const p=R.value.series.find(c=>c.type==="rx_count"),b=R.value.series.find(c=>c.type==="tx_count");p?.data&&(t=p.data.map(([c,d])=>{let n=c;return c>1e15?n=c/1e3:c>1e12?n=c:c>1e9?n=c*1e3:n=Date.now(),{x:n,y:d}})),b?.data&&(s=b.data.map(([c,d])=>{let n=c;return c>1e15?n=c/1e3:c>1e12?n=c:c>1e9?n=c*1e3:n=Date.now(),{x:n,y:d}}))}if(t.length===0&&s.length===0){L.value=!0;return}L.value=!1,_.value&&(_.value.destroy(),_.value=null);const r=Math.round(v.value*60*60*1e3/72),i=p=>{if(p.length===0)return[];const b=new Map;return p.forEach(d=>{const n=Math.floor(d.x/r)*r;b.has(n)||b.set(n,[]),b.get(n).push(d.y)}),Array.from(b.entries()).map(([d,n])=>({x:d,y:n.reduce((F,W)=>F+W,0)/n.length})).sort((d,n)=>d.x-n.x)},g=(p,b=3)=>{if(p.lengthAe+Ne.y,0)/W.length;c.push({x:p[d].x,y:Be})}return c},f=g(i(t)),S=g(i(s)),P=[...f.map(p=>p.y),...S.map(p=>p.y)],l=Math.min(...P),m=Math.max(...P),x=m-l||m*.1||.001,Me=Math.max(0,l-x*.05),Pe=m+x*.05;try{const p=JSON.parse(JSON.stringify(f)),b=JSON.parse(JSON.stringify(S)),c=new q(e,{type:"line",data:{datasets:[{label:"TX/hr",data:b,borderColor:"#F59E0B",backgroundColor:"#F59E0B",borderWidth:2,fill:"origin",tension:.4,pointRadius:0,pointHoverRadius:3,order:1},{label:"RX/hr",data:p,borderColor:"#C084FC",backgroundColor:"#C084FC",borderWidth:2,fill:"origin",tension:.4,pointRadius:0,pointHoverRadius:3,order:2}]},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},interaction:{mode:"index",intersect:!1},plugins:{legend:{display:!1},title:{display:!1},tooltip:{enabled:!0,backgroundColor:"rgba(0, 0, 0, 0.8)",titleColor:"rgba(255, 255, 255, 0.9)",bodyColor:"rgba(255, 255, 255, 0.8)",borderColor:"rgba(255, 255, 255, 0.2)",borderWidth:1,padding:12,displayColors:!0,callbacks:{title:function(d){const n=d[0]?.parsed?.x;return n==null?"":new Date(n).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})},label:function(d){const n=d.dataset?.label||"",F=d.parsed?.y;return F==null?n:`${n}: ${F.toFixed(3)}`}}}},scales:{x:{type:"time",time:{unit:"hour",displayFormats:{hour:"HH:mm"}},min:Date.now()-v.value*3600*1e3,max:Date.now(),grid:{color:T().gridColor},ticks:{color:T().tickColor,maxTicksLimit:8}},y:{beginAtZero:!1,grid:{color:T().gridColor},ticks:{color:T().tickColor,callback:function(d){return typeof d=="number"?d.toFixed(3):d}},min:Me,max:Pe}}}});_.value=le(c)}catch(p){console.error("Error creating packet rate chart:",p),L.value=!0}},De=()=>{if(!ae.value)return;const e=ae.value.getContext("2d");if(!e)return;const t=[],s=[],o=["#60A5FA","#34D399","#FBBF24","#A78BFA","#F87171","#06B6D4","#84CC16","#F472B6","#10B981"];if(U.value.length>0)U.value.forEach(r=>{const i=r.data?r.data.reduce((g,f)=>g+f[1],0):0;i>0&&(t.push(r.name.replace(/\([^)]*\)/g,"").trim()),s.push(i))});else{V.value=!0;return}V.value=!1,w.value&&(w.value.destroy(),w.value=null);try{const r=JSON.parse(JSON.stringify(t)),i=JSON.parse(JSON.stringify(s)),g=new q(e,{type:"bar",data:{labels:r,datasets:[{data:i,backgroundColor:o.slice(0,i.length),borderRadius:8,borderSkipped:!1}]},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},plugins:{legend:{display:!1}},scales:{x:{grid:{display:!1},ticks:{color:"rgba(255, 255, 255, 0.7)",font:{size:10}}},y:{beginAtZero:!0,grid:{color:"rgba(255, 255, 255, 0.1)"},ticks:{color:"rgba(255, 255, 255, 0.7)"}}}}});w.value=le(g)}catch(r){console.error("Error creating packet type chart:",r),V.value=!0}},Ee=()=>{if(!se.value)return;const e=se.value.getContext("2d");if(!e)return;const t=te.value.map(l=>({x:l.timestamp,y:l.noiseFloor})).filter(l=>l.y!==null&&l.y!==void 0),s=t.map(l=>l.y),o=s.length>0?Math.min(...s):-120,r=s.length>0?Math.max(...s):-110,i=r-o||1,g=o-i*.05,f=r+i*.05;if(y.value)try{const l=M(y.value),m=JSON.parse(JSON.stringify(t));l.data.datasets[0]&&(l.data.datasets[0].data=m),l.options?.scales?.x&&(l.options.scales.x.min=Date.now()-v.value*3600*1e3,l.options.scales.x.max=Date.now()),l.update("active");return}catch{y.value.destroy(),y.value=null}const S=JSON.parse(JSON.stringify(t)),P=new q(e,{type:"scatter",data:{datasets:[{label:"Noise Floor (dBm)",data:S,borderWidth:0,backgroundColor:"rgba(245, 158, 11, 0.8)",pointRadius:3,pointHoverRadius:5,pointStyle:"circle"}]},options:{responsive:!0,maintainAspectRatio:!1,animation:{duration:0},interaction:{mode:"index",intersect:!1},plugins:{legend:{display:!0,position:"top",labels:{color:T().legendColor,usePointStyle:!0,padding:20}},tooltip:{enabled:!0,backgroundColor:"rgba(0, 0, 0, 0.8)",titleColor:"rgba(255, 255, 255, 0.9)",bodyColor:"rgba(255, 255, 255, 0.8)",borderColor:"rgba(255, 255, 255, 0.2)",borderWidth:1,padding:12,displayColors:!0,callbacks:{title:function(l){const m=l[0]?.parsed?.x;return m==null?"":new Date(m).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"})},label:function(l){const m=l.dataset?.label||"",x=l.parsed?.y;return x==null?m:`${m}: ${x.toFixed(1)} dBm`}}}},scales:{x:{type:"time",time:{unit:"hour",displayFormats:{hour:"HH:mm"}},min:Date.now()-v.value*3600*1e3,max:Date.now(),grid:{color:T().gridColor},ticks:{color:T().tickColor,maxTicksLimit:8}},y:{type:"linear",display:!0,title:{display:!0,text:"Noise Floor (dBm)",color:T().titleColor},grid:{color:"rgba(245, 158, 11, 0.2)"},ticks:{color:"#F59E0B",callback:function(l){return typeof l=="number"?l.toFixed(1):l}},min:g,max:f}}}});y.value=le(P)},Fe=()=>{if(!E.value)return;if(!D.value||!D.value.route_totals){z.value=!0;return}z.value=!1;const e=D.value.route_totals,t=Object.keys(e),s=Object.values(e),o=["#3B82F6","#10B981","#F59E0B","#A78BFA","#F87171"];try{const r=JSON.parse(JSON.stringify(t)),i=JSON.parse(JSON.stringify(s)),g=i.reduce((m,x)=>m+x,0),f=i.map(m=>m/g*100),S=r.map((m,x)=>({type:"bar",name:m,x:[f[x]],y:[""],orientation:"h",marker:{color:o[x%o.length]},text:f[x]>=5?`${m} ${f[x].toFixed(0)}%`:"",textposition:"inside",textfont:{color:"white",size:11},hoverinfo:"none",insidetextanchor:"middle"})),P={paper_bgcolor:"rgba(0,0,0,0)",plot_bgcolor:"rgba(0,0,0,0)",font:{color:"rgba(255, 255, 255, 0.8)",size:11},margin:{t:10,b:60,l:10,r:10},barmode:"stack",showlegend:!0,legend:{orientation:"h",x:0,y:-.3,xanchor:"left",font:{color:"rgba(255, 255, 255, 0.8)",size:10}},xaxis:{showgrid:!1,showticklabels:!1,zeroline:!1,range:[0,100]},yaxis:{showgrid:!1,showticklabels:!1,zeroline:!1},hovermode:!1,bargap:0},l={responsive:!0,displayModeBar:!1,staticPlot:!0};J.newPlot(E.value,S,P,l)}catch(r){console.error("Error creating route treemap chart:",r),z.value=!0}};return He(async()=>{await ve(),X(),Q.isConnected||ne(),pe(()=>Q.isConnected,e=>{e?ie():ne()}),window.addEventListener("resize",()=>{setTimeout(()=>{M(_.value)?.resize(),M(w.value)?.resize(),M(y.value)?.resize(),E.value&&J.Plots&&J.Plots.resize(E.value)},100)})}),Je(()=>{ie(),_.value?.destroy(),w.value?.destroy(),y.value?.destroy(),E.value&&J.purge(E.value),window.removeEventListener("resize",()=>{})}),(e,t)=>(k(),h("div",ct,[a("div",dt,[t[2]||(t[2]=a("h2",{class:"text-xl sm:text-2xl font-bold text-content-primary dark:text-content-primary"},"Statistics",-1)),a("div",ut,[t[1]||(t[1]=a("label",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Time Range:",-1)),Ue(a("select",{"onUpdate:modelValue":t[0]||(t[0]=s=>v.value=s),onChange:Se,class:"bg-white dark:bg-white/10 border border-stroke-subtle dark:border-stroke/20 rounded-lg px-2 sm:px-3 py-1.5 sm:py-2 text-content-primary dark:text-content-primary text-xs sm:text-sm focus:outline-hidden focus:border-primary dark:focus:border-accent-purple/50 transition-colors"},[(k(),h(ge,null,fe(be,s=>a("option",{key:s.value,value:s.value,class:"bg-white dark:bg-gray-800 text-content-primary dark:text-content-primary"},Y(s.label),9,pt)),64))],544),[[je,v.value]])])]),a("div",mt,[G(Z,{title:"Total RX",value:ce.value.totalRx,color:"#AAE8E8",data:oe.value.totalPackets,loading:C.value.sparklines,variant:"classic"},null,8,["value","data","loading"]),G(Z,{title:"Total TX",value:ce.value.totalTx,color:"#FFC246",data:oe.value.transmittedPackets,loading:C.value.sparklines,variant:"classic"},null,8,["value","data","loading"]),G(Z,{title:"CRC Errors",value:j.value.reduce((s,o)=>s+o.count,0),color:"#F59E0B",data:oe.value.crcErrors,loading:C.value.sparklines,variant:"classic"},null,8,["value","data","loading"]),G(Z,{title:"Packet Hash Cache",value:xe(A).systemStats?.duplicate_cache_size??0,color:"#9F7AEA",data:[],loading:!1,variant:"smooth",subtitle:`Entries expire after ${(()=>{const s=xe(A).systemStats?.cache_ttl??3600,o=Math.floor(s/60);return o>=60?`${Math.floor(o/60)}h`:`${o}m`})()}`},null,8,["value","subtitle"])]),a("div",vt,[t[6]||(t[6]=a("h3",{class:"text-content-primary dark:text-content-primary text-lg sm:text-xl font-semibold mb-3 sm:mb-4"},"Performance Metrics",-1)),a("div",null,[t[5]||(t[5]=$e('

Packet Rate (RX/TX PER HOUR)

RX/hr
TX/hr
',2)),a("div",gt,[a("canvas",{ref_key:"packetRateCanvasRef",ref:I,class:"w-full h-full relative z-10"},null,512),C.value.packetRate?(k(),h("div",ft,t[3]||(t[3]=[a("div",{class:"text-center"},[a("div",{class:"animate-spin w-6 h-6 sm:w-8 sm:h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-purple-600 dark:border-t-purple-400 rounded-full mx-auto mb-2"}),a("div",{class:"text-content-secondary dark:text-content-muted text-[10px] sm:text-xs"},"Loading packet rate data...")],-1)]))):B("",!0),L.value&&!C.value.packetRate?(k(),h("div",xt,t[4]||(t[4]=[a("div",{class:"text-center"},[a("div",{class:"text-red-700 dark:text-red-400 text-sm font-semibold mb-1"},"No Data Available"),a("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Packet rate data not found")],-1)]))):B("",!0)])])]),a("div",bt,[a("div",yt,[t[8]||(t[8]=a("h3",{class:"text-content-primary dark:text-content-primary text-lg sm:text-xl font-semibold mb-3 sm:mb-4"}," Noise Floor Over Time ",-1)),a("div",ht,[a("canvas",{ref_key:"signalMetricsCanvasRef",ref:se,class:"w-full h-full"},null,512),C.value.noiseFloor?(k(),h("div",kt,t[7]||(t[7]=[a("div",{class:"text-center"},[a("div",{class:"animate-spin w-6 h-6 sm:w-8 sm:h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-amber-600 dark:border-t-amber-400 rounded-full mx-auto mb-2"}),a("div",{class:"text-content-secondary dark:text-content-muted text-[10px] sm:text-xs"},"Loading noise floor data...")],-1)]))):B("",!0)])]),a("div",Ct,[t[11]||(t[11]=a("h3",{class:"text-content-primary dark:text-content-primary text-lg sm:text-xl font-semibold mb-3 sm:mb-4"},"Route Distribution",-1)),a("div",_t,[C.value.routePie?(k(),h("div",wt,t[9]||(t[9]=[a("div",{class:"text-center"},[a("div",{class:"animate-spin w-6 h-6 border-2 border-stroke-subtle dark:border-stroke/20 border-t-green-600 dark:border-t-green-400 rounded-full mx-auto mb-2"}),a("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Loading route data...")],-1)]))):z.value?(k(),h("div",St,t[10]||(t[10]=[a("div",{class:"text-center"},[a("div",{class:"text-red-700 dark:text-red-400 text-sm font-semibold mb-1"},"No Data Available"),a("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Route statistics not found")],-1)]))):D.value?.route_totals?(k(!0),h(ge,{key:2},fe(D.value.route_totals,(s,o,r)=>(k(),h("div",{key:o,class:"flex items-center gap-3"},[a("div",Tt,Y(o),1),a("div",Rt,[a("div",{class:"h-full rounded transition-all duration-300",style:Ke({width:`${s/Math.max(...Object.values(D.value.route_totals))*100}%`,backgroundColor:["#3B82F6","#10B981","#F59E0B","#A78BFA","#F87171"][r%5]})},null,4)]),a("div",Dt,Y(s.toLocaleString()),1)]))),128)):B("",!0)])])]),$.value?(k(),h("div",Et,t[12]||(t[12]=[a("div",{class:"text-content-secondary dark:text-content-muted mb-2 text-sm"},"Loading statistics...",-1),a("div",{class:"animate-spin w-6 h-6 sm:w-8 sm:h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-content-primary dark:border-t-white/70 rounded-full mx-auto"},null,-1)]))):B("",!0),K.value?(k(),h("div",Ft,[t[13]||(t[13]=a("div",{class:"text-red-700 dark:text-red-400 mb-2 text-sm font-semibold"},"Failed to load statistics",-1)),a("p",Mt,Y(K.value),1),a("button",{onClick:X,class:"mt-4 px-4 py-2 bg-primary hover:bg-primary/90 dark:bg-primary dark:hover:bg-primary/80 text-white font-medium rounded-lg border border-primary/20 dark:border-primary/30 transition-colors shadow-sm"}," Retry ")])):B("",!0)]))}}),Jt=Ve(Pt,[["__scopeId","data-v-8daccd7e"]]);export{Jt as default}; diff --git a/repeater/web/html/assets/SystemStats-SdfzbcmV.js b/repeater/web/html/assets/SystemStats-DiLdS6K6.js similarity index 99% rename from repeater/web/html/assets/SystemStats-SdfzbcmV.js rename to repeater/web/html/assets/SystemStats-DiLdS6K6.js index 0aba18e..135d54a 100644 --- a/repeater/web/html/assets/SystemStats-SdfzbcmV.js +++ b/repeater/web/html/assets/SystemStats-DiLdS6K6.js @@ -1,2 +1,2 @@ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/plotly.min-DO11Gp-n.js","assets/_commonjsHelpers-CqkleIqs.js"])))=>i.map(i=>d[i]); -import{a as ot,r as u,c as W,E as N,o as nt,R as X,S as O,H as lt,b as d,e as t,g as v,f as A,t as o,F as Y,h as G,I as K,L as Q,j as V,p as i,x as dt}from"./index-sHch0610.js";import{S as P}from"./chartjs-adapter-date-fns.esm-BnFZGz19.js";import{C as j,a as it,L as ct,P as ut,b as mt,c as vt,B as pt,D as xt,p as yt,d as gt,e as ft,A as bt,f as kt,i as ht,T as _t}from"./chart-B185MtDy.js";const Ct={class:"p-6 space-y-6"},wt={class:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"},Ft={class:"grid grid-cols-1 lg:grid-cols-2 gap-6"},St={class:"glass-card rounded-[15px] p-6"},Ut={class:"relative h-32 bg-gray-100/50 dark:bg-white/5 rounded-lg p-4 mb-4 chart-container"},Bt={key:0,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-sm z-20"},Et={key:1,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 z-20"},At={key:0,class:"grid grid-cols-2 gap-4 text-sm"},Pt={class:"text-content-primary dark:text-content-primary font-semibold"},Lt={class:"text-content-primary dark:text-content-primary font-semibold"},Mt={class:"text-content-primary dark:text-content-primary font-semibold"},Dt={class:"text-content-primary dark:text-content-primary font-semibold"},Rt={class:"glass-card rounded-[15px] p-6"},Tt={class:"relative h-32 bg-gray-100/50 dark:bg-white/5 rounded-lg p-4 mb-4 chart-container"},zt={key:0,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-sm z-20"},$t={key:1,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 z-20"},It={key:0,class:"grid grid-cols-2 gap-4 text-sm"},Nt={class:"text-content-primary dark:text-content-primary font-semibold"},Ot={class:"text-content-primary dark:text-content-primary font-semibold"},Vt={class:"text-content-primary dark:text-content-primary font-semibold"},jt={class:"text-content-primary dark:text-content-primary font-semibold"},Ht={class:"grid grid-cols-1 lg:grid-cols-2 gap-6"},qt={class:"glass-card rounded-[15px] p-6"},Jt={class:"relative h-48"},Wt={key:0,class:"grid grid-cols-3 gap-4 text-sm mt-4"},Xt={class:"text-center"},Yt={class:"text-content-primary dark:text-content-primary font-semibold"},Gt={class:"text-center"},Kt={class:"font-semibold text-red-500 dark:text-red-400"},Qt={class:"text-center"},Zt={class:"font-semibold text-green-700 dark:text-green-400"},te={class:"glass-card rounded-[15px] p-6"},ee={key:0,class:"space-y-4"},ae={class:"grid grid-cols-2 gap-4 text-sm"},se={class:"text-content-primary dark:text-content-primary font-semibold"},re={class:"text-content-primary dark:text-content-primary font-semibold"},oe={class:"text-content-primary dark:text-content-primary font-semibold"},ne={class:"text-content-primary dark:text-content-primary font-semibold"},le={key:0,class:"pt-4 border-t border-stroke-subtle dark:border-stroke/10"},de={class:"grid grid-cols-2 gap-2 text-sm"},ie={class:"text-content-secondary dark:text-content-muted"},ce={class:"text-content-primary dark:text-content-primary font-semibold ml-1"},ue={class:"glass-card rounded-[15px] p-6"},me={key:0,class:"overflow-x-auto"},ve={class:"w-full text-sm"},pe={class:"text-content-secondary dark:text-content-primary/80 py-2 transition-all duration-300"},xe={class:"text-content-primary dark:text-content-primary font-semibold py-2 transition-all duration-300"},ye={class:"text-center text-orange-500 dark:text-orange-400 py-2 transition-all duration-300"},ge={class:"text-center text-green-700 dark:text-green-400 py-2 transition-all duration-300"},fe={class:"text-right text-content-secondary dark:text-content-primary/80 py-2 transition-all duration-300"},be={key:0,class:"mt-4 text-center text-content-secondary dark:text-content-muted text-sm transition-all duration-300"},ke={key:1,class:"text-center text-content-secondary dark:text-content-muted py-8"},he={key:0,class:"glass-card rounded-[15px] p-8 text-center"},_e={key:1,class:"glass-card rounded-[15px] p-8 text-center"},Ce={class:"text-content-secondary dark:text-content-muted text-sm"},we=ot({name:"SystemStatsView",__name:"SystemStats",setup(Fe){j.register(it,ct,ut,mt,vt,pt,xt,yt,gt,ft,bt,kt,ht,_t);const L=u(null),_=u(!0),C=u(null),s=u(null),b=u(null),x=u([]),M=u(null),p=u({cpuChart:!0,memoryChart:!0,diskChart:!1,processChart:!0}),D=u(!1),R=u(!1),y=u(null),g=u(null),T=u(null),z=u(null),k=u(null),B=W(()=>s.value?{cpuUsage:s.value.cpu.usage_percent,memoryUsage:s.value.memory.usage_percent,diskUsage:s.value.disk.usage_percent,uptime:s.value.system.uptime}:{cpuUsage:0,memoryUsage:0,diskUsage:0,uptime:0}),E=W(()=>x.value.length===0?{cpu:[],memory:[],disk:[],network:[]}:{cpu:x.value.map(a=>a.cpu.usage_percent),memory:x.value.map(a=>a.memory.usage_percent),disk:x.value.map(a=>a.disk.usage_percent),network:x.value.map(a=>a.network.bytes_recv/1024/1024)}),f=a=>{const e=["B","KB","MB","GB","TB"];if(a===0)return"0 B";const r=Math.floor(Math.log(a)/Math.log(1024));return parseFloat((a/Math.pow(1024,r)).toFixed(2))+" "+e[r]},Z=a=>{const e=Math.floor(a/86400),r=Math.floor(a%86400/3600),n=Math.floor(a%3600/60);return e>0?`${e}d ${r}h ${n}m`:r>0?`${r}h ${n}m`:`${n}m`},tt=async()=>{try{const a=await Q.get("/hardware_stats");if(a?.success&&a.data){const e=a.data;if(s.value=e,x.value.length===0)for(let n=0;n<12;n++)x.value.push(JSON.parse(JSON.stringify(e)));else x.value.push(e),x.value.length>20&&x.value.shift()}}catch(a){console.error("Failed to fetch hardware stats:",a),C.value="Failed to fetch hardware stats"}},et=async()=>{try{const a=await Q.get("/hardware_processes");a?.success&&a.data&&(M.value=b.value,b.value=a.data)}catch(a){console.error("Failed to fetch process stats:",a)}},$=(a,e)=>{if(!M.value)return!1;const r=M.value.processes.find(n=>n.pid===a.pid);return r?r[e]!==a[e]:!0},I=async()=>{try{_.value=!0,C.value=null,await Promise.all([tt(),et()]),_.value=!1,await N(),H()}catch(a){C.value=a instanceof Error?a.message:"Failed to fetch system data",_.value=!1}},H=()=>{s.value&&(at(),st(),rt())},at=()=>{if(!T.value||!s.value){p.value.cpuChart=!1;return}const a=T.value.getContext("2d");if(!a){p.value.cpuChart=!1;return}const e=s.value.cpu.usage_percent,r=100-e;if(y.value)try{y.value.data.datasets[0].data=[e,r],y.value.update("none");return}catch(m){console.warn("Failed to update CPU chart, recreating...",m),y.value.destroy(),y.value=null}const n=document.documentElement.classList.contains("dark"),h=n?"rgba(255, 255, 255, 0.1)":"rgba(0, 0, 0, 0.1)",w=n?"rgba(255, 255, 255, 0.2)":"rgba(0, 0, 0, 0.2)",F=n?"rgba(255, 255, 255, 0.6)":"rgba(0, 0, 0, 0.6)";try{const m=new j(a,{type:"doughnut",data:{labels:["Used","Available"],datasets:[{data:[e,r],backgroundColor:["#FFC246",h],borderColor:["#FFC246",w],borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,cutout:"70%",animation:{animateRotate:!1,animateScale:!1,duration:0},plugins:{legend:{display:!1},tooltip:{callbacks:{label:function(c){return`${c.label}: ${c.parsed.toFixed(1)}%`}}}}},plugins:[{id:"centerText",beforeDraw:function(c){const l=c.ctx;l.save();const S=(c.chartArea.left+c.chartArea.right)/2,U=(c.chartArea.top+c.chartArea.bottom)/2;l.textAlign="center",l.textBaseline="middle",l.fillStyle="#FFC246",l.font="bold 18px sans-serif",l.fillText(`${e.toFixed(1)}%`,S,U-5),l.fillStyle=F,l.font="10px sans-serif",l.fillText("CPU",S,U+12),l.restore()}}]});y.value=K(m),D.value=!1,p.value.cpuChart=!1}catch(m){console.error("Error creating CPU chart:",m),D.value=!0,p.value.cpuChart=!1}},st=()=>{if(!z.value||!s.value){p.value.memoryChart=!1;return}const a=z.value.getContext("2d");if(!a){p.value.memoryChart=!1;return}const e=s.value.memory.usage_percent,r=100-e;if(g.value)try{g.value.data.datasets[0].data=[e,r],g.value.update("none");return}catch(m){console.warn("Failed to update Memory chart, recreating...",m),g.value.destroy(),g.value=null}const n=document.documentElement.classList.contains("dark"),h=n?"rgba(255, 255, 255, 0.1)":"rgba(0, 0, 0, 0.1)",w=n?"rgba(255, 255, 255, 0.2)":"rgba(0, 0, 0, 0.2)",F=n?"rgba(255, 255, 255, 0.6)":"rgba(0, 0, 0, 0.6)";try{const m=new j(a,{type:"doughnut",data:{labels:["Used","Available"],datasets:[{data:[e,r],backgroundColor:["#A5E5B6",h],borderColor:["#A5E5B6",w],borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,cutout:"70%",animation:{animateRotate:!1,animateScale:!1,duration:0},plugins:{legend:{display:!1},tooltip:{callbacks:{label:function(c){return`${c.label}: ${c.parsed.toFixed(1)}%`}}}}},plugins:[{id:"centerText",beforeDraw:function(c){const l=c.ctx;l.save();const S=(c.chartArea.left+c.chartArea.right)/2,U=(c.chartArea.top+c.chartArea.bottom)/2;l.textAlign="center",l.textBaseline="middle",l.fillStyle="#A5E5B6",l.font="bold 18px sans-serif",l.fillText(`${e.toFixed(1)}%`,S,U-5),l.fillStyle=F,l.font="10px sans-serif",l.fillText("Memory",S,U+12),l.restore()}}]});g.value=K(m),R.value=!1,p.value.memoryChart=!1}catch(m){console.error("Error creating Memory chart:",m),R.value=!0,p.value.memoryChart=!1}},rt=()=>{if(!k.value||!s.value)return;const e=document.documentElement.classList.contains("dark")?"rgba(255, 255, 255, 0.8)":"rgba(0, 0, 0, 0.8)";try{O(()=>import("./plotly.min-DO11Gp-n.js").then(r=>r.p),__vite__mapDeps([0,1])).then(r=>{const n=r.default||r,h=s.value.disk,w=[{type:"pie",labels:["Used","Free"],values:[h.used,h.free],marker:{colors:["#FB787B","#A5E5B6"]},hovertemplate:"%{label}
Size: %{value}
Percentage: %{percent}",textinfo:"label+percent",textposition:"auto",hole:.4}],F={title:{text:"",font:{color:e}},paper_bgcolor:"rgba(0,0,0,0)",plot_bgcolor:"rgba(0,0,0,0)",font:{color:e,size:11},margin:{t:20,b:20,l:20,r:20},showlegend:!0,legend:{orientation:"h",x:0,y:-.2,font:{color:e,size:10}}},m={responsive:!0,displayModeBar:!1,staticPlot:!1};n.newPlot(k.value,w,F,m)})}catch(r){console.error("Error creating disk chart:",r)}},q=()=>{try{if(y.value&&(y.value.destroy(),y.value=null),g.value&&(g.value.destroy(),g.value=null),k.value)try{O(()=>import("./plotly.min-DO11Gp-n.js").then(a=>a.p),__vite__mapDeps([0,1])).then(a=>{const e=a?.default||a;e?.purge&&e.purge(k.value)}).catch(()=>{})}catch{}}catch(a){console.error("Error destroying charts:",a)}},J=new MutationObserver(a=>{a.forEach(e=>{e.attributeName==="class"&&(q(),N(()=>{H()}))})});return nt(async()=>{await N(),I(),L.value=window.setInterval(I,5e3),J.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]}),window.addEventListener("resize",()=>{setTimeout(()=>{X(y.value)?.resize(),X(g.value)?.resize();try{O(()=>import("./plotly.min-DO11Gp-n.js").then(a=>a.p),__vite__mapDeps([0,1])).then(a=>{const e=a?.default||a;e?.Plots&&e.Plots.resize(k.value)}).catch(()=>{})}catch{}},100)})}),lt(()=>{L.value&&clearInterval(L.value),J.disconnect(),q(),window.removeEventListener("resize",()=>{})}),(a,e)=>(i(),d("div",Ct,[e[28]||(e[28]=t("div",{class:"flex justify-between items-center"},[t("h2",{class:"text-2xl font-bold text-content-primary dark:text-content-primary"},"System Statistics"),t("div",{class:"text-content-secondary dark:text-content-muted text-sm"}," Updates every 5 seconds ")],-1)),t("div",wt,[A(P,{title:"CPU Usage",value:`${B.value.cpuUsage.toFixed(1)}%`,color:"#FFC246",data:E.value.cpu},null,8,["value","data"]),A(P,{title:"Memory Usage",value:`${B.value.memoryUsage.toFixed(1)}%`,color:"#A5E5B6",data:E.value.memory},null,8,["value","data"]),A(P,{title:"Disk Usage",value:`${B.value.diskUsage.toFixed(1)}%`,color:"#FB787B",data:E.value.disk},null,8,["value","data"]),A(P,{title:"Uptime",value:Z(B.value.uptime),color:"#EBA0FC",data:E.value.network},null,8,["value","data"])]),t("div",Ft,[t("div",St,[e[6]||(e[6]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-4"},"CPU Performance",-1)),t("div",Ut,[t("canvas",{ref_key:"cpuCanvasRef",ref:T,class:"w-full h-full relative z-10"},null,512),p.value.cpuChart?(i(),d("div",Bt,e[0]||(e[0]=[t("div",{class:"text-center"},[t("div",{class:"animate-spin w-6 h-6 border-2 border-stroke-subtle dark:border-stroke/20 border-t-orange-400 rounded-full mx-auto mb-2"}),t("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Loading CPU data...")],-1)]))):v("",!0),D.value&&!p.value.cpuChart?(i(),d("div",Et,e[1]||(e[1]=[t("div",{class:"text-center"},[t("div",{class:"text-red-500 dark:text-red-400 text-sm mb-1"},"No Data Available"),t("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"CPU data not found")],-1)]))):v("",!0)]),s.value?(i(),d("div",At,[t("div",null,[e[2]||(e[2]=t("div",{class:"text-content-secondary dark:text-content-muted"},"CPU Count",-1)),t("div",Pt,o(s.value.cpu.count)+" cores",1)]),t("div",null,[e[3]||(e[3]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Frequency",-1)),t("div",Lt,o(s.value.cpu.frequency.toFixed(0))+" MHz",1)]),t("div",null,[e[4]||(e[4]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Load (1m)",-1)),t("div",Mt,o(s.value.cpu.load_avg["1min"].toFixed(2)),1)]),t("div",null,[e[5]||(e[5]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Load (5m)",-1)),t("div",Dt,o(s.value.cpu.load_avg["5min"].toFixed(2)),1)])])):v("",!0)]),t("div",Rt,[e[13]||(e[13]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-4"},"Memory Usage",-1)),t("div",Tt,[t("canvas",{ref_key:"memoryCanvasRef",ref:z,class:"w-full h-full relative z-10"},null,512),p.value.memoryChart?(i(),d("div",zt,e[7]||(e[7]=[t("div",{class:"text-center"},[t("div",{class:"animate-spin w-6 h-6 border-2 border-stroke-subtle dark:border-stroke/20 border-t-green-400 rounded-full mx-auto mb-2"}),t("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Loading memory data...")],-1)]))):v("",!0),R.value&&!p.value.memoryChart?(i(),d("div",$t,e[8]||(e[8]=[t("div",{class:"text-center"},[t("div",{class:"text-red-500 dark:text-red-400 text-sm mb-1"},"No Data Available"),t("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Memory data not found")],-1)]))):v("",!0)]),s.value?(i(),d("div",It,[t("div",null,[e[9]||(e[9]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Total",-1)),t("div",Nt,o(f(s.value.memory.total)),1)]),t("div",null,[e[10]||(e[10]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Used",-1)),t("div",Ot,o(f(s.value.memory.used)),1)]),t("div",null,[e[11]||(e[11]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Available",-1)),t("div",Vt,o(f(s.value.memory.available)),1)]),t("div",null,[e[12]||(e[12]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Usage",-1)),t("div",jt,o(s.value.memory.usage_percent.toFixed(1))+"%",1)])])):v("",!0)])]),t("div",Ht,[t("div",qt,[e[17]||(e[17]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-4"},"Storage Usage",-1)),t("div",Jt,[t("div",{ref_key:"diskCanvasRef",ref:k,class:"w-full h-full"},null,512)]),s.value?(i(),d("div",Wt,[t("div",Xt,[e[14]||(e[14]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Total",-1)),t("div",Yt,o(f(s.value.disk.total)),1)]),t("div",Gt,[e[15]||(e[15]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Used",-1)),t("div",Kt,o(f(s.value.disk.used)),1)]),t("div",Qt,[e[16]||(e[16]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Free",-1)),t("div",Zt,o(f(s.value.disk.free)),1)])])):v("",!0)]),t("div",te,[e[23]||(e[23]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-4"},"Network Statistics",-1)),s.value?(i(),d("div",ee,[t("div",ae,[t("div",null,[e[18]||(e[18]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Bytes Sent",-1)),t("div",se,o(f(s.value.network.bytes_sent)),1)]),t("div",null,[e[19]||(e[19]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Bytes Received",-1)),t("div",re,o(f(s.value.network.bytes_recv)),1)]),t("div",null,[e[20]||(e[20]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Packets Sent",-1)),t("div",oe,o(s.value.network.packets_sent.toLocaleString()),1)]),t("div",null,[e[21]||(e[21]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Packets Received",-1)),t("div",ne,o(s.value.network.packets_recv.toLocaleString()),1)])]),s.value.temperatures&&Object.keys(s.value.temperatures).length>0?(i(),d("div",le,[e[22]||(e[22]=t("div",{class:"text-content-secondary dark:text-content-muted mb-2"},"System Temperatures",-1)),t("div",de,[(i(!0),d(Y,null,G(s.value.temperatures,(r,n)=>(i(),d("div",{key:n},[t("span",ie,o(n)+":",1),t("span",ce,o(r.toFixed(1))+"°C",1)]))),128))])])):v("",!0)])):v("",!0)])]),t("div",ue,[e[25]||(e[25]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-4"},"Top Processes",-1)),b.value?.processes&&b.value.processes.length>0?(i(),d("div",me,[t("table",ve,[e[24]||(e[24]=t("thead",null,[t("tr",{class:"border-b border-stroke-subtle dark:border-stroke/10"},[t("th",{class:"text-left text-content-secondary dark:text-content-muted py-2"},"PID"),t("th",{class:"text-left text-content-secondary dark:text-content-muted py-2"},"Name"),t("th",{class:"text-center text-content-secondary dark:text-content-muted py-2"},"CPU %"),t("th",{class:"text-center text-content-secondary dark:text-content-muted py-2"},"Memory %"),t("th",{class:"text-right text-content-secondary dark:text-content-muted py-2"},"Memory")])],-1)),t("tbody",null,[(i(!0),d(Y,null,G(b.value.processes.slice(0,10),r=>(i(),d("tr",{key:r.pid,class:"border-b border-stroke-subtle dark:border-white/5 process-row"},[t("td",pe,o(r.pid),1),t("td",xe,o(r.name),1),t("td",ye,[t("span",{class:V(["cpu-value",{"value-updated":$(r,"cpu_percent")}])},o(r.cpu_percent.toFixed(1))+"% ",3)]),t("td",ge,[t("span",{class:V(["memory-value",{"value-updated":$(r,"memory_percent")}])},o(r.memory_percent.toFixed(1))+"% ",3)]),t("td",fe,[t("span",{class:V({"value-updated":$(r,"memory_mb")})},o(r.memory_mb.toFixed(1))+" MB ",3)])]))),128))])]),b.value.total_processes?(i(),d("div",be," Showing top 10 of "+o(b.value.total_processes)+" total processes ",1)):v("",!0)])):_.value?v("",!0):(i(),d("div",ke," No process data available "))]),_.value?(i(),d("div",he,e[26]||(e[26]=[t("div",{class:"text-content-secondary dark:text-content-muted mb-2"},"Loading system statistics...",-1),t("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-gray-900 dark:border-t-white/70 rounded-full mx-auto"},null,-1)]))):v("",!0),C.value?(i(),d("div",_e,[e[27]||(e[27]=t("div",{class:"text-red-500 dark:text-red-400 mb-2"},"Failed to load system statistics",-1)),t("p",Ce,o(C.value),1),t("button",{onClick:I,class:"mt-4 px-4 py-2 bg-purple-500/20 dark:bg-accent-purple/20 hover:bg-purple-500/30 dark:hover:bg-accent-purple/30 text-content-primary dark:text-content-primary rounded-lg border border-purple-500/50 dark:border-accent-purple/50 transition-colors"}," Retry ")])):v("",!0)]))}}),Ee=dt(we,[["__scopeId","data-v-eab6d04d"]]);export{Ee as default}; +import{a as ot,r as u,c as W,E as N,o as nt,R as X,S as O,H as lt,b as d,e as t,g as v,f as A,t as o,F as Y,h as G,I as K,L as Q,j as V,p as i,x as dt}from"./index-DyUIpN7m.js";import{S as P}from"./chartjs-adapter-date-fns.esm-BYg_FBhT.js";import{C as j,a as it,L as ct,P as ut,b as mt,c as vt,B as pt,D as xt,p as yt,d as gt,e as ft,A as bt,f as kt,i as ht,T as _t}from"./chart-B185MtDy.js";const Ct={class:"p-6 space-y-6"},wt={class:"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4"},Ft={class:"grid grid-cols-1 lg:grid-cols-2 gap-6"},St={class:"glass-card rounded-[15px] p-6"},Ut={class:"relative h-32 bg-gray-100/50 dark:bg-white/5 rounded-lg p-4 mb-4 chart-container"},Bt={key:0,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-sm z-20"},Et={key:1,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 z-20"},At={key:0,class:"grid grid-cols-2 gap-4 text-sm"},Pt={class:"text-content-primary dark:text-content-primary font-semibold"},Lt={class:"text-content-primary dark:text-content-primary font-semibold"},Mt={class:"text-content-primary dark:text-content-primary font-semibold"},Dt={class:"text-content-primary dark:text-content-primary font-semibold"},Rt={class:"glass-card rounded-[15px] p-6"},Tt={class:"relative h-32 bg-gray-100/50 dark:bg-white/5 rounded-lg p-4 mb-4 chart-container"},zt={key:0,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-sm z-20"},$t={key:1,class:"absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 z-20"},It={key:0,class:"grid grid-cols-2 gap-4 text-sm"},Nt={class:"text-content-primary dark:text-content-primary font-semibold"},Ot={class:"text-content-primary dark:text-content-primary font-semibold"},Vt={class:"text-content-primary dark:text-content-primary font-semibold"},jt={class:"text-content-primary dark:text-content-primary font-semibold"},Ht={class:"grid grid-cols-1 lg:grid-cols-2 gap-6"},qt={class:"glass-card rounded-[15px] p-6"},Jt={class:"relative h-48"},Wt={key:0,class:"grid grid-cols-3 gap-4 text-sm mt-4"},Xt={class:"text-center"},Yt={class:"text-content-primary dark:text-content-primary font-semibold"},Gt={class:"text-center"},Kt={class:"font-semibold text-red-500 dark:text-red-400"},Qt={class:"text-center"},Zt={class:"font-semibold text-green-700 dark:text-green-400"},te={class:"glass-card rounded-[15px] p-6"},ee={key:0,class:"space-y-4"},ae={class:"grid grid-cols-2 gap-4 text-sm"},se={class:"text-content-primary dark:text-content-primary font-semibold"},re={class:"text-content-primary dark:text-content-primary font-semibold"},oe={class:"text-content-primary dark:text-content-primary font-semibold"},ne={class:"text-content-primary dark:text-content-primary font-semibold"},le={key:0,class:"pt-4 border-t border-stroke-subtle dark:border-stroke/10"},de={class:"grid grid-cols-2 gap-2 text-sm"},ie={class:"text-content-secondary dark:text-content-muted"},ce={class:"text-content-primary dark:text-content-primary font-semibold ml-1"},ue={class:"glass-card rounded-[15px] p-6"},me={key:0,class:"overflow-x-auto"},ve={class:"w-full text-sm"},pe={class:"text-content-secondary dark:text-content-primary/80 py-2 transition-all duration-300"},xe={class:"text-content-primary dark:text-content-primary font-semibold py-2 transition-all duration-300"},ye={class:"text-center text-orange-500 dark:text-orange-400 py-2 transition-all duration-300"},ge={class:"text-center text-green-700 dark:text-green-400 py-2 transition-all duration-300"},fe={class:"text-right text-content-secondary dark:text-content-primary/80 py-2 transition-all duration-300"},be={key:0,class:"mt-4 text-center text-content-secondary dark:text-content-muted text-sm transition-all duration-300"},ke={key:1,class:"text-center text-content-secondary dark:text-content-muted py-8"},he={key:0,class:"glass-card rounded-[15px] p-8 text-center"},_e={key:1,class:"glass-card rounded-[15px] p-8 text-center"},Ce={class:"text-content-secondary dark:text-content-muted text-sm"},we=ot({name:"SystemStatsView",__name:"SystemStats",setup(Fe){j.register(it,ct,ut,mt,vt,pt,xt,yt,gt,ft,bt,kt,ht,_t);const L=u(null),_=u(!0),C=u(null),s=u(null),b=u(null),x=u([]),M=u(null),p=u({cpuChart:!0,memoryChart:!0,diskChart:!1,processChart:!0}),D=u(!1),R=u(!1),y=u(null),g=u(null),T=u(null),z=u(null),k=u(null),B=W(()=>s.value?{cpuUsage:s.value.cpu.usage_percent,memoryUsage:s.value.memory.usage_percent,diskUsage:s.value.disk.usage_percent,uptime:s.value.system.uptime}:{cpuUsage:0,memoryUsage:0,diskUsage:0,uptime:0}),E=W(()=>x.value.length===0?{cpu:[],memory:[],disk:[],network:[]}:{cpu:x.value.map(a=>a.cpu.usage_percent),memory:x.value.map(a=>a.memory.usage_percent),disk:x.value.map(a=>a.disk.usage_percent),network:x.value.map(a=>a.network.bytes_recv/1024/1024)}),f=a=>{const e=["B","KB","MB","GB","TB"];if(a===0)return"0 B";const r=Math.floor(Math.log(a)/Math.log(1024));return parseFloat((a/Math.pow(1024,r)).toFixed(2))+" "+e[r]},Z=a=>{const e=Math.floor(a/86400),r=Math.floor(a%86400/3600),n=Math.floor(a%3600/60);return e>0?`${e}d ${r}h ${n}m`:r>0?`${r}h ${n}m`:`${n}m`},tt=async()=>{try{const a=await Q.get("/hardware_stats");if(a?.success&&a.data){const e=a.data;if(s.value=e,x.value.length===0)for(let n=0;n<12;n++)x.value.push(JSON.parse(JSON.stringify(e)));else x.value.push(e),x.value.length>20&&x.value.shift()}}catch(a){console.error("Failed to fetch hardware stats:",a),C.value="Failed to fetch hardware stats"}},et=async()=>{try{const a=await Q.get("/hardware_processes");a?.success&&a.data&&(M.value=b.value,b.value=a.data)}catch(a){console.error("Failed to fetch process stats:",a)}},$=(a,e)=>{if(!M.value)return!1;const r=M.value.processes.find(n=>n.pid===a.pid);return r?r[e]!==a[e]:!0},I=async()=>{try{_.value=!0,C.value=null,await Promise.all([tt(),et()]),_.value=!1,await N(),H()}catch(a){C.value=a instanceof Error?a.message:"Failed to fetch system data",_.value=!1}},H=()=>{s.value&&(at(),st(),rt())},at=()=>{if(!T.value||!s.value){p.value.cpuChart=!1;return}const a=T.value.getContext("2d");if(!a){p.value.cpuChart=!1;return}const e=s.value.cpu.usage_percent,r=100-e;if(y.value)try{y.value.data.datasets[0].data=[e,r],y.value.update("none");return}catch(m){console.warn("Failed to update CPU chart, recreating...",m),y.value.destroy(),y.value=null}const n=document.documentElement.classList.contains("dark"),h=n?"rgba(255, 255, 255, 0.1)":"rgba(0, 0, 0, 0.1)",w=n?"rgba(255, 255, 255, 0.2)":"rgba(0, 0, 0, 0.2)",F=n?"rgba(255, 255, 255, 0.6)":"rgba(0, 0, 0, 0.6)";try{const m=new j(a,{type:"doughnut",data:{labels:["Used","Available"],datasets:[{data:[e,r],backgroundColor:["#FFC246",h],borderColor:["#FFC246",w],borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,cutout:"70%",animation:{animateRotate:!1,animateScale:!1,duration:0},plugins:{legend:{display:!1},tooltip:{callbacks:{label:function(c){return`${c.label}: ${c.parsed.toFixed(1)}%`}}}}},plugins:[{id:"centerText",beforeDraw:function(c){const l=c.ctx;l.save();const S=(c.chartArea.left+c.chartArea.right)/2,U=(c.chartArea.top+c.chartArea.bottom)/2;l.textAlign="center",l.textBaseline="middle",l.fillStyle="#FFC246",l.font="bold 18px sans-serif",l.fillText(`${e.toFixed(1)}%`,S,U-5),l.fillStyle=F,l.font="10px sans-serif",l.fillText("CPU",S,U+12),l.restore()}}]});y.value=K(m),D.value=!1,p.value.cpuChart=!1}catch(m){console.error("Error creating CPU chart:",m),D.value=!0,p.value.cpuChart=!1}},st=()=>{if(!z.value||!s.value){p.value.memoryChart=!1;return}const a=z.value.getContext("2d");if(!a){p.value.memoryChart=!1;return}const e=s.value.memory.usage_percent,r=100-e;if(g.value)try{g.value.data.datasets[0].data=[e,r],g.value.update("none");return}catch(m){console.warn("Failed to update Memory chart, recreating...",m),g.value.destroy(),g.value=null}const n=document.documentElement.classList.contains("dark"),h=n?"rgba(255, 255, 255, 0.1)":"rgba(0, 0, 0, 0.1)",w=n?"rgba(255, 255, 255, 0.2)":"rgba(0, 0, 0, 0.2)",F=n?"rgba(255, 255, 255, 0.6)":"rgba(0, 0, 0, 0.6)";try{const m=new j(a,{type:"doughnut",data:{labels:["Used","Available"],datasets:[{data:[e,r],backgroundColor:["#A5E5B6",h],borderColor:["#A5E5B6",w],borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,cutout:"70%",animation:{animateRotate:!1,animateScale:!1,duration:0},plugins:{legend:{display:!1},tooltip:{callbacks:{label:function(c){return`${c.label}: ${c.parsed.toFixed(1)}%`}}}}},plugins:[{id:"centerText",beforeDraw:function(c){const l=c.ctx;l.save();const S=(c.chartArea.left+c.chartArea.right)/2,U=(c.chartArea.top+c.chartArea.bottom)/2;l.textAlign="center",l.textBaseline="middle",l.fillStyle="#A5E5B6",l.font="bold 18px sans-serif",l.fillText(`${e.toFixed(1)}%`,S,U-5),l.fillStyle=F,l.font="10px sans-serif",l.fillText("Memory",S,U+12),l.restore()}}]});g.value=K(m),R.value=!1,p.value.memoryChart=!1}catch(m){console.error("Error creating Memory chart:",m),R.value=!0,p.value.memoryChart=!1}},rt=()=>{if(!k.value||!s.value)return;const e=document.documentElement.classList.contains("dark")?"rgba(255, 255, 255, 0.8)":"rgba(0, 0, 0, 0.8)";try{O(()=>import("./plotly.min-DO11Gp-n.js").then(r=>r.p),__vite__mapDeps([0,1])).then(r=>{const n=r.default||r,h=s.value.disk,w=[{type:"pie",labels:["Used","Free"],values:[h.used,h.free],marker:{colors:["#FB787B","#A5E5B6"]},hovertemplate:"%{label}
Size: %{value}
Percentage: %{percent}",textinfo:"label+percent",textposition:"auto",hole:.4}],F={title:{text:"",font:{color:e}},paper_bgcolor:"rgba(0,0,0,0)",plot_bgcolor:"rgba(0,0,0,0)",font:{color:e,size:11},margin:{t:20,b:20,l:20,r:20},showlegend:!0,legend:{orientation:"h",x:0,y:-.2,font:{color:e,size:10}}},m={responsive:!0,displayModeBar:!1,staticPlot:!1};n.newPlot(k.value,w,F,m)})}catch(r){console.error("Error creating disk chart:",r)}},q=()=>{try{if(y.value&&(y.value.destroy(),y.value=null),g.value&&(g.value.destroy(),g.value=null),k.value)try{O(()=>import("./plotly.min-DO11Gp-n.js").then(a=>a.p),__vite__mapDeps([0,1])).then(a=>{const e=a?.default||a;e?.purge&&e.purge(k.value)}).catch(()=>{})}catch{}}catch(a){console.error("Error destroying charts:",a)}},J=new MutationObserver(a=>{a.forEach(e=>{e.attributeName==="class"&&(q(),N(()=>{H()}))})});return nt(async()=>{await N(),I(),L.value=window.setInterval(I,5e3),J.observe(document.documentElement,{attributes:!0,attributeFilter:["class"]}),window.addEventListener("resize",()=>{setTimeout(()=>{X(y.value)?.resize(),X(g.value)?.resize();try{O(()=>import("./plotly.min-DO11Gp-n.js").then(a=>a.p),__vite__mapDeps([0,1])).then(a=>{const e=a?.default||a;e?.Plots&&e.Plots.resize(k.value)}).catch(()=>{})}catch{}},100)})}),lt(()=>{L.value&&clearInterval(L.value),J.disconnect(),q(),window.removeEventListener("resize",()=>{})}),(a,e)=>(i(),d("div",Ct,[e[28]||(e[28]=t("div",{class:"flex justify-between items-center"},[t("h2",{class:"text-2xl font-bold text-content-primary dark:text-content-primary"},"System Statistics"),t("div",{class:"text-content-secondary dark:text-content-muted text-sm"}," Updates every 5 seconds ")],-1)),t("div",wt,[A(P,{title:"CPU Usage",value:`${B.value.cpuUsage.toFixed(1)}%`,color:"#FFC246",data:E.value.cpu},null,8,["value","data"]),A(P,{title:"Memory Usage",value:`${B.value.memoryUsage.toFixed(1)}%`,color:"#A5E5B6",data:E.value.memory},null,8,["value","data"]),A(P,{title:"Disk Usage",value:`${B.value.diskUsage.toFixed(1)}%`,color:"#FB787B",data:E.value.disk},null,8,["value","data"]),A(P,{title:"Uptime",value:Z(B.value.uptime),color:"#EBA0FC",data:E.value.network},null,8,["value","data"])]),t("div",Ft,[t("div",St,[e[6]||(e[6]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-4"},"CPU Performance",-1)),t("div",Ut,[t("canvas",{ref_key:"cpuCanvasRef",ref:T,class:"w-full h-full relative z-10"},null,512),p.value.cpuChart?(i(),d("div",Bt,e[0]||(e[0]=[t("div",{class:"text-center"},[t("div",{class:"animate-spin w-6 h-6 border-2 border-stroke-subtle dark:border-stroke/20 border-t-orange-400 rounded-full mx-auto mb-2"}),t("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Loading CPU data...")],-1)]))):v("",!0),D.value&&!p.value.cpuChart?(i(),d("div",Et,e[1]||(e[1]=[t("div",{class:"text-center"},[t("div",{class:"text-red-500 dark:text-red-400 text-sm mb-1"},"No Data Available"),t("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"CPU data not found")],-1)]))):v("",!0)]),s.value?(i(),d("div",At,[t("div",null,[e[2]||(e[2]=t("div",{class:"text-content-secondary dark:text-content-muted"},"CPU Count",-1)),t("div",Pt,o(s.value.cpu.count)+" cores",1)]),t("div",null,[e[3]||(e[3]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Frequency",-1)),t("div",Lt,o(s.value.cpu.frequency.toFixed(0))+" MHz",1)]),t("div",null,[e[4]||(e[4]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Load (1m)",-1)),t("div",Mt,o(s.value.cpu.load_avg["1min"].toFixed(2)),1)]),t("div",null,[e[5]||(e[5]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Load (5m)",-1)),t("div",Dt,o(s.value.cpu.load_avg["5min"].toFixed(2)),1)])])):v("",!0)]),t("div",Rt,[e[13]||(e[13]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-4"},"Memory Usage",-1)),t("div",Tt,[t("canvas",{ref_key:"memoryCanvasRef",ref:z,class:"w-full h-full relative z-10"},null,512),p.value.memoryChart?(i(),d("div",zt,e[7]||(e[7]=[t("div",{class:"text-center"},[t("div",{class:"animate-spin w-6 h-6 border-2 border-stroke-subtle dark:border-stroke/20 border-t-green-400 rounded-full mx-auto mb-2"}),t("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Loading memory data...")],-1)]))):v("",!0),R.value&&!p.value.memoryChart?(i(),d("div",$t,e[8]||(e[8]=[t("div",{class:"text-center"},[t("div",{class:"text-red-500 dark:text-red-400 text-sm mb-1"},"No Data Available"),t("div",{class:"text-content-secondary dark:text-content-muted text-xs"},"Memory data not found")],-1)]))):v("",!0)]),s.value?(i(),d("div",It,[t("div",null,[e[9]||(e[9]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Total",-1)),t("div",Nt,o(f(s.value.memory.total)),1)]),t("div",null,[e[10]||(e[10]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Used",-1)),t("div",Ot,o(f(s.value.memory.used)),1)]),t("div",null,[e[11]||(e[11]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Available",-1)),t("div",Vt,o(f(s.value.memory.available)),1)]),t("div",null,[e[12]||(e[12]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Usage",-1)),t("div",jt,o(s.value.memory.usage_percent.toFixed(1))+"%",1)])])):v("",!0)])]),t("div",Ht,[t("div",qt,[e[17]||(e[17]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-4"},"Storage Usage",-1)),t("div",Jt,[t("div",{ref_key:"diskCanvasRef",ref:k,class:"w-full h-full"},null,512)]),s.value?(i(),d("div",Wt,[t("div",Xt,[e[14]||(e[14]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Total",-1)),t("div",Yt,o(f(s.value.disk.total)),1)]),t("div",Gt,[e[15]||(e[15]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Used",-1)),t("div",Kt,o(f(s.value.disk.used)),1)]),t("div",Qt,[e[16]||(e[16]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Free",-1)),t("div",Zt,o(f(s.value.disk.free)),1)])])):v("",!0)]),t("div",te,[e[23]||(e[23]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-4"},"Network Statistics",-1)),s.value?(i(),d("div",ee,[t("div",ae,[t("div",null,[e[18]||(e[18]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Bytes Sent",-1)),t("div",se,o(f(s.value.network.bytes_sent)),1)]),t("div",null,[e[19]||(e[19]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Bytes Received",-1)),t("div",re,o(f(s.value.network.bytes_recv)),1)]),t("div",null,[e[20]||(e[20]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Packets Sent",-1)),t("div",oe,o(s.value.network.packets_sent.toLocaleString()),1)]),t("div",null,[e[21]||(e[21]=t("div",{class:"text-content-secondary dark:text-content-muted"},"Packets Received",-1)),t("div",ne,o(s.value.network.packets_recv.toLocaleString()),1)])]),s.value.temperatures&&Object.keys(s.value.temperatures).length>0?(i(),d("div",le,[e[22]||(e[22]=t("div",{class:"text-content-secondary dark:text-content-muted mb-2"},"System Temperatures",-1)),t("div",de,[(i(!0),d(Y,null,G(s.value.temperatures,(r,n)=>(i(),d("div",{key:n},[t("span",ie,o(n)+":",1),t("span",ce,o(r.toFixed(1))+"°C",1)]))),128))])])):v("",!0)])):v("",!0)])]),t("div",ue,[e[25]||(e[25]=t("h3",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-4"},"Top Processes",-1)),b.value?.processes&&b.value.processes.length>0?(i(),d("div",me,[t("table",ve,[e[24]||(e[24]=t("thead",null,[t("tr",{class:"border-b border-stroke-subtle dark:border-stroke/10"},[t("th",{class:"text-left text-content-secondary dark:text-content-muted py-2"},"PID"),t("th",{class:"text-left text-content-secondary dark:text-content-muted py-2"},"Name"),t("th",{class:"text-center text-content-secondary dark:text-content-muted py-2"},"CPU %"),t("th",{class:"text-center text-content-secondary dark:text-content-muted py-2"},"Memory %"),t("th",{class:"text-right text-content-secondary dark:text-content-muted py-2"},"Memory")])],-1)),t("tbody",null,[(i(!0),d(Y,null,G(b.value.processes.slice(0,10),r=>(i(),d("tr",{key:r.pid,class:"border-b border-stroke-subtle dark:border-white/5 process-row"},[t("td",pe,o(r.pid),1),t("td",xe,o(r.name),1),t("td",ye,[t("span",{class:V(["cpu-value",{"value-updated":$(r,"cpu_percent")}])},o(r.cpu_percent.toFixed(1))+"% ",3)]),t("td",ge,[t("span",{class:V(["memory-value",{"value-updated":$(r,"memory_percent")}])},o(r.memory_percent.toFixed(1))+"% ",3)]),t("td",fe,[t("span",{class:V({"value-updated":$(r,"memory_mb")})},o(r.memory_mb.toFixed(1))+" MB ",3)])]))),128))])]),b.value.total_processes?(i(),d("div",be," Showing top 10 of "+o(b.value.total_processes)+" total processes ",1)):v("",!0)])):_.value?v("",!0):(i(),d("div",ke," No process data available "))]),_.value?(i(),d("div",he,e[26]||(e[26]=[t("div",{class:"text-content-secondary dark:text-content-muted mb-2"},"Loading system statistics...",-1),t("div",{class:"animate-spin w-8 h-8 border-2 border-stroke-subtle dark:border-stroke/20 border-t-gray-900 dark:border-t-white/70 rounded-full mx-auto"},null,-1)]))):v("",!0),C.value?(i(),d("div",_e,[e[27]||(e[27]=t("div",{class:"text-red-500 dark:text-red-400 mb-2"},"Failed to load system statistics",-1)),t("p",Ce,o(C.value),1),t("button",{onClick:I,class:"mt-4 px-4 py-2 bg-purple-500/20 dark:bg-accent-purple/20 hover:bg-purple-500/30 dark:hover:bg-accent-purple/30 text-content-primary dark:text-content-primary rounded-lg border border-purple-500/50 dark:border-accent-purple/50 transition-colors"}," Retry ")])):v("",!0)]))}}),Ee=dt(we,[["__scopeId","data-v-eab6d04d"]]);export{Ee as default}; diff --git a/repeater/web/html/assets/Terminal-BCiQkOZc.js b/repeater/web/html/assets/Terminal-BAoTtMQy.js similarity index 99% rename from repeater/web/html/assets/Terminal-BCiQkOZc.js rename to repeater/web/html/assets/Terminal-BAoTtMQy.js index 45079c4..971020a 100644 --- a/repeater/web/html/assets/Terminal-BCiQkOZc.js +++ b/repeater/web/html/assets/Terminal-BAoTtMQy.js @@ -1,4 +1,4 @@ -import{L as J,a as zl,r as ut,o as Hl,$ as Ul,P as Rn,D as ql,b as tt,e as Z,g as Yt,t as Is,w as Kl,v as Vl,X as Ji,j as Tn,s as jl,p as it,x as Gl}from"./index-sHch0610.js";/** +import{L as J,a as zl,r as ut,o as Hl,$ as Ul,P as Rn,D as ql,b as tt,e as Z,g as Yt,t as Is,w as Kl,v as Vl,X as Ji,j as Tn,s as jl,p as it,x as Gl}from"./index-DyUIpN7m.js";/** * Copyright (c) 2014-2024 The xterm.js authors. All rights reserved. * @license MIT * diff --git a/repeater/web/html/assets/chartjs-adapter-date-fns.esm-BnFZGz19.js b/repeater/web/html/assets/chartjs-adapter-date-fns.esm-BYg_FBhT.js similarity index 99% rename from repeater/web/html/assets/chartjs-adapter-date-fns.esm-BnFZGz19.js rename to repeater/web/html/assets/chartjs-adapter-date-fns.esm-BYg_FBhT.js index a94e041..6b7af6a 100644 --- a/repeater/web/html/assets/chartjs-adapter-date-fns.esm-BnFZGz19.js +++ b/repeater/web/html/assets/chartjs-adapter-date-fns.esm-BYg_FBhT.js @@ -1,4 +1,4 @@ -import{a as Ae,c as j,b as O,e as I,g as U,t as Z,n as ye,F as ge,p as Y,x as Ge}from"./index-sHch0610.js";import{g as Ve}from"./chart-B185MtDy.js";const ze={class:"sparkline-card"},je={class:"card-header"},Ue={class:"card-title"},Ze={key:0,class:"card-subtitle"},Je={key:0,class:"card-chart"},Ke={key:0,class:"chart-loader"},Se={key:1,class:"chart-text"},et={class:"percent-value"},tt=["id","viewBox"],nt=["d","fill"],rt=["d","stroke"],J=100,K=40,at=Ae({name:"SparklineChart",__name:"Sparkline",props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:"smooth"},loading:{type:Boolean,default:!1},centerText:{default:""},subtitle:{default:""}},setup(r){const e=r,t=i=>{if(i.length<3)return i;const d=Math.min(15,Math.max(3,Math.floor(i.length*.2))),f=[];for(let D=0;DR+H,0)/k.length)}const y=Math.min(10,f.length),T=f.length/y,N=[];for(let D=0;D!e.data||e.data.length===0?[]:e.variant==="smooth"?t(e.data):e.data),a=i=>{if(i.length<2)return"";const d=Math.max(...i),f=Math.min(...i),y=d-f||1,T=e.variant==="classic"?4:2;let N="";return i.forEach((D,P)=>{const l=P/(i.length-1)*J,w=(D-f)/y,k=T+(K-T*2)*(1-w);if(P===0)N+=`M ${l.toFixed(2)} ${k.toFixed(2)}`;else{const H=((P-1)/(i.length-1)*J+l)/2;N+=` Q ${H.toFixed(2)} ${k.toFixed(2)} ${l.toFixed(2)} ${k.toFixed(2)}`}}),N},s=j(()=>a(n.value)),o=j(()=>s.value?`${s.value} L ${J} ${K} L 0 ${K} Z`:""),c=j(()=>`sparkline-${e.title.replace(/\s+/g,"-").toLowerCase()}`);return(i,d)=>(Y(),O("div",ze,[I("div",je,[I("div",null,[I("p",Ue,Z(i.title),1),i.subtitle?(Y(),O("p",Ze,Z(i.subtitle),1)):U("",!0)]),I("span",{class:"card-value",style:ye({color:i.color})},Z(typeof i.value=="number"?i.value.toLocaleString():i.value),5)]),i.showChart?(Y(),O("div",Je,[i.loading&&i.variant==="classic"?(Y(),O("div",Ke,[I("div",{class:"loader-spinner",style:ye({borderTopColor:i.color})},null,4)])):i.centerText?(Y(),O("div",Se,[I("span",et,Z(i.centerText),1)])):(Y(),O("svg",{key:2,id:c.value,class:"chart-svg",viewBox:`0 0 ${J} ${K}`,preserveAspectRatio:"none"},[i.variant==="classic"?(Y(),O(ge,{key:0},[n.value.length>1?(Y(),O("path",{key:0,d:o.value,fill:i.color,"fill-opacity":"0.8",class:"sparkline-path"},null,8,nt)):U("",!0)],64)):(Y(),O(ge,{key:1},[n.value.length>1?(Y(),O("path",{key:0,d:s.value,stroke:i.color,"stroke-width":"2.5","stroke-linecap":"round","stroke-linejoin":"round",fill:"none",class:"sparkline-path"},null,8,rt)):U("",!0)],64))],8,tt))])):U("",!0)]))}}),Vr=Ge(at,[["__scopeId","data-v-257cbdca"]]),Te=6048e5,st=864e5,G=6e4,V=36e5,ot=1e3,pe=Symbol.for("constructDateFrom");function p(r,e){return typeof r=="function"?r(e):r&&typeof r=="object"&&pe in r?r[pe](e):r instanceof Date?new r.constructor(e):new Date(e)}function u(r,e){return p(e||r,r)}function ne(r,e,t){const n=u(r,t?.in);return isNaN(e)?p(t?.in||r,NaN):(e&&n.setDate(n.getDate()+e),n)}function ce(r,e,t){const n=u(r,t?.in);if(isNaN(e))return p(r,NaN);if(!e)return n;const a=n.getDate(),s=p(r,n.getTime());s.setMonth(n.getMonth()+e+1,0);const o=s.getDate();return a>=o?s:(n.setFullYear(s.getFullYear(),s.getMonth(),a),n)}function ue(r,e,t){return p(r,+u(r)+e)}function it(r,e,t){return ue(r,e*V)}let ct={};function F(){return ct}function W(r,e){const t=F(),n=e?.weekStartsOn??e?.locale?.options?.weekStartsOn??t.weekStartsOn??t.locale?.options?.weekStartsOn??0,a=u(r,e?.in),s=a.getDay(),o=(s=s.getTime()?n+1:t.getTime()>=c.getTime()?n:n-1}function ee(r){const e=u(r),t=new Date(Date.UTC(e.getFullYear(),e.getMonth(),e.getDate(),e.getHours(),e.getMinutes(),e.getSeconds(),e.getMilliseconds()));return t.setUTCFullYear(e.getFullYear()),+r-+t}function C(r,...e){const t=p.bind(null,e.find(n=>typeof n=="object"));return e.map(t)}function se(r,e){const t=u(r,e?.in);return t.setHours(0,0,0,0),t}function Oe(r,e,t){const[n,a]=C(t?.in,r,e),s=se(n),o=se(a),c=+s-ee(s),i=+o-ee(o);return Math.round((c-i)/st)}function ut(r,e){const t=Pe(r,e),n=p(r,0);return n.setFullYear(t,0,4),n.setHours(0,0,0,0),Q(n)}function dt(r,e,t){const n=u(r,t?.in);return n.setTime(n.getTime()+e*G),n}function lt(r,e,t){return ce(r,e*3,t)}function ft(r,e,t){return ue(r,e*1e3)}function ht(r,e,t){return ne(r,e*7,t)}function mt(r,e,t){return ce(r,e*12,t)}function A(r,e){const t=+u(r)-+u(e);return t<0?-1:t>0?1:t}function wt(r){return r instanceof Date||typeof r=="object"&&Object.prototype.toString.call(r)==="[object Date]"}function Ye(r){return!(!wt(r)&&typeof r!="number"||isNaN(+u(r)))}function yt(r,e,t){const[n,a]=C(t?.in,r,e),s=n.getFullYear()-a.getFullYear(),o=n.getMonth()-a.getMonth();return s*12+o}function gt(r,e,t){const[n,a]=C(t?.in,r,e);return n.getFullYear()-a.getFullYear()}function ve(r,e,t){const[n,a]=C(t?.in,r,e),s=be(n,a),o=Math.abs(Oe(n,a));n.setDate(n.getDate()-s*o);const c=+(be(n,a)===-s),i=s*(o-c);return i===0?0:i}function be(r,e){const t=r.getFullYear()-e.getFullYear()||r.getMonth()-e.getMonth()||r.getDate()-e.getDate()||r.getHours()-e.getHours()||r.getMinutes()-e.getMinutes()||r.getSeconds()-e.getSeconds()||r.getMilliseconds()-e.getMilliseconds();return t<0?-1:t>0?1:t}function z(r){return e=>{const n=(r?Math[r]:Math.trunc)(e);return n===0?0:n}}function pt(r,e,t){const[n,a]=C(t?.in,r,e),s=(+n-+a)/V;return z(t?.roundingMethod)(s)}function de(r,e){return+u(r)-+u(e)}function bt(r,e,t){const n=de(r,e)/G;return z(t?.roundingMethod)(n)}function _e(r,e){const t=u(r,e?.in);return t.setHours(23,59,59,999),t}function We(r,e){const t=u(r,e?.in),n=t.getMonth();return t.setFullYear(t.getFullYear(),n+1,0),t.setHours(23,59,59,999),t}function xt(r,e){const t=u(r,e?.in);return+_e(t,e)==+We(t,e)}function Ne(r,e,t){const[n,a,s]=C(t?.in,r,r,e),o=A(a,s),c=Math.abs(yt(a,s));if(c<1)return 0;a.getMonth()===1&&a.getDate()>27&&a.setDate(30),a.setMonth(a.getMonth()-o*c);let i=A(a,s)===-o;xt(n)&&c===1&&A(n,s)===1&&(i=!1);const d=o*(c-+i);return d===0?0:d}function Mt(r,e,t){const n=Ne(r,e,t)/3;return z(t?.roundingMethod)(n)}function Dt(r,e,t){const n=de(r,e)/1e3;return z(t?.roundingMethod)(n)}function kt(r,e,t){const n=ve(r,e,t)/7;return z(t?.roundingMethod)(n)}function Tt(r,e,t){const[n,a]=C(t?.in,r,e),s=A(n,a),o=Math.abs(gt(n,a));n.setFullYear(1584),a.setFullYear(1584);const c=A(n,a)===-s,i=s*(o-+c);return i===0?0:i}function Pt(r,e){const t=u(r,e?.in),n=t.getMonth(),a=n-n%3;return t.setMonth(a,1),t.setHours(0,0,0,0),t}function Ot(r,e){const t=u(r,e?.in);return t.setDate(1),t.setHours(0,0,0,0),t}function Yt(r,e){const t=u(r,e?.in),n=t.getFullYear();return t.setFullYear(n+1,0,0),t.setHours(23,59,59,999),t}function Ee(r,e){const t=u(r,e?.in);return t.setFullYear(t.getFullYear(),0,1),t.setHours(0,0,0,0),t}function vt(r,e){const t=u(r,e?.in);return t.setMinutes(59,59,999),t}function _t(r,e){const t=F(),n=t.weekStartsOn??t.locale?.options?.weekStartsOn??0,a=u(r,e?.in),s=a.getDay(),o=(s{let n;const a=Ht[r];return typeof a=="string"?n=a:e===1?n=a.one:n=a.other.replace("{{count}}",e.toString()),t?.addSuffix?t.comparison&&t.comparison>0?"in "+n:n+" ago":n};function re(r){return(e={})=>{const t=e.width?String(e.width):r.defaultWidth;return r.formats[t]||r.formats[r.defaultWidth]}}const Ft={full:"EEEE, MMMM do, y",long:"MMMM do, y",medium:"MMM d, y",short:"MM/dd/yyyy"},Ct={full:"h:mm:ss a zzzz",long:"h:mm:ss a z",medium:"h:mm:ss a",short:"h:mm a"},It={full:"{{date}} 'at' {{time}}",long:"{{date}} 'at' {{time}}",medium:"{{date}}, {{time}}",short:"{{date}}, {{time}}"},Lt={date:re({formats:Ft,defaultWidth:"full"}),time:re({formats:Ct,defaultWidth:"full"}),dateTime:re({formats:It,defaultWidth:"full"})},Qt={lastWeek:"'last' eeee 'at' p",yesterday:"'yesterday at' p",today:"'today at' p",tomorrow:"'tomorrow at' p",nextWeek:"eeee 'at' p",other:"P"},Rt=(r,e,t,n)=>Qt[r];function B(r){return(e,t)=>{const n=t?.context?String(t.context):"standalone";let a;if(n==="formatting"&&r.formattingValues){const o=r.defaultFormattingWidth||r.defaultWidth,c=t?.width?String(t.width):o;a=r.formattingValues[c]||r.formattingValues[o]}else{const o=r.defaultWidth,c=t?.width?String(t.width):r.defaultWidth;a=r.values[c]||r.values[o]}const s=r.argumentCallback?r.argumentCallback(e):e;return a[s]}}const Bt={narrow:["B","A"],abbreviated:["BC","AD"],wide:["Before Christ","Anno Domini"]},Xt={narrow:["1","2","3","4"],abbreviated:["Q1","Q2","Q3","Q4"],wide:["1st quarter","2nd quarter","3rd quarter","4th quarter"]},$t={narrow:["J","F","M","A","M","J","J","A","S","O","N","D"],abbreviated:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],wide:["January","February","March","April","May","June","July","August","September","October","November","December"]},At={narrow:["S","M","T","W","T","F","S"],short:["Su","Mo","Tu","We","Th","Fr","Sa"],abbreviated:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],wide:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},Gt={narrow:{am:"a",pm:"p",midnight:"mi",noon:"n",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},abbreviated:{am:"AM",pm:"PM",midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},wide:{am:"a.m.",pm:"p.m.",midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"}},Vt={narrow:{am:"a",pm:"p",midnight:"mi",noon:"n",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"},abbreviated:{am:"AM",pm:"PM",midnight:"midnight",noon:"noon",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"},wide:{am:"a.m.",pm:"p.m.",midnight:"midnight",noon:"noon",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"}},zt=(r,e)=>{const t=Number(r),n=t%100;if(n>20||n<10)switch(n%10){case 1:return t+"st";case 2:return t+"nd";case 3:return t+"rd"}return t+"th"},jt={ordinalNumber:zt,era:B({values:Bt,defaultWidth:"wide"}),quarter:B({values:Xt,defaultWidth:"wide",argumentCallback:r=>r-1}),month:B({values:$t,defaultWidth:"wide"}),day:B({values:At,defaultWidth:"wide"}),dayPeriod:B({values:Gt,defaultWidth:"wide",formattingValues:Vt,defaultFormattingWidth:"wide"})};function X(r){return(e,t={})=>{const n=t.width,a=n&&r.matchPatterns[n]||r.matchPatterns[r.defaultMatchWidth],s=e.match(a);if(!s)return null;const o=s[0],c=n&&r.parsePatterns[n]||r.parsePatterns[r.defaultParseWidth],i=Array.isArray(c)?Zt(c,y=>y.test(o)):Ut(c,y=>y.test(o));let d;d=r.valueCallback?r.valueCallback(i):i,d=t.valueCallback?t.valueCallback(d):d;const f=e.slice(o.length);return{value:d,rest:f}}}function Ut(r,e){for(const t in r)if(Object.prototype.hasOwnProperty.call(r,t)&&e(r[t]))return t}function Zt(r,e){for(let t=0;t{const n=e.match(r.matchPattern);if(!n)return null;const a=n[0],s=e.match(r.parsePattern);if(!s)return null;let o=r.valueCallback?r.valueCallback(s[0]):s[0];o=t.valueCallback?t.valueCallback(o):o;const c=e.slice(a.length);return{value:o,rest:c}}}const Kt=/^(\d+)(th|st|nd|rd)?/i,St=/\d+/i,en={narrow:/^(b|a)/i,abbreviated:/^(b\.?\s?c\.?|b\.?\s?c\.?\s?e\.?|a\.?\s?d\.?|c\.?\s?e\.?)/i,wide:/^(before christ|before common era|anno domini|common era)/i},tn={any:[/^b/i,/^(a|c)/i]},nn={narrow:/^[1234]/i,abbreviated:/^q[1234]/i,wide:/^[1234](th|st|nd|rd)? quarter/i},rn={any:[/1/i,/2/i,/3/i,/4/i]},an={narrow:/^[jfmasond]/i,abbreviated:/^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i,wide:/^(january|february|march|april|may|june|july|august|september|october|november|december)/i},sn={narrow:[/^j/i,/^f/i,/^m/i,/^a/i,/^m/i,/^j/i,/^j/i,/^a/i,/^s/i,/^o/i,/^n/i,/^d/i],any:[/^ja/i,/^f/i,/^mar/i,/^ap/i,/^may/i,/^jun/i,/^jul/i,/^au/i,/^s/i,/^o/i,/^n/i,/^d/i]},on={narrow:/^[smtwf]/i,short:/^(su|mo|tu|we|th|fr|sa)/i,abbreviated:/^(sun|mon|tue|wed|thu|fri|sat)/i,wide:/^(sunday|monday|tuesday|wednesday|thursday|friday|saturday)/i},cn={narrow:[/^s/i,/^m/i,/^t/i,/^w/i,/^t/i,/^f/i,/^s/i],any:[/^su/i,/^m/i,/^tu/i,/^w/i,/^th/i,/^f/i,/^sa/i]},un={narrow:/^(a|p|mi|n|(in the|at) (morning|afternoon|evening|night))/i,any:/^([ap]\.?\s?m\.?|midnight|noon|(in the|at) (morning|afternoon|evening|night))/i},dn={any:{am:/^a/i,pm:/^p/i,midnight:/^mi/i,noon:/^no/i,morning:/morning/i,afternoon:/afternoon/i,evening:/evening/i,night:/night/i}},ln={ordinalNumber:Jt({matchPattern:Kt,parsePattern:St,valueCallback:r=>parseInt(r,10)}),era:X({matchPatterns:en,defaultMatchWidth:"wide",parsePatterns:tn,defaultParseWidth:"any"}),quarter:X({matchPatterns:nn,defaultMatchWidth:"wide",parsePatterns:rn,defaultParseWidth:"any",valueCallback:r=>r+1}),month:X({matchPatterns:an,defaultMatchWidth:"wide",parsePatterns:sn,defaultParseWidth:"any"}),day:X({matchPatterns:on,defaultMatchWidth:"wide",parsePatterns:cn,defaultParseWidth:"any"}),dayPeriod:X({matchPatterns:un,defaultMatchWidth:"any",parsePatterns:dn,defaultParseWidth:"any"})},He={code:"en-US",formatDistance:qt,formatLong:Lt,formatRelative:Rt,localize:jt,match:ln,options:{weekStartsOn:0,firstWeekContainsDate:1}};function fn(r,e){const t=u(r,e?.in);return Oe(t,Ee(t))+1}function qe(r,e){const t=u(r,e?.in),n=+Q(t)-+ut(t);return Math.round(n/Te)+1}function le(r,e){const t=u(r,e?.in),n=t.getFullYear(),a=F(),s=e?.firstWeekContainsDate??e?.locale?.options?.firstWeekContainsDate??a.firstWeekContainsDate??a.locale?.options?.firstWeekContainsDate??1,o=p(e?.in||r,0);o.setFullYear(n+1,0,s),o.setHours(0,0,0,0);const c=W(o,e),i=p(e?.in||r,0);i.setFullYear(n,0,s),i.setHours(0,0,0,0);const d=W(i,e);return+t>=+c?n+1:+t>=+d?n:n-1}function hn(r,e){const t=F(),n=e?.firstWeekContainsDate??e?.locale?.options?.firstWeekContainsDate??t.firstWeekContainsDate??t.locale?.options?.firstWeekContainsDate??1,a=le(r,e),s=p(e?.in||r,0);return s.setFullYear(a,0,n),s.setHours(0,0,0,0),W(s,e)}function Fe(r,e){const t=u(r,e?.in),n=+W(t,e)-+hn(t,e);return Math.round(n/Te)+1}function m(r,e){const t=r<0?"-":"",n=Math.abs(r).toString().padStart(e,"0");return t+n}const E={y(r,e){const t=r.getFullYear(),n=t>0?t:1-t;return m(e==="yy"?n%100:n,e.length)},M(r,e){const t=r.getMonth();return e==="M"?String(t+1):m(t+1,2)},d(r,e){return m(r.getDate(),e.length)},a(r,e){const t=r.getHours()/12>=1?"pm":"am";switch(e){case"a":case"aa":return t.toUpperCase();case"aaa":return t;case"aaaaa":return t[0];case"aaaa":default:return t==="am"?"a.m.":"p.m."}},h(r,e){return m(r.getHours()%12||12,e.length)},H(r,e){return m(r.getHours(),e.length)},m(r,e){return m(r.getMinutes(),e.length)},s(r,e){return m(r.getSeconds(),e.length)},S(r,e){const t=e.length,n=r.getMilliseconds(),a=Math.trunc(n*Math.pow(10,t-3));return m(a,e.length)}},L={midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},xe={G:function(r,e,t){const n=r.getFullYear()>0?1:0;switch(e){case"G":case"GG":case"GGG":return t.era(n,{width:"abbreviated"});case"GGGGG":return t.era(n,{width:"narrow"});case"GGGG":default:return t.era(n,{width:"wide"})}},y:function(r,e,t){if(e==="yo"){const n=r.getFullYear(),a=n>0?n:1-n;return t.ordinalNumber(a,{unit:"year"})}return E.y(r,e)},Y:function(r,e,t,n){const a=le(r,n),s=a>0?a:1-a;if(e==="YY"){const o=s%100;return m(o,2)}return e==="Yo"?t.ordinalNumber(s,{unit:"year"}):m(s,e.length)},R:function(r,e){const t=Pe(r);return m(t,e.length)},u:function(r,e){const t=r.getFullYear();return m(t,e.length)},Q:function(r,e,t){const n=Math.ceil((r.getMonth()+1)/3);switch(e){case"Q":return String(n);case"QQ":return m(n,2);case"Qo":return t.ordinalNumber(n,{unit:"quarter"});case"QQQ":return t.quarter(n,{width:"abbreviated",context:"formatting"});case"QQQQQ":return t.quarter(n,{width:"narrow",context:"formatting"});case"QQQQ":default:return t.quarter(n,{width:"wide",context:"formatting"})}},q:function(r,e,t){const n=Math.ceil((r.getMonth()+1)/3);switch(e){case"q":return String(n);case"qq":return m(n,2);case"qo":return t.ordinalNumber(n,{unit:"quarter"});case"qqq":return t.quarter(n,{width:"abbreviated",context:"standalone"});case"qqqqq":return t.quarter(n,{width:"narrow",context:"standalone"});case"qqqq":default:return t.quarter(n,{width:"wide",context:"standalone"})}},M:function(r,e,t){const n=r.getMonth();switch(e){case"M":case"MM":return E.M(r,e);case"Mo":return t.ordinalNumber(n+1,{unit:"month"});case"MMM":return t.month(n,{width:"abbreviated",context:"formatting"});case"MMMMM":return t.month(n,{width:"narrow",context:"formatting"});case"MMMM":default:return t.month(n,{width:"wide",context:"formatting"})}},L:function(r,e,t){const n=r.getMonth();switch(e){case"L":return String(n+1);case"LL":return m(n+1,2);case"Lo":return t.ordinalNumber(n+1,{unit:"month"});case"LLL":return t.month(n,{width:"abbreviated",context:"standalone"});case"LLLLL":return t.month(n,{width:"narrow",context:"standalone"});case"LLLL":default:return t.month(n,{width:"wide",context:"standalone"})}},w:function(r,e,t,n){const a=Fe(r,n);return e==="wo"?t.ordinalNumber(a,{unit:"week"}):m(a,e.length)},I:function(r,e,t){const n=qe(r);return e==="Io"?t.ordinalNumber(n,{unit:"week"}):m(n,e.length)},d:function(r,e,t){return e==="do"?t.ordinalNumber(r.getDate(),{unit:"date"}):E.d(r,e)},D:function(r,e,t){const n=fn(r);return e==="Do"?t.ordinalNumber(n,{unit:"dayOfYear"}):m(n,e.length)},E:function(r,e,t){const n=r.getDay();switch(e){case"E":case"EE":case"EEE":return t.day(n,{width:"abbreviated",context:"formatting"});case"EEEEE":return t.day(n,{width:"narrow",context:"formatting"});case"EEEEEE":return t.day(n,{width:"short",context:"formatting"});case"EEEE":default:return t.day(n,{width:"wide",context:"formatting"})}},e:function(r,e,t,n){const a=r.getDay(),s=(a-n.weekStartsOn+8)%7||7;switch(e){case"e":return String(s);case"ee":return m(s,2);case"eo":return t.ordinalNumber(s,{unit:"day"});case"eee":return t.day(a,{width:"abbreviated",context:"formatting"});case"eeeee":return t.day(a,{width:"narrow",context:"formatting"});case"eeeeee":return t.day(a,{width:"short",context:"formatting"});case"eeee":default:return t.day(a,{width:"wide",context:"formatting"})}},c:function(r,e,t,n){const a=r.getDay(),s=(a-n.weekStartsOn+8)%7||7;switch(e){case"c":return String(s);case"cc":return m(s,e.length);case"co":return t.ordinalNumber(s,{unit:"day"});case"ccc":return t.day(a,{width:"abbreviated",context:"standalone"});case"ccccc":return t.day(a,{width:"narrow",context:"standalone"});case"cccccc":return t.day(a,{width:"short",context:"standalone"});case"cccc":default:return t.day(a,{width:"wide",context:"standalone"})}},i:function(r,e,t){const n=r.getDay(),a=n===0?7:n;switch(e){case"i":return String(a);case"ii":return m(a,e.length);case"io":return t.ordinalNumber(a,{unit:"day"});case"iii":return t.day(n,{width:"abbreviated",context:"formatting"});case"iiiii":return t.day(n,{width:"narrow",context:"formatting"});case"iiiiii":return t.day(n,{width:"short",context:"formatting"});case"iiii":default:return t.day(n,{width:"wide",context:"formatting"})}},a:function(r,e,t){const a=r.getHours()/12>=1?"pm":"am";switch(e){case"a":case"aa":return t.dayPeriod(a,{width:"abbreviated",context:"formatting"});case"aaa":return t.dayPeriod(a,{width:"abbreviated",context:"formatting"}).toLowerCase();case"aaaaa":return t.dayPeriod(a,{width:"narrow",context:"formatting"});case"aaaa":default:return t.dayPeriod(a,{width:"wide",context:"formatting"})}},b:function(r,e,t){const n=r.getHours();let a;switch(n===12?a=L.noon:n===0?a=L.midnight:a=n/12>=1?"pm":"am",e){case"b":case"bb":return t.dayPeriod(a,{width:"abbreviated",context:"formatting"});case"bbb":return t.dayPeriod(a,{width:"abbreviated",context:"formatting"}).toLowerCase();case"bbbbb":return t.dayPeriod(a,{width:"narrow",context:"formatting"});case"bbbb":default:return t.dayPeriod(a,{width:"wide",context:"formatting"})}},B:function(r,e,t){const n=r.getHours();let a;switch(n>=17?a=L.evening:n>=12?a=L.afternoon:n>=4?a=L.morning:a=L.night,e){case"B":case"BB":case"BBB":return t.dayPeriod(a,{width:"abbreviated",context:"formatting"});case"BBBBB":return t.dayPeriod(a,{width:"narrow",context:"formatting"});case"BBBB":default:return t.dayPeriod(a,{width:"wide",context:"formatting"})}},h:function(r,e,t){if(e==="ho"){let n=r.getHours()%12;return n===0&&(n=12),t.ordinalNumber(n,{unit:"hour"})}return E.h(r,e)},H:function(r,e,t){return e==="Ho"?t.ordinalNumber(r.getHours(),{unit:"hour"}):E.H(r,e)},K:function(r,e,t){const n=r.getHours()%12;return e==="Ko"?t.ordinalNumber(n,{unit:"hour"}):m(n,e.length)},k:function(r,e,t){let n=r.getHours();return n===0&&(n=24),e==="ko"?t.ordinalNumber(n,{unit:"hour"}):m(n,e.length)},m:function(r,e,t){return e==="mo"?t.ordinalNumber(r.getMinutes(),{unit:"minute"}):E.m(r,e)},s:function(r,e,t){return e==="so"?t.ordinalNumber(r.getSeconds(),{unit:"second"}):E.s(r,e)},S:function(r,e){return E.S(r,e)},X:function(r,e,t){const n=r.getTimezoneOffset();if(n===0)return"Z";switch(e){case"X":return De(n);case"XXXX":case"XX":return q(n);case"XXXXX":case"XXX":default:return q(n,":")}},x:function(r,e,t){const n=r.getTimezoneOffset();switch(e){case"x":return De(n);case"xxxx":case"xx":return q(n);case"xxxxx":case"xxx":default:return q(n,":")}},O:function(r,e,t){const n=r.getTimezoneOffset();switch(e){case"O":case"OO":case"OOO":return"GMT"+Me(n,":");case"OOOO":default:return"GMT"+q(n,":")}},z:function(r,e,t){const n=r.getTimezoneOffset();switch(e){case"z":case"zz":case"zzz":return"GMT"+Me(n,":");case"zzzz":default:return"GMT"+q(n,":")}},t:function(r,e,t){const n=Math.trunc(+r/1e3);return m(n,e.length)},T:function(r,e,t){return m(+r,e.length)}};function Me(r,e=""){const t=r>0?"-":"+",n=Math.abs(r),a=Math.trunc(n/60),s=n%60;return s===0?t+String(a):t+String(a)+e+m(s,2)}function De(r,e){return r%60===0?(r>0?"-":"+")+m(Math.abs(r)/60,2):q(r,e)}function q(r,e=""){const t=r>0?"-":"+",n=Math.abs(r),a=m(Math.trunc(n/60),2),s=m(n%60,2);return t+a+e+s}const ke=(r,e)=>{switch(r){case"P":return e.date({width:"short"});case"PP":return e.date({width:"medium"});case"PPP":return e.date({width:"long"});case"PPPP":default:return e.date({width:"full"})}},Ce=(r,e)=>{switch(r){case"p":return e.time({width:"short"});case"pp":return e.time({width:"medium"});case"ppp":return e.time({width:"long"});case"pppp":default:return e.time({width:"full"})}},mn=(r,e)=>{const t=r.match(/(P+)(p+)?/)||[],n=t[1],a=t[2];if(!a)return ke(r,e);let s;switch(n){case"P":s=e.dateTime({width:"short"});break;case"PP":s=e.dateTime({width:"medium"});break;case"PPP":s=e.dateTime({width:"long"});break;case"PPPP":default:s=e.dateTime({width:"full"});break}return s.replace("{{date}}",ke(n,e)).replace("{{time}}",Ce(a,e))},oe={p:Ce,P:mn},wn=/^D+$/,yn=/^Y+$/,gn=["D","DD","YY","YYYY"];function Ie(r){return wn.test(r)}function Le(r){return yn.test(r)}function ie(r,e,t){const n=pn(r,e,t);if(console.warn(n),gn.includes(r))throw new RangeError(n)}function pn(r,e,t){const n=r[0]==="Y"?"years":"days of the month";return`Use \`${r.toLowerCase()}\` instead of \`${r}\` (in \`${e}\`) for formatting ${n} to the input \`${t}\`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md`}const bn=/[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g,xn=/P+p+|P+|p+|''|'(''|[^'])+('|$)|./g,Mn=/^'([^]*?)'?$/,Dn=/''/g,kn=/[a-zA-Z]/;function Tn(r,e,t){const n=F(),a=t?.locale??n.locale??He,s=t?.firstWeekContainsDate??t?.locale?.options?.firstWeekContainsDate??n.firstWeekContainsDate??n.locale?.options?.firstWeekContainsDate??1,o=t?.weekStartsOn??t?.locale?.options?.weekStartsOn??n.weekStartsOn??n.locale?.options?.weekStartsOn??0,c=u(r,t?.in);if(!Ye(c))throw new RangeError("Invalid time value");let i=e.match(xn).map(f=>{const y=f[0];if(y==="p"||y==="P"){const T=oe[y];return T(f,a.formatLong)}return f}).join("").match(bn).map(f=>{if(f==="''")return{isToken:!1,value:"'"};const y=f[0];if(y==="'")return{isToken:!1,value:Pn(f)};if(xe[y])return{isToken:!0,value:f};if(y.match(kn))throw new RangeError("Format string contains an unescaped latin alphabet character `"+y+"`");return{isToken:!1,value:f}});a.localize.preprocessor&&(i=a.localize.preprocessor(c,i));const d={firstWeekContainsDate:s,weekStartsOn:o,locale:a};return i.map(f=>{if(!f.isToken)return f.value;const y=f.value;(!t?.useAdditionalWeekYearTokens&&Le(y)||!t?.useAdditionalDayOfYearTokens&&Ie(y))&&ie(y,e,String(r));const T=xe[y[0]];return T(c,y,a.localize,d)}).join("")}function Pn(r){const e=r.match(Mn);return e?e[1].replace(Dn,"'"):r}function On(){return Object.assign({},F())}function Yn(r,e){const t=u(r,e?.in).getDay();return t===0?7:t}function vn(r,e){const t=_n(e)?new e(0):p(e,0);return t.setFullYear(r.getFullYear(),r.getMonth(),r.getDate()),t.setHours(r.getHours(),r.getMinutes(),r.getSeconds(),r.getMilliseconds()),t}function _n(r){return typeof r=="function"&&r.prototype?.constructor===r}const Wn=10;class Qe{subPriority=0;validate(e,t){return!0}}class Nn extends Qe{constructor(e,t,n,a,s){super(),this.value=e,this.validateValue=t,this.setValue=n,this.priority=a,s&&(this.subPriority=s)}validate(e,t){return this.validateValue(e,this.value,t)}set(e,t,n){return this.setValue(e,t,this.value,n)}}class En extends Qe{priority=Wn;subPriority=-1;constructor(e,t){super(),this.context=e||(n=>p(t,n))}set(e,t){return t.timestampIsSet?e:p(e,vn(e,this.context))}}class h{run(e,t,n,a){const s=this.parse(e,t,n,a);return s?{setter:new Nn(s.value,this.validate,this.set,this.priority,this.subPriority),rest:s.rest}:null}validate(e,t,n){return!0}}class Hn extends h{priority=140;parse(e,t,n){switch(t){case"G":case"GG":case"GGG":return n.era(e,{width:"abbreviated"})||n.era(e,{width:"narrow"});case"GGGGG":return n.era(e,{width:"narrow"});case"GGGG":default:return n.era(e,{width:"wide"})||n.era(e,{width:"abbreviated"})||n.era(e,{width:"narrow"})}}set(e,t,n){return t.era=n,e.setFullYear(n,0,1),e.setHours(0,0,0,0),e}incompatibleTokens=["R","u","t","T"]}const x={month:/^(1[0-2]|0?\d)/,date:/^(3[0-1]|[0-2]?\d)/,dayOfYear:/^(36[0-6]|3[0-5]\d|[0-2]?\d?\d)/,week:/^(5[0-3]|[0-4]?\d)/,hour23h:/^(2[0-3]|[0-1]?\d)/,hour24h:/^(2[0-4]|[0-1]?\d)/,hour11h:/^(1[0-1]|0?\d)/,hour12h:/^(1[0-2]|0?\d)/,minute:/^[0-5]?\d/,second:/^[0-5]?\d/,singleDigit:/^\d/,twoDigits:/^\d{1,2}/,threeDigits:/^\d{1,3}/,fourDigits:/^\d{1,4}/,anyDigitsSigned:/^-?\d+/,singleDigitSigned:/^-?\d/,twoDigitsSigned:/^-?\d{1,2}/,threeDigitsSigned:/^-?\d{1,3}/,fourDigitsSigned:/^-?\d{1,4}/},v={basicOptionalMinutes:/^([+-])(\d{2})(\d{2})?|Z/,basic:/^([+-])(\d{2})(\d{2})|Z/,basicOptionalSeconds:/^([+-])(\d{2})(\d{2})((\d{2}))?|Z/,extended:/^([+-])(\d{2}):(\d{2})|Z/,extendedOptionalSeconds:/^([+-])(\d{2}):(\d{2})(:(\d{2}))?|Z/};function M(r,e){return r&&{value:e(r.value),rest:r.rest}}function g(r,e){const t=e.match(r);return t?{value:parseInt(t[0],10),rest:e.slice(t[0].length)}:null}function _(r,e){const t=e.match(r);if(!t)return null;if(t[0]==="Z")return{value:0,rest:e.slice(1)};const n=t[1]==="+"?1:-1,a=t[2]?parseInt(t[2],10):0,s=t[3]?parseInt(t[3],10):0,o=t[5]?parseInt(t[5],10):0;return{value:n*(a*V+s*G+o*ot),rest:e.slice(t[0].length)}}function Re(r){return g(x.anyDigitsSigned,r)}function b(r,e){switch(r){case 1:return g(x.singleDigit,e);case 2:return g(x.twoDigits,e);case 3:return g(x.threeDigits,e);case 4:return g(x.fourDigits,e);default:return g(new RegExp("^\\d{1,"+r+"}"),e)}}function te(r,e){switch(r){case 1:return g(x.singleDigitSigned,e);case 2:return g(x.twoDigitsSigned,e);case 3:return g(x.threeDigitsSigned,e);case 4:return g(x.fourDigitsSigned,e);default:return g(new RegExp("^-?\\d{1,"+r+"}"),e)}}function fe(r){switch(r){case"morning":return 4;case"evening":return 17;case"pm":case"noon":case"afternoon":return 12;case"am":case"midnight":case"night":default:return 0}}function Be(r,e){const t=e>0,n=t?e:1-e;let a;if(n<=50)a=r||100;else{const s=n+50,o=Math.trunc(s/100)*100,c=r>=s%100;a=r+o-(c?100:0)}return t?a:1-a}function Xe(r){return r%400===0||r%4===0&&r%100!==0}class qn extends h{priority=130;incompatibleTokens=["Y","R","u","w","I","i","e","c","t","T"];parse(e,t,n){const a=s=>({year:s,isTwoDigitYear:t==="yy"});switch(t){case"y":return M(b(4,e),a);case"yo":return M(n.ordinalNumber(e,{unit:"year"}),a);default:return M(b(t.length,e),a)}}validate(e,t){return t.isTwoDigitYear||t.year>0}set(e,t,n){const a=e.getFullYear();if(n.isTwoDigitYear){const o=Be(n.year,a);return e.setFullYear(o,0,1),e.setHours(0,0,0,0),e}const s=!("era"in t)||t.era===1?n.year:1-n.year;return e.setFullYear(s,0,1),e.setHours(0,0,0,0),e}}class Fn extends h{priority=130;parse(e,t,n){const a=s=>({year:s,isTwoDigitYear:t==="YY"});switch(t){case"Y":return M(b(4,e),a);case"Yo":return M(n.ordinalNumber(e,{unit:"year"}),a);default:return M(b(t.length,e),a)}}validate(e,t){return t.isTwoDigitYear||t.year>0}set(e,t,n,a){const s=le(e,a);if(n.isTwoDigitYear){const c=Be(n.year,s);return e.setFullYear(c,0,a.firstWeekContainsDate),e.setHours(0,0,0,0),W(e,a)}const o=!("era"in t)||t.era===1?n.year:1-n.year;return e.setFullYear(o,0,a.firstWeekContainsDate),e.setHours(0,0,0,0),W(e,a)}incompatibleTokens=["y","R","u","Q","q","M","L","I","d","D","i","t","T"]}class Cn extends h{priority=130;parse(e,t){return te(t==="R"?4:t.length,e)}set(e,t,n){const a=p(e,0);return a.setFullYear(n,0,4),a.setHours(0,0,0,0),Q(a)}incompatibleTokens=["G","y","Y","u","Q","q","M","L","w","d","D","e","c","t","T"]}class In extends h{priority=130;parse(e,t){return te(t==="u"?4:t.length,e)}set(e,t,n){return e.setFullYear(n,0,1),e.setHours(0,0,0,0),e}incompatibleTokens=["G","y","Y","R","w","I","i","e","c","t","T"]}class Ln extends h{priority=120;parse(e,t,n){switch(t){case"Q":case"QQ":return b(t.length,e);case"Qo":return n.ordinalNumber(e,{unit:"quarter"});case"QQQ":return n.quarter(e,{width:"abbreviated",context:"formatting"})||n.quarter(e,{width:"narrow",context:"formatting"});case"QQQQQ":return n.quarter(e,{width:"narrow",context:"formatting"});case"QQQQ":default:return n.quarter(e,{width:"wide",context:"formatting"})||n.quarter(e,{width:"abbreviated",context:"formatting"})||n.quarter(e,{width:"narrow",context:"formatting"})}}validate(e,t){return t>=1&&t<=4}set(e,t,n){return e.setMonth((n-1)*3,1),e.setHours(0,0,0,0),e}incompatibleTokens=["Y","R","q","M","L","w","I","d","D","i","e","c","t","T"]}class Qn extends h{priority=120;parse(e,t,n){switch(t){case"q":case"qq":return b(t.length,e);case"qo":return n.ordinalNumber(e,{unit:"quarter"});case"qqq":return n.quarter(e,{width:"abbreviated",context:"standalone"})||n.quarter(e,{width:"narrow",context:"standalone"});case"qqqqq":return n.quarter(e,{width:"narrow",context:"standalone"});case"qqqq":default:return n.quarter(e,{width:"wide",context:"standalone"})||n.quarter(e,{width:"abbreviated",context:"standalone"})||n.quarter(e,{width:"narrow",context:"standalone"})}}validate(e,t){return t>=1&&t<=4}set(e,t,n){return e.setMonth((n-1)*3,1),e.setHours(0,0,0,0),e}incompatibleTokens=["Y","R","Q","M","L","w","I","d","D","i","e","c","t","T"]}class Rn extends h{incompatibleTokens=["Y","R","q","Q","L","w","I","D","i","e","c","t","T"];priority=110;parse(e,t,n){const a=s=>s-1;switch(t){case"M":return M(g(x.month,e),a);case"MM":return M(b(2,e),a);case"Mo":return M(n.ordinalNumber(e,{unit:"month"}),a);case"MMM":return n.month(e,{width:"abbreviated",context:"formatting"})||n.month(e,{width:"narrow",context:"formatting"});case"MMMMM":return n.month(e,{width:"narrow",context:"formatting"});case"MMMM":default:return n.month(e,{width:"wide",context:"formatting"})||n.month(e,{width:"abbreviated",context:"formatting"})||n.month(e,{width:"narrow",context:"formatting"})}}validate(e,t){return t>=0&&t<=11}set(e,t,n){return e.setMonth(n,1),e.setHours(0,0,0,0),e}}class Bn extends h{priority=110;parse(e,t,n){const a=s=>s-1;switch(t){case"L":return M(g(x.month,e),a);case"LL":return M(b(2,e),a);case"Lo":return M(n.ordinalNumber(e,{unit:"month"}),a);case"LLL":return n.month(e,{width:"abbreviated",context:"standalone"})||n.month(e,{width:"narrow",context:"standalone"});case"LLLLL":return n.month(e,{width:"narrow",context:"standalone"});case"LLLL":default:return n.month(e,{width:"wide",context:"standalone"})||n.month(e,{width:"abbreviated",context:"standalone"})||n.month(e,{width:"narrow",context:"standalone"})}}validate(e,t){return t>=0&&t<=11}set(e,t,n){return e.setMonth(n,1),e.setHours(0,0,0,0),e}incompatibleTokens=["Y","R","q","Q","M","w","I","D","i","e","c","t","T"]}function Xn(r,e,t){const n=u(r,t?.in),a=Fe(n,t)-e;return n.setDate(n.getDate()-a*7),u(n,t?.in)}class $n extends h{priority=100;parse(e,t,n){switch(t){case"w":return g(x.week,e);case"wo":return n.ordinalNumber(e,{unit:"week"});default:return b(t.length,e)}}validate(e,t){return t>=1&&t<=53}set(e,t,n,a){return W(Xn(e,n,a),a)}incompatibleTokens=["y","R","u","q","Q","M","L","I","d","D","i","t","T"]}function An(r,e,t){const n=u(r,t?.in),a=qe(n,t)-e;return n.setDate(n.getDate()-a*7),n}class Gn extends h{priority=100;parse(e,t,n){switch(t){case"I":return g(x.week,e);case"Io":return n.ordinalNumber(e,{unit:"week"});default:return b(t.length,e)}}validate(e,t){return t>=1&&t<=53}set(e,t,n){return Q(An(e,n))}incompatibleTokens=["y","Y","u","q","Q","M","L","w","d","D","e","c","t","T"]}const Vn=[31,28,31,30,31,30,31,31,30,31,30,31],zn=[31,29,31,30,31,30,31,31,30,31,30,31];class jn extends h{priority=90;subPriority=1;parse(e,t,n){switch(t){case"d":return g(x.date,e);case"do":return n.ordinalNumber(e,{unit:"date"});default:return b(t.length,e)}}validate(e,t){const n=e.getFullYear(),a=Xe(n),s=e.getMonth();return a?t>=1&&t<=zn[s]:t>=1&&t<=Vn[s]}set(e,t,n){return e.setDate(n),e.setHours(0,0,0,0),e}incompatibleTokens=["Y","R","q","Q","w","I","D","i","e","c","t","T"]}class Un extends h{priority=90;subpriority=1;parse(e,t,n){switch(t){case"D":case"DD":return g(x.dayOfYear,e);case"Do":return n.ordinalNumber(e,{unit:"date"});default:return b(t.length,e)}}validate(e,t){const n=e.getFullYear();return Xe(n)?t>=1&&t<=366:t>=1&&t<=365}set(e,t,n){return e.setMonth(0,n),e.setHours(0,0,0,0),e}incompatibleTokens=["Y","R","q","Q","M","L","w","I","d","E","i","e","c","t","T"]}function he(r,e,t){const n=F(),a=t?.weekStartsOn??t?.locale?.options?.weekStartsOn??n.weekStartsOn??n.locale?.options?.weekStartsOn??0,s=u(r,t?.in),o=s.getDay(),i=(e%7+7)%7,d=7-a,f=e<0||e>6?e-(o+d)%7:(i+d)%7-(o+d)%7;return ne(s,f,t)}class Zn extends h{priority=90;parse(e,t,n){switch(t){case"E":case"EE":case"EEE":return n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"});case"EEEEE":return n.day(e,{width:"narrow",context:"formatting"});case"EEEEEE":return n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"});case"EEEE":default:return n.day(e,{width:"wide",context:"formatting"})||n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"})}}validate(e,t){return t>=0&&t<=6}set(e,t,n,a){return e=he(e,n,a),e.setHours(0,0,0,0),e}incompatibleTokens=["D","i","e","c","t","T"]}class Jn extends h{priority=90;parse(e,t,n,a){const s=o=>{const c=Math.floor((o-1)/7)*7;return(o+a.weekStartsOn+6)%7+c};switch(t){case"e":case"ee":return M(b(t.length,e),s);case"eo":return M(n.ordinalNumber(e,{unit:"day"}),s);case"eee":return n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"});case"eeeee":return n.day(e,{width:"narrow",context:"formatting"});case"eeeeee":return n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"});case"eeee":default:return n.day(e,{width:"wide",context:"formatting"})||n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"})}}validate(e,t){return t>=0&&t<=6}set(e,t,n,a){return e=he(e,n,a),e.setHours(0,0,0,0),e}incompatibleTokens=["y","R","u","q","Q","M","L","I","d","D","E","i","c","t","T"]}class Kn extends h{priority=90;parse(e,t,n,a){const s=o=>{const c=Math.floor((o-1)/7)*7;return(o+a.weekStartsOn+6)%7+c};switch(t){case"c":case"cc":return M(b(t.length,e),s);case"co":return M(n.ordinalNumber(e,{unit:"day"}),s);case"ccc":return n.day(e,{width:"abbreviated",context:"standalone"})||n.day(e,{width:"short",context:"standalone"})||n.day(e,{width:"narrow",context:"standalone"});case"ccccc":return n.day(e,{width:"narrow",context:"standalone"});case"cccccc":return n.day(e,{width:"short",context:"standalone"})||n.day(e,{width:"narrow",context:"standalone"});case"cccc":default:return n.day(e,{width:"wide",context:"standalone"})||n.day(e,{width:"abbreviated",context:"standalone"})||n.day(e,{width:"short",context:"standalone"})||n.day(e,{width:"narrow",context:"standalone"})}}validate(e,t){return t>=0&&t<=6}set(e,t,n,a){return e=he(e,n,a),e.setHours(0,0,0,0),e}incompatibleTokens=["y","R","u","q","Q","M","L","I","d","D","E","i","e","t","T"]}function Sn(r,e,t){const n=u(r,t?.in),a=Yn(n,t),s=e-a;return ne(n,s,t)}class er extends h{priority=90;parse(e,t,n){const a=s=>s===0?7:s;switch(t){case"i":case"ii":return b(t.length,e);case"io":return n.ordinalNumber(e,{unit:"day"});case"iii":return M(n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"}),a);case"iiiii":return M(n.day(e,{width:"narrow",context:"formatting"}),a);case"iiiiii":return M(n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"}),a);case"iiii":default:return M(n.day(e,{width:"wide",context:"formatting"})||n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"}),a)}}validate(e,t){return t>=1&&t<=7}set(e,t,n){return e=Sn(e,n),e.setHours(0,0,0,0),e}incompatibleTokens=["y","Y","u","q","Q","M","L","w","d","D","E","e","c","t","T"]}class tr extends h{priority=80;parse(e,t,n){switch(t){case"a":case"aa":case"aaa":return n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"});case"aaaaa":return n.dayPeriod(e,{width:"narrow",context:"formatting"});case"aaaa":default:return n.dayPeriod(e,{width:"wide",context:"formatting"})||n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"})}}set(e,t,n){return e.setHours(fe(n),0,0,0),e}incompatibleTokens=["b","B","H","k","t","T"]}class nr extends h{priority=80;parse(e,t,n){switch(t){case"b":case"bb":case"bbb":return n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"});case"bbbbb":return n.dayPeriod(e,{width:"narrow",context:"formatting"});case"bbbb":default:return n.dayPeriod(e,{width:"wide",context:"formatting"})||n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"})}}set(e,t,n){return e.setHours(fe(n),0,0,0),e}incompatibleTokens=["a","B","H","k","t","T"]}class rr extends h{priority=80;parse(e,t,n){switch(t){case"B":case"BB":case"BBB":return n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"});case"BBBBB":return n.dayPeriod(e,{width:"narrow",context:"formatting"});case"BBBB":default:return n.dayPeriod(e,{width:"wide",context:"formatting"})||n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"})}}set(e,t,n){return e.setHours(fe(n),0,0,0),e}incompatibleTokens=["a","b","t","T"]}class ar extends h{priority=70;parse(e,t,n){switch(t){case"h":return g(x.hour12h,e);case"ho":return n.ordinalNumber(e,{unit:"hour"});default:return b(t.length,e)}}validate(e,t){return t>=1&&t<=12}set(e,t,n){const a=e.getHours()>=12;return a&&n<12?e.setHours(n+12,0,0,0):!a&&n===12?e.setHours(0,0,0,0):e.setHours(n,0,0,0),e}incompatibleTokens=["H","K","k","t","T"]}class sr extends h{priority=70;parse(e,t,n){switch(t){case"H":return g(x.hour23h,e);case"Ho":return n.ordinalNumber(e,{unit:"hour"});default:return b(t.length,e)}}validate(e,t){return t>=0&&t<=23}set(e,t,n){return e.setHours(n,0,0,0),e}incompatibleTokens=["a","b","h","K","k","t","T"]}class or extends h{priority=70;parse(e,t,n){switch(t){case"K":return g(x.hour11h,e);case"Ko":return n.ordinalNumber(e,{unit:"hour"});default:return b(t.length,e)}}validate(e,t){return t>=0&&t<=11}set(e,t,n){return e.getHours()>=12&&n<12?e.setHours(n+12,0,0,0):e.setHours(n,0,0,0),e}incompatibleTokens=["h","H","k","t","T"]}class ir extends h{priority=70;parse(e,t,n){switch(t){case"k":return g(x.hour24h,e);case"ko":return n.ordinalNumber(e,{unit:"hour"});default:return b(t.length,e)}}validate(e,t){return t>=1&&t<=24}set(e,t,n){const a=n<=24?n%24:n;return e.setHours(a,0,0,0),e}incompatibleTokens=["a","b","h","H","K","t","T"]}class cr extends h{priority=60;parse(e,t,n){switch(t){case"m":return g(x.minute,e);case"mo":return n.ordinalNumber(e,{unit:"minute"});default:return b(t.length,e)}}validate(e,t){return t>=0&&t<=59}set(e,t,n){return e.setMinutes(n,0,0),e}incompatibleTokens=["t","T"]}class ur extends h{priority=50;parse(e,t,n){switch(t){case"s":return g(x.second,e);case"so":return n.ordinalNumber(e,{unit:"second"});default:return b(t.length,e)}}validate(e,t){return t>=0&&t<=59}set(e,t,n){return e.setSeconds(n,0),e}incompatibleTokens=["t","T"]}class dr extends h{priority=30;parse(e,t){const n=a=>Math.trunc(a*Math.pow(10,-t.length+3));return M(b(t.length,e),n)}set(e,t,n){return e.setMilliseconds(n),e}incompatibleTokens=["t","T"]}class lr extends h{priority=10;parse(e,t){switch(t){case"X":return _(v.basicOptionalMinutes,e);case"XX":return _(v.basic,e);case"XXXX":return _(v.basicOptionalSeconds,e);case"XXXXX":return _(v.extendedOptionalSeconds,e);case"XXX":default:return _(v.extended,e)}}set(e,t,n){return t.timestampIsSet?e:p(e,e.getTime()-ee(e)-n)}incompatibleTokens=["t","T","x"]}class fr extends h{priority=10;parse(e,t){switch(t){case"x":return _(v.basicOptionalMinutes,e);case"xx":return _(v.basic,e);case"xxxx":return _(v.basicOptionalSeconds,e);case"xxxxx":return _(v.extendedOptionalSeconds,e);case"xxx":default:return _(v.extended,e)}}set(e,t,n){return t.timestampIsSet?e:p(e,e.getTime()-ee(e)-n)}incompatibleTokens=["t","T","X"]}class hr extends h{priority=40;parse(e){return Re(e)}set(e,t,n){return[p(e,n*1e3),{timestampIsSet:!0}]}incompatibleTokens="*"}class mr extends h{priority=20;parse(e){return Re(e)}set(e,t,n){return[p(e,n),{timestampIsSet:!0}]}incompatibleTokens="*"}const wr={G:new Hn,y:new qn,Y:new Fn,R:new Cn,u:new In,Q:new Ln,q:new Qn,M:new Rn,L:new Bn,w:new $n,I:new Gn,d:new jn,D:new Un,E:new Zn,e:new Jn,c:new Kn,i:new er,a:new tr,b:new nr,B:new rr,h:new ar,H:new sr,K:new or,k:new ir,m:new cr,s:new ur,S:new dr,X:new lr,x:new fr,t:new hr,T:new mr},yr=/[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g,gr=/P+p+|P+|p+|''|'(''|[^'])+('|$)|./g,pr=/^'([^]*?)'?$/,br=/''/g,xr=/\S/,Mr=/[a-zA-Z]/;function Dr(r,e,t,n){const a=()=>p(n?.in||t,NaN),s=On(),o=n?.locale??s.locale??He,c=n?.firstWeekContainsDate??n?.locale?.options?.firstWeekContainsDate??s.firstWeekContainsDate??s.locale?.options?.firstWeekContainsDate??1,i=n?.weekStartsOn??n?.locale?.options?.weekStartsOn??s.weekStartsOn??s.locale?.options?.weekStartsOn??0;if(!e)return r?a():u(t,n?.in);const d={firstWeekContainsDate:c,weekStartsOn:i,locale:o},f=[new En(n?.in,t)],y=e.match(gr).map(l=>{const w=l[0];if(w in oe){const k=oe[w];return k(l,o.formatLong)}return l}).join("").match(yr),T=[];for(let l of y){!n?.useAdditionalWeekYearTokens&&Le(l)&&ie(l,e,r),!n?.useAdditionalDayOfYearTokens&&Ie(l)&&ie(l,e,r);const w=l[0],k=wr[w];if(k){const{incompatibleTokens:R}=k;if(Array.isArray(R)){const me=T.find(we=>R.includes(we.token)||we.token===w);if(me)throw new RangeError(`The format string mustn't contain \`${me.fullToken}\` and \`${l}\` at the same time`)}else if(k.incompatibleTokens==="*"&&T.length>0)throw new RangeError(`The format string mustn't contain \`${l}\` and any other token at the same time`);T.push({token:w,fullToken:l});const H=k.run(r,l,o.match,d);if(!H)return a();f.push(H.setter),r=H.rest}else{if(w.match(Mr))throw new RangeError("Format string contains an unescaped latin alphabet character `"+w+"`");if(l==="''"?l="'":w==="'"&&(l=kr(l)),r.indexOf(l)===0)r=r.slice(l.length);else return a()}}if(r.length>0&&xr.test(r))return a();const N=f.map(l=>l.priority).sort((l,w)=>w-l).filter((l,w,k)=>k.indexOf(l)===w).map(l=>f.filter(w=>w.priority===l).sort((w,k)=>k.subPriority-w.subPriority)).map(l=>l[0]);let D=u(t,n?.in);if(isNaN(+D))return a();const P={};for(const l of N){if(!l.validate(D,d))return a();const w=l.set(D,P,d);Array.isArray(w)?(D=w[0],Object.assign(P,w[1])):D=w}return D}function kr(r){return r.match(pr)[1].replace(br,"'")}function Tr(r,e){const t=u(r,e?.in);return t.setMinutes(0,0,0),t}function Pr(r,e){const t=u(r,e?.in);return t.setSeconds(0,0),t}function Or(r,e){const t=u(r,e?.in);return t.setMilliseconds(0),t}function Yr(r,e){const t=()=>p(e?.in,NaN),n=e?.additionalDigits??2,a=Nr(r);let s;if(a.date){const d=Er(a.date,n);s=Hr(d.restDateString,d.year)}if(!s||isNaN(+s))return t();const o=+s;let c=0,i;if(a.time&&(c=qr(a.time),isNaN(c)))return t();if(a.timezone){if(i=Fr(a.timezone),isNaN(i))return t()}else{const d=new Date(o+c),f=u(0,e?.in);return f.setFullYear(d.getUTCFullYear(),d.getUTCMonth(),d.getUTCDate()),f.setHours(d.getUTCHours(),d.getUTCMinutes(),d.getUTCSeconds(),d.getUTCMilliseconds()),f}return u(o+c+i,e?.in)}const S={dateTimeDelimiter:/[T ]/,timeZoneDelimiter:/[Z ]/i,timezone:/([Z+-].*)$/},vr=/^-?(?:(\d{3})|(\d{2})(?:-?(\d{2}))?|W(\d{2})(?:-?(\d{1}))?|)$/,_r=/^(\d{2}(?:[.,]\d*)?)(?::?(\d{2}(?:[.,]\d*)?))?(?::?(\d{2}(?:[.,]\d*)?))?$/,Wr=/^([+-])(\d{2})(?::?(\d{2}))?$/;function Nr(r){const e={},t=r.split(S.dateTimeDelimiter);let n;if(t.length>2)return e;if(/:/.test(t[0])?n=t[0]:(e.date=t[0],n=t[1],S.timeZoneDelimiter.test(e.date)&&(e.date=r.split(S.timeZoneDelimiter)[0],n=r.substr(e.date.length,r.length))),n){const a=S.timezone.exec(n);a?(e.time=n.replace(a[1],""),e.timezone=a[1]):e.time=n}return e}function Er(r,e){const t=new RegExp("^(?:(\\d{4}|[+-]\\d{"+(4+e)+"})|(\\d{2}|[+-]\\d{"+(2+e)+"})$)"),n=r.match(t);if(!n)return{year:NaN,restDateString:""};const a=n[1]?parseInt(n[1]):null,s=n[2]?parseInt(n[2]):null;return{year:s===null?a:s*100,restDateString:r.slice((n[1]||n[2]).length)}}function Hr(r,e){if(e===null)return new Date(NaN);const t=r.match(vr);if(!t)return new Date(NaN);const n=!!t[4],a=$(t[1]),s=$(t[2])-1,o=$(t[3]),c=$(t[4]),i=$(t[5])-1;if(n)return Rr(e,c,i)?Cr(e,c,i):new Date(NaN);{const d=new Date(0);return!Lr(e,s,o)||!Qr(e,a)?new Date(NaN):(d.setUTCFullYear(e,s,Math.max(a,o)),d)}}function $(r){return r?parseInt(r):1}function qr(r){const e=r.match(_r);if(!e)return NaN;const t=ae(e[1]),n=ae(e[2]),a=ae(e[3]);return Br(t,n,a)?t*V+n*G+a*1e3:NaN}function ae(r){return r&&parseFloat(r.replace(",","."))||0}function Fr(r){if(r==="Z")return 0;const e=r.match(Wr);if(!e)return 0;const t=e[1]==="+"?-1:1,n=parseInt(e[2]),a=e[3]&&parseInt(e[3])||0;return Xr(n,a)?t*(n*V+a*G):NaN}function Cr(r,e,t){const n=new Date(0);n.setUTCFullYear(r,0,4);const a=n.getUTCDay()||7,s=(e-1)*7+t+1-a;return n.setUTCDate(n.getUTCDate()+s),n}const Ir=[31,null,31,30,31,30,31,31,30,31,30,31];function $e(r){return r%400===0||r%4===0&&r%100!==0}function Lr(r,e,t){return e>=0&&e<=11&&t>=1&&t<=(Ir[e]||($e(r)?29:28))}function Qr(r,e){return e>=1&&e<=($e(r)?366:365)}function Rr(r,e,t){return e>=1&&e<=53&&t>=0&&t<=6}function Br(r,e,t){return r===24?e===0&&t===0:t>=0&&t<60&&e>=0&&e<60&&r>=0&&r<25}function Xr(r,e){return e>=0&&e<=59}/*! +import{a as Ae,c as j,b as O,e as I,g as U,t as Z,n as ye,F as ge,p as Y,x as Ge}from"./index-DyUIpN7m.js";import{g as Ve}from"./chart-B185MtDy.js";const ze={class:"sparkline-card"},je={class:"card-header"},Ue={class:"card-title"},Ze={key:0,class:"card-subtitle"},Je={key:0,class:"card-chart"},Ke={key:0,class:"chart-loader"},Se={key:1,class:"chart-text"},et={class:"percent-value"},tt=["id","viewBox"],nt=["d","fill"],rt=["d","stroke"],J=100,K=40,at=Ae({name:"SparklineChart",__name:"Sparkline",props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:"smooth"},loading:{type:Boolean,default:!1},centerText:{default:""},subtitle:{default:""}},setup(r){const e=r,t=i=>{if(i.length<3)return i;const d=Math.min(15,Math.max(3,Math.floor(i.length*.2))),f=[];for(let D=0;DR+H,0)/k.length)}const y=Math.min(10,f.length),T=f.length/y,N=[];for(let D=0;D!e.data||e.data.length===0?[]:e.variant==="smooth"?t(e.data):e.data),a=i=>{if(i.length<2)return"";const d=Math.max(...i),f=Math.min(...i),y=d-f||1,T=e.variant==="classic"?4:2;let N="";return i.forEach((D,P)=>{const l=P/(i.length-1)*J,w=(D-f)/y,k=T+(K-T*2)*(1-w);if(P===0)N+=`M ${l.toFixed(2)} ${k.toFixed(2)}`;else{const H=((P-1)/(i.length-1)*J+l)/2;N+=` Q ${H.toFixed(2)} ${k.toFixed(2)} ${l.toFixed(2)} ${k.toFixed(2)}`}}),N},s=j(()=>a(n.value)),o=j(()=>s.value?`${s.value} L ${J} ${K} L 0 ${K} Z`:""),c=j(()=>`sparkline-${e.title.replace(/\s+/g,"-").toLowerCase()}`);return(i,d)=>(Y(),O("div",ze,[I("div",je,[I("div",null,[I("p",Ue,Z(i.title),1),i.subtitle?(Y(),O("p",Ze,Z(i.subtitle),1)):U("",!0)]),I("span",{class:"card-value",style:ye({color:i.color})},Z(typeof i.value=="number"?i.value.toLocaleString():i.value),5)]),i.showChart?(Y(),O("div",Je,[i.loading&&i.variant==="classic"?(Y(),O("div",Ke,[I("div",{class:"loader-spinner",style:ye({borderTopColor:i.color})},null,4)])):i.centerText?(Y(),O("div",Se,[I("span",et,Z(i.centerText),1)])):(Y(),O("svg",{key:2,id:c.value,class:"chart-svg",viewBox:`0 0 ${J} ${K}`,preserveAspectRatio:"none"},[i.variant==="classic"?(Y(),O(ge,{key:0},[n.value.length>1?(Y(),O("path",{key:0,d:o.value,fill:i.color,"fill-opacity":"0.8",class:"sparkline-path"},null,8,nt)):U("",!0)],64)):(Y(),O(ge,{key:1},[n.value.length>1?(Y(),O("path",{key:0,d:s.value,stroke:i.color,"stroke-width":"2.5","stroke-linecap":"round","stroke-linejoin":"round",fill:"none",class:"sparkline-path"},null,8,rt)):U("",!0)],64))],8,tt))])):U("",!0)]))}}),Vr=Ge(at,[["__scopeId","data-v-257cbdca"]]),Te=6048e5,st=864e5,G=6e4,V=36e5,ot=1e3,pe=Symbol.for("constructDateFrom");function p(r,e){return typeof r=="function"?r(e):r&&typeof r=="object"&&pe in r?r[pe](e):r instanceof Date?new r.constructor(e):new Date(e)}function u(r,e){return p(e||r,r)}function ne(r,e,t){const n=u(r,t?.in);return isNaN(e)?p(t?.in||r,NaN):(e&&n.setDate(n.getDate()+e),n)}function ce(r,e,t){const n=u(r,t?.in);if(isNaN(e))return p(r,NaN);if(!e)return n;const a=n.getDate(),s=p(r,n.getTime());s.setMonth(n.getMonth()+e+1,0);const o=s.getDate();return a>=o?s:(n.setFullYear(s.getFullYear(),s.getMonth(),a),n)}function ue(r,e,t){return p(r,+u(r)+e)}function it(r,e,t){return ue(r,e*V)}let ct={};function F(){return ct}function W(r,e){const t=F(),n=e?.weekStartsOn??e?.locale?.options?.weekStartsOn??t.weekStartsOn??t.locale?.options?.weekStartsOn??0,a=u(r,e?.in),s=a.getDay(),o=(s=s.getTime()?n+1:t.getTime()>=c.getTime()?n:n-1}function ee(r){const e=u(r),t=new Date(Date.UTC(e.getFullYear(),e.getMonth(),e.getDate(),e.getHours(),e.getMinutes(),e.getSeconds(),e.getMilliseconds()));return t.setUTCFullYear(e.getFullYear()),+r-+t}function C(r,...e){const t=p.bind(null,e.find(n=>typeof n=="object"));return e.map(t)}function se(r,e){const t=u(r,e?.in);return t.setHours(0,0,0,0),t}function Oe(r,e,t){const[n,a]=C(t?.in,r,e),s=se(n),o=se(a),c=+s-ee(s),i=+o-ee(o);return Math.round((c-i)/st)}function ut(r,e){const t=Pe(r,e),n=p(r,0);return n.setFullYear(t,0,4),n.setHours(0,0,0,0),Q(n)}function dt(r,e,t){const n=u(r,t?.in);return n.setTime(n.getTime()+e*G),n}function lt(r,e,t){return ce(r,e*3,t)}function ft(r,e,t){return ue(r,e*1e3)}function ht(r,e,t){return ne(r,e*7,t)}function mt(r,e,t){return ce(r,e*12,t)}function A(r,e){const t=+u(r)-+u(e);return t<0?-1:t>0?1:t}function wt(r){return r instanceof Date||typeof r=="object"&&Object.prototype.toString.call(r)==="[object Date]"}function Ye(r){return!(!wt(r)&&typeof r!="number"||isNaN(+u(r)))}function yt(r,e,t){const[n,a]=C(t?.in,r,e),s=n.getFullYear()-a.getFullYear(),o=n.getMonth()-a.getMonth();return s*12+o}function gt(r,e,t){const[n,a]=C(t?.in,r,e);return n.getFullYear()-a.getFullYear()}function ve(r,e,t){const[n,a]=C(t?.in,r,e),s=be(n,a),o=Math.abs(Oe(n,a));n.setDate(n.getDate()-s*o);const c=+(be(n,a)===-s),i=s*(o-c);return i===0?0:i}function be(r,e){const t=r.getFullYear()-e.getFullYear()||r.getMonth()-e.getMonth()||r.getDate()-e.getDate()||r.getHours()-e.getHours()||r.getMinutes()-e.getMinutes()||r.getSeconds()-e.getSeconds()||r.getMilliseconds()-e.getMilliseconds();return t<0?-1:t>0?1:t}function z(r){return e=>{const n=(r?Math[r]:Math.trunc)(e);return n===0?0:n}}function pt(r,e,t){const[n,a]=C(t?.in,r,e),s=(+n-+a)/V;return z(t?.roundingMethod)(s)}function de(r,e){return+u(r)-+u(e)}function bt(r,e,t){const n=de(r,e)/G;return z(t?.roundingMethod)(n)}function _e(r,e){const t=u(r,e?.in);return t.setHours(23,59,59,999),t}function We(r,e){const t=u(r,e?.in),n=t.getMonth();return t.setFullYear(t.getFullYear(),n+1,0),t.setHours(23,59,59,999),t}function xt(r,e){const t=u(r,e?.in);return+_e(t,e)==+We(t,e)}function Ne(r,e,t){const[n,a,s]=C(t?.in,r,r,e),o=A(a,s),c=Math.abs(yt(a,s));if(c<1)return 0;a.getMonth()===1&&a.getDate()>27&&a.setDate(30),a.setMonth(a.getMonth()-o*c);let i=A(a,s)===-o;xt(n)&&c===1&&A(n,s)===1&&(i=!1);const d=o*(c-+i);return d===0?0:d}function Mt(r,e,t){const n=Ne(r,e,t)/3;return z(t?.roundingMethod)(n)}function Dt(r,e,t){const n=de(r,e)/1e3;return z(t?.roundingMethod)(n)}function kt(r,e,t){const n=ve(r,e,t)/7;return z(t?.roundingMethod)(n)}function Tt(r,e,t){const[n,a]=C(t?.in,r,e),s=A(n,a),o=Math.abs(gt(n,a));n.setFullYear(1584),a.setFullYear(1584);const c=A(n,a)===-s,i=s*(o-+c);return i===0?0:i}function Pt(r,e){const t=u(r,e?.in),n=t.getMonth(),a=n-n%3;return t.setMonth(a,1),t.setHours(0,0,0,0),t}function Ot(r,e){const t=u(r,e?.in);return t.setDate(1),t.setHours(0,0,0,0),t}function Yt(r,e){const t=u(r,e?.in),n=t.getFullYear();return t.setFullYear(n+1,0,0),t.setHours(23,59,59,999),t}function Ee(r,e){const t=u(r,e?.in);return t.setFullYear(t.getFullYear(),0,1),t.setHours(0,0,0,0),t}function vt(r,e){const t=u(r,e?.in);return t.setMinutes(59,59,999),t}function _t(r,e){const t=F(),n=t.weekStartsOn??t.locale?.options?.weekStartsOn??0,a=u(r,e?.in),s=a.getDay(),o=(s{let n;const a=Ht[r];return typeof a=="string"?n=a:e===1?n=a.one:n=a.other.replace("{{count}}",e.toString()),t?.addSuffix?t.comparison&&t.comparison>0?"in "+n:n+" ago":n};function re(r){return(e={})=>{const t=e.width?String(e.width):r.defaultWidth;return r.formats[t]||r.formats[r.defaultWidth]}}const Ft={full:"EEEE, MMMM do, y",long:"MMMM do, y",medium:"MMM d, y",short:"MM/dd/yyyy"},Ct={full:"h:mm:ss a zzzz",long:"h:mm:ss a z",medium:"h:mm:ss a",short:"h:mm a"},It={full:"{{date}} 'at' {{time}}",long:"{{date}} 'at' {{time}}",medium:"{{date}}, {{time}}",short:"{{date}}, {{time}}"},Lt={date:re({formats:Ft,defaultWidth:"full"}),time:re({formats:Ct,defaultWidth:"full"}),dateTime:re({formats:It,defaultWidth:"full"})},Qt={lastWeek:"'last' eeee 'at' p",yesterday:"'yesterday at' p",today:"'today at' p",tomorrow:"'tomorrow at' p",nextWeek:"eeee 'at' p",other:"P"},Rt=(r,e,t,n)=>Qt[r];function B(r){return(e,t)=>{const n=t?.context?String(t.context):"standalone";let a;if(n==="formatting"&&r.formattingValues){const o=r.defaultFormattingWidth||r.defaultWidth,c=t?.width?String(t.width):o;a=r.formattingValues[c]||r.formattingValues[o]}else{const o=r.defaultWidth,c=t?.width?String(t.width):r.defaultWidth;a=r.values[c]||r.values[o]}const s=r.argumentCallback?r.argumentCallback(e):e;return a[s]}}const Bt={narrow:["B","A"],abbreviated:["BC","AD"],wide:["Before Christ","Anno Domini"]},Xt={narrow:["1","2","3","4"],abbreviated:["Q1","Q2","Q3","Q4"],wide:["1st quarter","2nd quarter","3rd quarter","4th quarter"]},$t={narrow:["J","F","M","A","M","J","J","A","S","O","N","D"],abbreviated:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],wide:["January","February","March","April","May","June","July","August","September","October","November","December"]},At={narrow:["S","M","T","W","T","F","S"],short:["Su","Mo","Tu","We","Th","Fr","Sa"],abbreviated:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],wide:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},Gt={narrow:{am:"a",pm:"p",midnight:"mi",noon:"n",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},abbreviated:{am:"AM",pm:"PM",midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},wide:{am:"a.m.",pm:"p.m.",midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"}},Vt={narrow:{am:"a",pm:"p",midnight:"mi",noon:"n",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"},abbreviated:{am:"AM",pm:"PM",midnight:"midnight",noon:"noon",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"},wide:{am:"a.m.",pm:"p.m.",midnight:"midnight",noon:"noon",morning:"in the morning",afternoon:"in the afternoon",evening:"in the evening",night:"at night"}},zt=(r,e)=>{const t=Number(r),n=t%100;if(n>20||n<10)switch(n%10){case 1:return t+"st";case 2:return t+"nd";case 3:return t+"rd"}return t+"th"},jt={ordinalNumber:zt,era:B({values:Bt,defaultWidth:"wide"}),quarter:B({values:Xt,defaultWidth:"wide",argumentCallback:r=>r-1}),month:B({values:$t,defaultWidth:"wide"}),day:B({values:At,defaultWidth:"wide"}),dayPeriod:B({values:Gt,defaultWidth:"wide",formattingValues:Vt,defaultFormattingWidth:"wide"})};function X(r){return(e,t={})=>{const n=t.width,a=n&&r.matchPatterns[n]||r.matchPatterns[r.defaultMatchWidth],s=e.match(a);if(!s)return null;const o=s[0],c=n&&r.parsePatterns[n]||r.parsePatterns[r.defaultParseWidth],i=Array.isArray(c)?Zt(c,y=>y.test(o)):Ut(c,y=>y.test(o));let d;d=r.valueCallback?r.valueCallback(i):i,d=t.valueCallback?t.valueCallback(d):d;const f=e.slice(o.length);return{value:d,rest:f}}}function Ut(r,e){for(const t in r)if(Object.prototype.hasOwnProperty.call(r,t)&&e(r[t]))return t}function Zt(r,e){for(let t=0;t{const n=e.match(r.matchPattern);if(!n)return null;const a=n[0],s=e.match(r.parsePattern);if(!s)return null;let o=r.valueCallback?r.valueCallback(s[0]):s[0];o=t.valueCallback?t.valueCallback(o):o;const c=e.slice(a.length);return{value:o,rest:c}}}const Kt=/^(\d+)(th|st|nd|rd)?/i,St=/\d+/i,en={narrow:/^(b|a)/i,abbreviated:/^(b\.?\s?c\.?|b\.?\s?c\.?\s?e\.?|a\.?\s?d\.?|c\.?\s?e\.?)/i,wide:/^(before christ|before common era|anno domini|common era)/i},tn={any:[/^b/i,/^(a|c)/i]},nn={narrow:/^[1234]/i,abbreviated:/^q[1234]/i,wide:/^[1234](th|st|nd|rd)? quarter/i},rn={any:[/1/i,/2/i,/3/i,/4/i]},an={narrow:/^[jfmasond]/i,abbreviated:/^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)/i,wide:/^(january|february|march|april|may|june|july|august|september|october|november|december)/i},sn={narrow:[/^j/i,/^f/i,/^m/i,/^a/i,/^m/i,/^j/i,/^j/i,/^a/i,/^s/i,/^o/i,/^n/i,/^d/i],any:[/^ja/i,/^f/i,/^mar/i,/^ap/i,/^may/i,/^jun/i,/^jul/i,/^au/i,/^s/i,/^o/i,/^n/i,/^d/i]},on={narrow:/^[smtwf]/i,short:/^(su|mo|tu|we|th|fr|sa)/i,abbreviated:/^(sun|mon|tue|wed|thu|fri|sat)/i,wide:/^(sunday|monday|tuesday|wednesday|thursday|friday|saturday)/i},cn={narrow:[/^s/i,/^m/i,/^t/i,/^w/i,/^t/i,/^f/i,/^s/i],any:[/^su/i,/^m/i,/^tu/i,/^w/i,/^th/i,/^f/i,/^sa/i]},un={narrow:/^(a|p|mi|n|(in the|at) (morning|afternoon|evening|night))/i,any:/^([ap]\.?\s?m\.?|midnight|noon|(in the|at) (morning|afternoon|evening|night))/i},dn={any:{am:/^a/i,pm:/^p/i,midnight:/^mi/i,noon:/^no/i,morning:/morning/i,afternoon:/afternoon/i,evening:/evening/i,night:/night/i}},ln={ordinalNumber:Jt({matchPattern:Kt,parsePattern:St,valueCallback:r=>parseInt(r,10)}),era:X({matchPatterns:en,defaultMatchWidth:"wide",parsePatterns:tn,defaultParseWidth:"any"}),quarter:X({matchPatterns:nn,defaultMatchWidth:"wide",parsePatterns:rn,defaultParseWidth:"any",valueCallback:r=>r+1}),month:X({matchPatterns:an,defaultMatchWidth:"wide",parsePatterns:sn,defaultParseWidth:"any"}),day:X({matchPatterns:on,defaultMatchWidth:"wide",parsePatterns:cn,defaultParseWidth:"any"}),dayPeriod:X({matchPatterns:un,defaultMatchWidth:"any",parsePatterns:dn,defaultParseWidth:"any"})},He={code:"en-US",formatDistance:qt,formatLong:Lt,formatRelative:Rt,localize:jt,match:ln,options:{weekStartsOn:0,firstWeekContainsDate:1}};function fn(r,e){const t=u(r,e?.in);return Oe(t,Ee(t))+1}function qe(r,e){const t=u(r,e?.in),n=+Q(t)-+ut(t);return Math.round(n/Te)+1}function le(r,e){const t=u(r,e?.in),n=t.getFullYear(),a=F(),s=e?.firstWeekContainsDate??e?.locale?.options?.firstWeekContainsDate??a.firstWeekContainsDate??a.locale?.options?.firstWeekContainsDate??1,o=p(e?.in||r,0);o.setFullYear(n+1,0,s),o.setHours(0,0,0,0);const c=W(o,e),i=p(e?.in||r,0);i.setFullYear(n,0,s),i.setHours(0,0,0,0);const d=W(i,e);return+t>=+c?n+1:+t>=+d?n:n-1}function hn(r,e){const t=F(),n=e?.firstWeekContainsDate??e?.locale?.options?.firstWeekContainsDate??t.firstWeekContainsDate??t.locale?.options?.firstWeekContainsDate??1,a=le(r,e),s=p(e?.in||r,0);return s.setFullYear(a,0,n),s.setHours(0,0,0,0),W(s,e)}function Fe(r,e){const t=u(r,e?.in),n=+W(t,e)-+hn(t,e);return Math.round(n/Te)+1}function m(r,e){const t=r<0?"-":"",n=Math.abs(r).toString().padStart(e,"0");return t+n}const E={y(r,e){const t=r.getFullYear(),n=t>0?t:1-t;return m(e==="yy"?n%100:n,e.length)},M(r,e){const t=r.getMonth();return e==="M"?String(t+1):m(t+1,2)},d(r,e){return m(r.getDate(),e.length)},a(r,e){const t=r.getHours()/12>=1?"pm":"am";switch(e){case"a":case"aa":return t.toUpperCase();case"aaa":return t;case"aaaaa":return t[0];case"aaaa":default:return t==="am"?"a.m.":"p.m."}},h(r,e){return m(r.getHours()%12||12,e.length)},H(r,e){return m(r.getHours(),e.length)},m(r,e){return m(r.getMinutes(),e.length)},s(r,e){return m(r.getSeconds(),e.length)},S(r,e){const t=e.length,n=r.getMilliseconds(),a=Math.trunc(n*Math.pow(10,t-3));return m(a,e.length)}},L={midnight:"midnight",noon:"noon",morning:"morning",afternoon:"afternoon",evening:"evening",night:"night"},xe={G:function(r,e,t){const n=r.getFullYear()>0?1:0;switch(e){case"G":case"GG":case"GGG":return t.era(n,{width:"abbreviated"});case"GGGGG":return t.era(n,{width:"narrow"});case"GGGG":default:return t.era(n,{width:"wide"})}},y:function(r,e,t){if(e==="yo"){const n=r.getFullYear(),a=n>0?n:1-n;return t.ordinalNumber(a,{unit:"year"})}return E.y(r,e)},Y:function(r,e,t,n){const a=le(r,n),s=a>0?a:1-a;if(e==="YY"){const o=s%100;return m(o,2)}return e==="Yo"?t.ordinalNumber(s,{unit:"year"}):m(s,e.length)},R:function(r,e){const t=Pe(r);return m(t,e.length)},u:function(r,e){const t=r.getFullYear();return m(t,e.length)},Q:function(r,e,t){const n=Math.ceil((r.getMonth()+1)/3);switch(e){case"Q":return String(n);case"QQ":return m(n,2);case"Qo":return t.ordinalNumber(n,{unit:"quarter"});case"QQQ":return t.quarter(n,{width:"abbreviated",context:"formatting"});case"QQQQQ":return t.quarter(n,{width:"narrow",context:"formatting"});case"QQQQ":default:return t.quarter(n,{width:"wide",context:"formatting"})}},q:function(r,e,t){const n=Math.ceil((r.getMonth()+1)/3);switch(e){case"q":return String(n);case"qq":return m(n,2);case"qo":return t.ordinalNumber(n,{unit:"quarter"});case"qqq":return t.quarter(n,{width:"abbreviated",context:"standalone"});case"qqqqq":return t.quarter(n,{width:"narrow",context:"standalone"});case"qqqq":default:return t.quarter(n,{width:"wide",context:"standalone"})}},M:function(r,e,t){const n=r.getMonth();switch(e){case"M":case"MM":return E.M(r,e);case"Mo":return t.ordinalNumber(n+1,{unit:"month"});case"MMM":return t.month(n,{width:"abbreviated",context:"formatting"});case"MMMMM":return t.month(n,{width:"narrow",context:"formatting"});case"MMMM":default:return t.month(n,{width:"wide",context:"formatting"})}},L:function(r,e,t){const n=r.getMonth();switch(e){case"L":return String(n+1);case"LL":return m(n+1,2);case"Lo":return t.ordinalNumber(n+1,{unit:"month"});case"LLL":return t.month(n,{width:"abbreviated",context:"standalone"});case"LLLLL":return t.month(n,{width:"narrow",context:"standalone"});case"LLLL":default:return t.month(n,{width:"wide",context:"standalone"})}},w:function(r,e,t,n){const a=Fe(r,n);return e==="wo"?t.ordinalNumber(a,{unit:"week"}):m(a,e.length)},I:function(r,e,t){const n=qe(r);return e==="Io"?t.ordinalNumber(n,{unit:"week"}):m(n,e.length)},d:function(r,e,t){return e==="do"?t.ordinalNumber(r.getDate(),{unit:"date"}):E.d(r,e)},D:function(r,e,t){const n=fn(r);return e==="Do"?t.ordinalNumber(n,{unit:"dayOfYear"}):m(n,e.length)},E:function(r,e,t){const n=r.getDay();switch(e){case"E":case"EE":case"EEE":return t.day(n,{width:"abbreviated",context:"formatting"});case"EEEEE":return t.day(n,{width:"narrow",context:"formatting"});case"EEEEEE":return t.day(n,{width:"short",context:"formatting"});case"EEEE":default:return t.day(n,{width:"wide",context:"formatting"})}},e:function(r,e,t,n){const a=r.getDay(),s=(a-n.weekStartsOn+8)%7||7;switch(e){case"e":return String(s);case"ee":return m(s,2);case"eo":return t.ordinalNumber(s,{unit:"day"});case"eee":return t.day(a,{width:"abbreviated",context:"formatting"});case"eeeee":return t.day(a,{width:"narrow",context:"formatting"});case"eeeeee":return t.day(a,{width:"short",context:"formatting"});case"eeee":default:return t.day(a,{width:"wide",context:"formatting"})}},c:function(r,e,t,n){const a=r.getDay(),s=(a-n.weekStartsOn+8)%7||7;switch(e){case"c":return String(s);case"cc":return m(s,e.length);case"co":return t.ordinalNumber(s,{unit:"day"});case"ccc":return t.day(a,{width:"abbreviated",context:"standalone"});case"ccccc":return t.day(a,{width:"narrow",context:"standalone"});case"cccccc":return t.day(a,{width:"short",context:"standalone"});case"cccc":default:return t.day(a,{width:"wide",context:"standalone"})}},i:function(r,e,t){const n=r.getDay(),a=n===0?7:n;switch(e){case"i":return String(a);case"ii":return m(a,e.length);case"io":return t.ordinalNumber(a,{unit:"day"});case"iii":return t.day(n,{width:"abbreviated",context:"formatting"});case"iiiii":return t.day(n,{width:"narrow",context:"formatting"});case"iiiiii":return t.day(n,{width:"short",context:"formatting"});case"iiii":default:return t.day(n,{width:"wide",context:"formatting"})}},a:function(r,e,t){const a=r.getHours()/12>=1?"pm":"am";switch(e){case"a":case"aa":return t.dayPeriod(a,{width:"abbreviated",context:"formatting"});case"aaa":return t.dayPeriod(a,{width:"abbreviated",context:"formatting"}).toLowerCase();case"aaaaa":return t.dayPeriod(a,{width:"narrow",context:"formatting"});case"aaaa":default:return t.dayPeriod(a,{width:"wide",context:"formatting"})}},b:function(r,e,t){const n=r.getHours();let a;switch(n===12?a=L.noon:n===0?a=L.midnight:a=n/12>=1?"pm":"am",e){case"b":case"bb":return t.dayPeriod(a,{width:"abbreviated",context:"formatting"});case"bbb":return t.dayPeriod(a,{width:"abbreviated",context:"formatting"}).toLowerCase();case"bbbbb":return t.dayPeriod(a,{width:"narrow",context:"formatting"});case"bbbb":default:return t.dayPeriod(a,{width:"wide",context:"formatting"})}},B:function(r,e,t){const n=r.getHours();let a;switch(n>=17?a=L.evening:n>=12?a=L.afternoon:n>=4?a=L.morning:a=L.night,e){case"B":case"BB":case"BBB":return t.dayPeriod(a,{width:"abbreviated",context:"formatting"});case"BBBBB":return t.dayPeriod(a,{width:"narrow",context:"formatting"});case"BBBB":default:return t.dayPeriod(a,{width:"wide",context:"formatting"})}},h:function(r,e,t){if(e==="ho"){let n=r.getHours()%12;return n===0&&(n=12),t.ordinalNumber(n,{unit:"hour"})}return E.h(r,e)},H:function(r,e,t){return e==="Ho"?t.ordinalNumber(r.getHours(),{unit:"hour"}):E.H(r,e)},K:function(r,e,t){const n=r.getHours()%12;return e==="Ko"?t.ordinalNumber(n,{unit:"hour"}):m(n,e.length)},k:function(r,e,t){let n=r.getHours();return n===0&&(n=24),e==="ko"?t.ordinalNumber(n,{unit:"hour"}):m(n,e.length)},m:function(r,e,t){return e==="mo"?t.ordinalNumber(r.getMinutes(),{unit:"minute"}):E.m(r,e)},s:function(r,e,t){return e==="so"?t.ordinalNumber(r.getSeconds(),{unit:"second"}):E.s(r,e)},S:function(r,e){return E.S(r,e)},X:function(r,e,t){const n=r.getTimezoneOffset();if(n===0)return"Z";switch(e){case"X":return De(n);case"XXXX":case"XX":return q(n);case"XXXXX":case"XXX":default:return q(n,":")}},x:function(r,e,t){const n=r.getTimezoneOffset();switch(e){case"x":return De(n);case"xxxx":case"xx":return q(n);case"xxxxx":case"xxx":default:return q(n,":")}},O:function(r,e,t){const n=r.getTimezoneOffset();switch(e){case"O":case"OO":case"OOO":return"GMT"+Me(n,":");case"OOOO":default:return"GMT"+q(n,":")}},z:function(r,e,t){const n=r.getTimezoneOffset();switch(e){case"z":case"zz":case"zzz":return"GMT"+Me(n,":");case"zzzz":default:return"GMT"+q(n,":")}},t:function(r,e,t){const n=Math.trunc(+r/1e3);return m(n,e.length)},T:function(r,e,t){return m(+r,e.length)}};function Me(r,e=""){const t=r>0?"-":"+",n=Math.abs(r),a=Math.trunc(n/60),s=n%60;return s===0?t+String(a):t+String(a)+e+m(s,2)}function De(r,e){return r%60===0?(r>0?"-":"+")+m(Math.abs(r)/60,2):q(r,e)}function q(r,e=""){const t=r>0?"-":"+",n=Math.abs(r),a=m(Math.trunc(n/60),2),s=m(n%60,2);return t+a+e+s}const ke=(r,e)=>{switch(r){case"P":return e.date({width:"short"});case"PP":return e.date({width:"medium"});case"PPP":return e.date({width:"long"});case"PPPP":default:return e.date({width:"full"})}},Ce=(r,e)=>{switch(r){case"p":return e.time({width:"short"});case"pp":return e.time({width:"medium"});case"ppp":return e.time({width:"long"});case"pppp":default:return e.time({width:"full"})}},mn=(r,e)=>{const t=r.match(/(P+)(p+)?/)||[],n=t[1],a=t[2];if(!a)return ke(r,e);let s;switch(n){case"P":s=e.dateTime({width:"short"});break;case"PP":s=e.dateTime({width:"medium"});break;case"PPP":s=e.dateTime({width:"long"});break;case"PPPP":default:s=e.dateTime({width:"full"});break}return s.replace("{{date}}",ke(n,e)).replace("{{time}}",Ce(a,e))},oe={p:Ce,P:mn},wn=/^D+$/,yn=/^Y+$/,gn=["D","DD","YY","YYYY"];function Ie(r){return wn.test(r)}function Le(r){return yn.test(r)}function ie(r,e,t){const n=pn(r,e,t);if(console.warn(n),gn.includes(r))throw new RangeError(n)}function pn(r,e,t){const n=r[0]==="Y"?"years":"days of the month";return`Use \`${r.toLowerCase()}\` instead of \`${r}\` (in \`${e}\`) for formatting ${n} to the input \`${t}\`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md`}const bn=/[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g,xn=/P+p+|P+|p+|''|'(''|[^'])+('|$)|./g,Mn=/^'([^]*?)'?$/,Dn=/''/g,kn=/[a-zA-Z]/;function Tn(r,e,t){const n=F(),a=t?.locale??n.locale??He,s=t?.firstWeekContainsDate??t?.locale?.options?.firstWeekContainsDate??n.firstWeekContainsDate??n.locale?.options?.firstWeekContainsDate??1,o=t?.weekStartsOn??t?.locale?.options?.weekStartsOn??n.weekStartsOn??n.locale?.options?.weekStartsOn??0,c=u(r,t?.in);if(!Ye(c))throw new RangeError("Invalid time value");let i=e.match(xn).map(f=>{const y=f[0];if(y==="p"||y==="P"){const T=oe[y];return T(f,a.formatLong)}return f}).join("").match(bn).map(f=>{if(f==="''")return{isToken:!1,value:"'"};const y=f[0];if(y==="'")return{isToken:!1,value:Pn(f)};if(xe[y])return{isToken:!0,value:f};if(y.match(kn))throw new RangeError("Format string contains an unescaped latin alphabet character `"+y+"`");return{isToken:!1,value:f}});a.localize.preprocessor&&(i=a.localize.preprocessor(c,i));const d={firstWeekContainsDate:s,weekStartsOn:o,locale:a};return i.map(f=>{if(!f.isToken)return f.value;const y=f.value;(!t?.useAdditionalWeekYearTokens&&Le(y)||!t?.useAdditionalDayOfYearTokens&&Ie(y))&&ie(y,e,String(r));const T=xe[y[0]];return T(c,y,a.localize,d)}).join("")}function Pn(r){const e=r.match(Mn);return e?e[1].replace(Dn,"'"):r}function On(){return Object.assign({},F())}function Yn(r,e){const t=u(r,e?.in).getDay();return t===0?7:t}function vn(r,e){const t=_n(e)?new e(0):p(e,0);return t.setFullYear(r.getFullYear(),r.getMonth(),r.getDate()),t.setHours(r.getHours(),r.getMinutes(),r.getSeconds(),r.getMilliseconds()),t}function _n(r){return typeof r=="function"&&r.prototype?.constructor===r}const Wn=10;class Qe{subPriority=0;validate(e,t){return!0}}class Nn extends Qe{constructor(e,t,n,a,s){super(),this.value=e,this.validateValue=t,this.setValue=n,this.priority=a,s&&(this.subPriority=s)}validate(e,t){return this.validateValue(e,this.value,t)}set(e,t,n){return this.setValue(e,t,this.value,n)}}class En extends Qe{priority=Wn;subPriority=-1;constructor(e,t){super(),this.context=e||(n=>p(t,n))}set(e,t){return t.timestampIsSet?e:p(e,vn(e,this.context))}}class h{run(e,t,n,a){const s=this.parse(e,t,n,a);return s?{setter:new Nn(s.value,this.validate,this.set,this.priority,this.subPriority),rest:s.rest}:null}validate(e,t,n){return!0}}class Hn extends h{priority=140;parse(e,t,n){switch(t){case"G":case"GG":case"GGG":return n.era(e,{width:"abbreviated"})||n.era(e,{width:"narrow"});case"GGGGG":return n.era(e,{width:"narrow"});case"GGGG":default:return n.era(e,{width:"wide"})||n.era(e,{width:"abbreviated"})||n.era(e,{width:"narrow"})}}set(e,t,n){return t.era=n,e.setFullYear(n,0,1),e.setHours(0,0,0,0),e}incompatibleTokens=["R","u","t","T"]}const x={month:/^(1[0-2]|0?\d)/,date:/^(3[0-1]|[0-2]?\d)/,dayOfYear:/^(36[0-6]|3[0-5]\d|[0-2]?\d?\d)/,week:/^(5[0-3]|[0-4]?\d)/,hour23h:/^(2[0-3]|[0-1]?\d)/,hour24h:/^(2[0-4]|[0-1]?\d)/,hour11h:/^(1[0-1]|0?\d)/,hour12h:/^(1[0-2]|0?\d)/,minute:/^[0-5]?\d/,second:/^[0-5]?\d/,singleDigit:/^\d/,twoDigits:/^\d{1,2}/,threeDigits:/^\d{1,3}/,fourDigits:/^\d{1,4}/,anyDigitsSigned:/^-?\d+/,singleDigitSigned:/^-?\d/,twoDigitsSigned:/^-?\d{1,2}/,threeDigitsSigned:/^-?\d{1,3}/,fourDigitsSigned:/^-?\d{1,4}/},v={basicOptionalMinutes:/^([+-])(\d{2})(\d{2})?|Z/,basic:/^([+-])(\d{2})(\d{2})|Z/,basicOptionalSeconds:/^([+-])(\d{2})(\d{2})((\d{2}))?|Z/,extended:/^([+-])(\d{2}):(\d{2})|Z/,extendedOptionalSeconds:/^([+-])(\d{2}):(\d{2})(:(\d{2}))?|Z/};function M(r,e){return r&&{value:e(r.value),rest:r.rest}}function g(r,e){const t=e.match(r);return t?{value:parseInt(t[0],10),rest:e.slice(t[0].length)}:null}function _(r,e){const t=e.match(r);if(!t)return null;if(t[0]==="Z")return{value:0,rest:e.slice(1)};const n=t[1]==="+"?1:-1,a=t[2]?parseInt(t[2],10):0,s=t[3]?parseInt(t[3],10):0,o=t[5]?parseInt(t[5],10):0;return{value:n*(a*V+s*G+o*ot),rest:e.slice(t[0].length)}}function Re(r){return g(x.anyDigitsSigned,r)}function b(r,e){switch(r){case 1:return g(x.singleDigit,e);case 2:return g(x.twoDigits,e);case 3:return g(x.threeDigits,e);case 4:return g(x.fourDigits,e);default:return g(new RegExp("^\\d{1,"+r+"}"),e)}}function te(r,e){switch(r){case 1:return g(x.singleDigitSigned,e);case 2:return g(x.twoDigitsSigned,e);case 3:return g(x.threeDigitsSigned,e);case 4:return g(x.fourDigitsSigned,e);default:return g(new RegExp("^-?\\d{1,"+r+"}"),e)}}function fe(r){switch(r){case"morning":return 4;case"evening":return 17;case"pm":case"noon":case"afternoon":return 12;case"am":case"midnight":case"night":default:return 0}}function Be(r,e){const t=e>0,n=t?e:1-e;let a;if(n<=50)a=r||100;else{const s=n+50,o=Math.trunc(s/100)*100,c=r>=s%100;a=r+o-(c?100:0)}return t?a:1-a}function Xe(r){return r%400===0||r%4===0&&r%100!==0}class qn extends h{priority=130;incompatibleTokens=["Y","R","u","w","I","i","e","c","t","T"];parse(e,t,n){const a=s=>({year:s,isTwoDigitYear:t==="yy"});switch(t){case"y":return M(b(4,e),a);case"yo":return M(n.ordinalNumber(e,{unit:"year"}),a);default:return M(b(t.length,e),a)}}validate(e,t){return t.isTwoDigitYear||t.year>0}set(e,t,n){const a=e.getFullYear();if(n.isTwoDigitYear){const o=Be(n.year,a);return e.setFullYear(o,0,1),e.setHours(0,0,0,0),e}const s=!("era"in t)||t.era===1?n.year:1-n.year;return e.setFullYear(s,0,1),e.setHours(0,0,0,0),e}}class Fn extends h{priority=130;parse(e,t,n){const a=s=>({year:s,isTwoDigitYear:t==="YY"});switch(t){case"Y":return M(b(4,e),a);case"Yo":return M(n.ordinalNumber(e,{unit:"year"}),a);default:return M(b(t.length,e),a)}}validate(e,t){return t.isTwoDigitYear||t.year>0}set(e,t,n,a){const s=le(e,a);if(n.isTwoDigitYear){const c=Be(n.year,s);return e.setFullYear(c,0,a.firstWeekContainsDate),e.setHours(0,0,0,0),W(e,a)}const o=!("era"in t)||t.era===1?n.year:1-n.year;return e.setFullYear(o,0,a.firstWeekContainsDate),e.setHours(0,0,0,0),W(e,a)}incompatibleTokens=["y","R","u","Q","q","M","L","I","d","D","i","t","T"]}class Cn extends h{priority=130;parse(e,t){return te(t==="R"?4:t.length,e)}set(e,t,n){const a=p(e,0);return a.setFullYear(n,0,4),a.setHours(0,0,0,0),Q(a)}incompatibleTokens=["G","y","Y","u","Q","q","M","L","w","d","D","e","c","t","T"]}class In extends h{priority=130;parse(e,t){return te(t==="u"?4:t.length,e)}set(e,t,n){return e.setFullYear(n,0,1),e.setHours(0,0,0,0),e}incompatibleTokens=["G","y","Y","R","w","I","i","e","c","t","T"]}class Ln extends h{priority=120;parse(e,t,n){switch(t){case"Q":case"QQ":return b(t.length,e);case"Qo":return n.ordinalNumber(e,{unit:"quarter"});case"QQQ":return n.quarter(e,{width:"abbreviated",context:"formatting"})||n.quarter(e,{width:"narrow",context:"formatting"});case"QQQQQ":return n.quarter(e,{width:"narrow",context:"formatting"});case"QQQQ":default:return n.quarter(e,{width:"wide",context:"formatting"})||n.quarter(e,{width:"abbreviated",context:"formatting"})||n.quarter(e,{width:"narrow",context:"formatting"})}}validate(e,t){return t>=1&&t<=4}set(e,t,n){return e.setMonth((n-1)*3,1),e.setHours(0,0,0,0),e}incompatibleTokens=["Y","R","q","M","L","w","I","d","D","i","e","c","t","T"]}class Qn extends h{priority=120;parse(e,t,n){switch(t){case"q":case"qq":return b(t.length,e);case"qo":return n.ordinalNumber(e,{unit:"quarter"});case"qqq":return n.quarter(e,{width:"abbreviated",context:"standalone"})||n.quarter(e,{width:"narrow",context:"standalone"});case"qqqqq":return n.quarter(e,{width:"narrow",context:"standalone"});case"qqqq":default:return n.quarter(e,{width:"wide",context:"standalone"})||n.quarter(e,{width:"abbreviated",context:"standalone"})||n.quarter(e,{width:"narrow",context:"standalone"})}}validate(e,t){return t>=1&&t<=4}set(e,t,n){return e.setMonth((n-1)*3,1),e.setHours(0,0,0,0),e}incompatibleTokens=["Y","R","Q","M","L","w","I","d","D","i","e","c","t","T"]}class Rn extends h{incompatibleTokens=["Y","R","q","Q","L","w","I","D","i","e","c","t","T"];priority=110;parse(e,t,n){const a=s=>s-1;switch(t){case"M":return M(g(x.month,e),a);case"MM":return M(b(2,e),a);case"Mo":return M(n.ordinalNumber(e,{unit:"month"}),a);case"MMM":return n.month(e,{width:"abbreviated",context:"formatting"})||n.month(e,{width:"narrow",context:"formatting"});case"MMMMM":return n.month(e,{width:"narrow",context:"formatting"});case"MMMM":default:return n.month(e,{width:"wide",context:"formatting"})||n.month(e,{width:"abbreviated",context:"formatting"})||n.month(e,{width:"narrow",context:"formatting"})}}validate(e,t){return t>=0&&t<=11}set(e,t,n){return e.setMonth(n,1),e.setHours(0,0,0,0),e}}class Bn extends h{priority=110;parse(e,t,n){const a=s=>s-1;switch(t){case"L":return M(g(x.month,e),a);case"LL":return M(b(2,e),a);case"Lo":return M(n.ordinalNumber(e,{unit:"month"}),a);case"LLL":return n.month(e,{width:"abbreviated",context:"standalone"})||n.month(e,{width:"narrow",context:"standalone"});case"LLLLL":return n.month(e,{width:"narrow",context:"standalone"});case"LLLL":default:return n.month(e,{width:"wide",context:"standalone"})||n.month(e,{width:"abbreviated",context:"standalone"})||n.month(e,{width:"narrow",context:"standalone"})}}validate(e,t){return t>=0&&t<=11}set(e,t,n){return e.setMonth(n,1),e.setHours(0,0,0,0),e}incompatibleTokens=["Y","R","q","Q","M","w","I","D","i","e","c","t","T"]}function Xn(r,e,t){const n=u(r,t?.in),a=Fe(n,t)-e;return n.setDate(n.getDate()-a*7),u(n,t?.in)}class $n extends h{priority=100;parse(e,t,n){switch(t){case"w":return g(x.week,e);case"wo":return n.ordinalNumber(e,{unit:"week"});default:return b(t.length,e)}}validate(e,t){return t>=1&&t<=53}set(e,t,n,a){return W(Xn(e,n,a),a)}incompatibleTokens=["y","R","u","q","Q","M","L","I","d","D","i","t","T"]}function An(r,e,t){const n=u(r,t?.in),a=qe(n,t)-e;return n.setDate(n.getDate()-a*7),n}class Gn extends h{priority=100;parse(e,t,n){switch(t){case"I":return g(x.week,e);case"Io":return n.ordinalNumber(e,{unit:"week"});default:return b(t.length,e)}}validate(e,t){return t>=1&&t<=53}set(e,t,n){return Q(An(e,n))}incompatibleTokens=["y","Y","u","q","Q","M","L","w","d","D","e","c","t","T"]}const Vn=[31,28,31,30,31,30,31,31,30,31,30,31],zn=[31,29,31,30,31,30,31,31,30,31,30,31];class jn extends h{priority=90;subPriority=1;parse(e,t,n){switch(t){case"d":return g(x.date,e);case"do":return n.ordinalNumber(e,{unit:"date"});default:return b(t.length,e)}}validate(e,t){const n=e.getFullYear(),a=Xe(n),s=e.getMonth();return a?t>=1&&t<=zn[s]:t>=1&&t<=Vn[s]}set(e,t,n){return e.setDate(n),e.setHours(0,0,0,0),e}incompatibleTokens=["Y","R","q","Q","w","I","D","i","e","c","t","T"]}class Un extends h{priority=90;subpriority=1;parse(e,t,n){switch(t){case"D":case"DD":return g(x.dayOfYear,e);case"Do":return n.ordinalNumber(e,{unit:"date"});default:return b(t.length,e)}}validate(e,t){const n=e.getFullYear();return Xe(n)?t>=1&&t<=366:t>=1&&t<=365}set(e,t,n){return e.setMonth(0,n),e.setHours(0,0,0,0),e}incompatibleTokens=["Y","R","q","Q","M","L","w","I","d","E","i","e","c","t","T"]}function he(r,e,t){const n=F(),a=t?.weekStartsOn??t?.locale?.options?.weekStartsOn??n.weekStartsOn??n.locale?.options?.weekStartsOn??0,s=u(r,t?.in),o=s.getDay(),i=(e%7+7)%7,d=7-a,f=e<0||e>6?e-(o+d)%7:(i+d)%7-(o+d)%7;return ne(s,f,t)}class Zn extends h{priority=90;parse(e,t,n){switch(t){case"E":case"EE":case"EEE":return n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"});case"EEEEE":return n.day(e,{width:"narrow",context:"formatting"});case"EEEEEE":return n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"});case"EEEE":default:return n.day(e,{width:"wide",context:"formatting"})||n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"})}}validate(e,t){return t>=0&&t<=6}set(e,t,n,a){return e=he(e,n,a),e.setHours(0,0,0,0),e}incompatibleTokens=["D","i","e","c","t","T"]}class Jn extends h{priority=90;parse(e,t,n,a){const s=o=>{const c=Math.floor((o-1)/7)*7;return(o+a.weekStartsOn+6)%7+c};switch(t){case"e":case"ee":return M(b(t.length,e),s);case"eo":return M(n.ordinalNumber(e,{unit:"day"}),s);case"eee":return n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"});case"eeeee":return n.day(e,{width:"narrow",context:"formatting"});case"eeeeee":return n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"});case"eeee":default:return n.day(e,{width:"wide",context:"formatting"})||n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"})}}validate(e,t){return t>=0&&t<=6}set(e,t,n,a){return e=he(e,n,a),e.setHours(0,0,0,0),e}incompatibleTokens=["y","R","u","q","Q","M","L","I","d","D","E","i","c","t","T"]}class Kn extends h{priority=90;parse(e,t,n,a){const s=o=>{const c=Math.floor((o-1)/7)*7;return(o+a.weekStartsOn+6)%7+c};switch(t){case"c":case"cc":return M(b(t.length,e),s);case"co":return M(n.ordinalNumber(e,{unit:"day"}),s);case"ccc":return n.day(e,{width:"abbreviated",context:"standalone"})||n.day(e,{width:"short",context:"standalone"})||n.day(e,{width:"narrow",context:"standalone"});case"ccccc":return n.day(e,{width:"narrow",context:"standalone"});case"cccccc":return n.day(e,{width:"short",context:"standalone"})||n.day(e,{width:"narrow",context:"standalone"});case"cccc":default:return n.day(e,{width:"wide",context:"standalone"})||n.day(e,{width:"abbreviated",context:"standalone"})||n.day(e,{width:"short",context:"standalone"})||n.day(e,{width:"narrow",context:"standalone"})}}validate(e,t){return t>=0&&t<=6}set(e,t,n,a){return e=he(e,n,a),e.setHours(0,0,0,0),e}incompatibleTokens=["y","R","u","q","Q","M","L","I","d","D","E","i","e","t","T"]}function Sn(r,e,t){const n=u(r,t?.in),a=Yn(n,t),s=e-a;return ne(n,s,t)}class er extends h{priority=90;parse(e,t,n){const a=s=>s===0?7:s;switch(t){case"i":case"ii":return b(t.length,e);case"io":return n.ordinalNumber(e,{unit:"day"});case"iii":return M(n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"}),a);case"iiiii":return M(n.day(e,{width:"narrow",context:"formatting"}),a);case"iiiiii":return M(n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"}),a);case"iiii":default:return M(n.day(e,{width:"wide",context:"formatting"})||n.day(e,{width:"abbreviated",context:"formatting"})||n.day(e,{width:"short",context:"formatting"})||n.day(e,{width:"narrow",context:"formatting"}),a)}}validate(e,t){return t>=1&&t<=7}set(e,t,n){return e=Sn(e,n),e.setHours(0,0,0,0),e}incompatibleTokens=["y","Y","u","q","Q","M","L","w","d","D","E","e","c","t","T"]}class tr extends h{priority=80;parse(e,t,n){switch(t){case"a":case"aa":case"aaa":return n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"});case"aaaaa":return n.dayPeriod(e,{width:"narrow",context:"formatting"});case"aaaa":default:return n.dayPeriod(e,{width:"wide",context:"formatting"})||n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"})}}set(e,t,n){return e.setHours(fe(n),0,0,0),e}incompatibleTokens=["b","B","H","k","t","T"]}class nr extends h{priority=80;parse(e,t,n){switch(t){case"b":case"bb":case"bbb":return n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"});case"bbbbb":return n.dayPeriod(e,{width:"narrow",context:"formatting"});case"bbbb":default:return n.dayPeriod(e,{width:"wide",context:"formatting"})||n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"})}}set(e,t,n){return e.setHours(fe(n),0,0,0),e}incompatibleTokens=["a","B","H","k","t","T"]}class rr extends h{priority=80;parse(e,t,n){switch(t){case"B":case"BB":case"BBB":return n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"});case"BBBBB":return n.dayPeriod(e,{width:"narrow",context:"formatting"});case"BBBB":default:return n.dayPeriod(e,{width:"wide",context:"formatting"})||n.dayPeriod(e,{width:"abbreviated",context:"formatting"})||n.dayPeriod(e,{width:"narrow",context:"formatting"})}}set(e,t,n){return e.setHours(fe(n),0,0,0),e}incompatibleTokens=["a","b","t","T"]}class ar extends h{priority=70;parse(e,t,n){switch(t){case"h":return g(x.hour12h,e);case"ho":return n.ordinalNumber(e,{unit:"hour"});default:return b(t.length,e)}}validate(e,t){return t>=1&&t<=12}set(e,t,n){const a=e.getHours()>=12;return a&&n<12?e.setHours(n+12,0,0,0):!a&&n===12?e.setHours(0,0,0,0):e.setHours(n,0,0,0),e}incompatibleTokens=["H","K","k","t","T"]}class sr extends h{priority=70;parse(e,t,n){switch(t){case"H":return g(x.hour23h,e);case"Ho":return n.ordinalNumber(e,{unit:"hour"});default:return b(t.length,e)}}validate(e,t){return t>=0&&t<=23}set(e,t,n){return e.setHours(n,0,0,0),e}incompatibleTokens=["a","b","h","K","k","t","T"]}class or extends h{priority=70;parse(e,t,n){switch(t){case"K":return g(x.hour11h,e);case"Ko":return n.ordinalNumber(e,{unit:"hour"});default:return b(t.length,e)}}validate(e,t){return t>=0&&t<=11}set(e,t,n){return e.getHours()>=12&&n<12?e.setHours(n+12,0,0,0):e.setHours(n,0,0,0),e}incompatibleTokens=["h","H","k","t","T"]}class ir extends h{priority=70;parse(e,t,n){switch(t){case"k":return g(x.hour24h,e);case"ko":return n.ordinalNumber(e,{unit:"hour"});default:return b(t.length,e)}}validate(e,t){return t>=1&&t<=24}set(e,t,n){const a=n<=24?n%24:n;return e.setHours(a,0,0,0),e}incompatibleTokens=["a","b","h","H","K","t","T"]}class cr extends h{priority=60;parse(e,t,n){switch(t){case"m":return g(x.minute,e);case"mo":return n.ordinalNumber(e,{unit:"minute"});default:return b(t.length,e)}}validate(e,t){return t>=0&&t<=59}set(e,t,n){return e.setMinutes(n,0,0),e}incompatibleTokens=["t","T"]}class ur extends h{priority=50;parse(e,t,n){switch(t){case"s":return g(x.second,e);case"so":return n.ordinalNumber(e,{unit:"second"});default:return b(t.length,e)}}validate(e,t){return t>=0&&t<=59}set(e,t,n){return e.setSeconds(n,0),e}incompatibleTokens=["t","T"]}class dr extends h{priority=30;parse(e,t){const n=a=>Math.trunc(a*Math.pow(10,-t.length+3));return M(b(t.length,e),n)}set(e,t,n){return e.setMilliseconds(n),e}incompatibleTokens=["t","T"]}class lr extends h{priority=10;parse(e,t){switch(t){case"X":return _(v.basicOptionalMinutes,e);case"XX":return _(v.basic,e);case"XXXX":return _(v.basicOptionalSeconds,e);case"XXXXX":return _(v.extendedOptionalSeconds,e);case"XXX":default:return _(v.extended,e)}}set(e,t,n){return t.timestampIsSet?e:p(e,e.getTime()-ee(e)-n)}incompatibleTokens=["t","T","x"]}class fr extends h{priority=10;parse(e,t){switch(t){case"x":return _(v.basicOptionalMinutes,e);case"xx":return _(v.basic,e);case"xxxx":return _(v.basicOptionalSeconds,e);case"xxxxx":return _(v.extendedOptionalSeconds,e);case"xxx":default:return _(v.extended,e)}}set(e,t,n){return t.timestampIsSet?e:p(e,e.getTime()-ee(e)-n)}incompatibleTokens=["t","T","X"]}class hr extends h{priority=40;parse(e){return Re(e)}set(e,t,n){return[p(e,n*1e3),{timestampIsSet:!0}]}incompatibleTokens="*"}class mr extends h{priority=20;parse(e){return Re(e)}set(e,t,n){return[p(e,n),{timestampIsSet:!0}]}incompatibleTokens="*"}const wr={G:new Hn,y:new qn,Y:new Fn,R:new Cn,u:new In,Q:new Ln,q:new Qn,M:new Rn,L:new Bn,w:new $n,I:new Gn,d:new jn,D:new Un,E:new Zn,e:new Jn,c:new Kn,i:new er,a:new tr,b:new nr,B:new rr,h:new ar,H:new sr,K:new or,k:new ir,m:new cr,s:new ur,S:new dr,X:new lr,x:new fr,t:new hr,T:new mr},yr=/[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g,gr=/P+p+|P+|p+|''|'(''|[^'])+('|$)|./g,pr=/^'([^]*?)'?$/,br=/''/g,xr=/\S/,Mr=/[a-zA-Z]/;function Dr(r,e,t,n){const a=()=>p(n?.in||t,NaN),s=On(),o=n?.locale??s.locale??He,c=n?.firstWeekContainsDate??n?.locale?.options?.firstWeekContainsDate??s.firstWeekContainsDate??s.locale?.options?.firstWeekContainsDate??1,i=n?.weekStartsOn??n?.locale?.options?.weekStartsOn??s.weekStartsOn??s.locale?.options?.weekStartsOn??0;if(!e)return r?a():u(t,n?.in);const d={firstWeekContainsDate:c,weekStartsOn:i,locale:o},f=[new En(n?.in,t)],y=e.match(gr).map(l=>{const w=l[0];if(w in oe){const k=oe[w];return k(l,o.formatLong)}return l}).join("").match(yr),T=[];for(let l of y){!n?.useAdditionalWeekYearTokens&&Le(l)&&ie(l,e,r),!n?.useAdditionalDayOfYearTokens&&Ie(l)&&ie(l,e,r);const w=l[0],k=wr[w];if(k){const{incompatibleTokens:R}=k;if(Array.isArray(R)){const me=T.find(we=>R.includes(we.token)||we.token===w);if(me)throw new RangeError(`The format string mustn't contain \`${me.fullToken}\` and \`${l}\` at the same time`)}else if(k.incompatibleTokens==="*"&&T.length>0)throw new RangeError(`The format string mustn't contain \`${l}\` and any other token at the same time`);T.push({token:w,fullToken:l});const H=k.run(r,l,o.match,d);if(!H)return a();f.push(H.setter),r=H.rest}else{if(w.match(Mr))throw new RangeError("Format string contains an unescaped latin alphabet character `"+w+"`");if(l==="''"?l="'":w==="'"&&(l=kr(l)),r.indexOf(l)===0)r=r.slice(l.length);else return a()}}if(r.length>0&&xr.test(r))return a();const N=f.map(l=>l.priority).sort((l,w)=>w-l).filter((l,w,k)=>k.indexOf(l)===w).map(l=>f.filter(w=>w.priority===l).sort((w,k)=>k.subPriority-w.subPriority)).map(l=>l[0]);let D=u(t,n?.in);if(isNaN(+D))return a();const P={};for(const l of N){if(!l.validate(D,d))return a();const w=l.set(D,P,d);Array.isArray(w)?(D=w[0],Object.assign(P,w[1])):D=w}return D}function kr(r){return r.match(pr)[1].replace(br,"'")}function Tr(r,e){const t=u(r,e?.in);return t.setMinutes(0,0,0),t}function Pr(r,e){const t=u(r,e?.in);return t.setSeconds(0,0),t}function Or(r,e){const t=u(r,e?.in);return t.setMilliseconds(0),t}function Yr(r,e){const t=()=>p(e?.in,NaN),n=e?.additionalDigits??2,a=Nr(r);let s;if(a.date){const d=Er(a.date,n);s=Hr(d.restDateString,d.year)}if(!s||isNaN(+s))return t();const o=+s;let c=0,i;if(a.time&&(c=qr(a.time),isNaN(c)))return t();if(a.timezone){if(i=Fr(a.timezone),isNaN(i))return t()}else{const d=new Date(o+c),f=u(0,e?.in);return f.setFullYear(d.getUTCFullYear(),d.getUTCMonth(),d.getUTCDate()),f.setHours(d.getUTCHours(),d.getUTCMinutes(),d.getUTCSeconds(),d.getUTCMilliseconds()),f}return u(o+c+i,e?.in)}const S={dateTimeDelimiter:/[T ]/,timeZoneDelimiter:/[Z ]/i,timezone:/([Z+-].*)$/},vr=/^-?(?:(\d{3})|(\d{2})(?:-?(\d{2}))?|W(\d{2})(?:-?(\d{1}))?|)$/,_r=/^(\d{2}(?:[.,]\d*)?)(?::?(\d{2}(?:[.,]\d*)?))?(?::?(\d{2}(?:[.,]\d*)?))?$/,Wr=/^([+-])(\d{2})(?::?(\d{2}))?$/;function Nr(r){const e={},t=r.split(S.dateTimeDelimiter);let n;if(t.length>2)return e;if(/:/.test(t[0])?n=t[0]:(e.date=t[0],n=t[1],S.timeZoneDelimiter.test(e.date)&&(e.date=r.split(S.timeZoneDelimiter)[0],n=r.substr(e.date.length,r.length))),n){const a=S.timezone.exec(n);a?(e.time=n.replace(a[1],""),e.timezone=a[1]):e.time=n}return e}function Er(r,e){const t=new RegExp("^(?:(\\d{4}|[+-]\\d{"+(4+e)+"})|(\\d{2}|[+-]\\d{"+(2+e)+"})$)"),n=r.match(t);if(!n)return{year:NaN,restDateString:""};const a=n[1]?parseInt(n[1]):null,s=n[2]?parseInt(n[2]):null;return{year:s===null?a:s*100,restDateString:r.slice((n[1]||n[2]).length)}}function Hr(r,e){if(e===null)return new Date(NaN);const t=r.match(vr);if(!t)return new Date(NaN);const n=!!t[4],a=$(t[1]),s=$(t[2])-1,o=$(t[3]),c=$(t[4]),i=$(t[5])-1;if(n)return Rr(e,c,i)?Cr(e,c,i):new Date(NaN);{const d=new Date(0);return!Lr(e,s,o)||!Qr(e,a)?new Date(NaN):(d.setUTCFullYear(e,s,Math.max(a,o)),d)}}function $(r){return r?parseInt(r):1}function qr(r){const e=r.match(_r);if(!e)return NaN;const t=ae(e[1]),n=ae(e[2]),a=ae(e[3]);return Br(t,n,a)?t*V+n*G+a*1e3:NaN}function ae(r){return r&&parseFloat(r.replace(",","."))||0}function Fr(r){if(r==="Z")return 0;const e=r.match(Wr);if(!e)return 0;const t=e[1]==="+"?-1:1,n=parseInt(e[2]),a=e[3]&&parseInt(e[3])||0;return Xr(n,a)?t*(n*V+a*G):NaN}function Cr(r,e,t){const n=new Date(0);n.setUTCFullYear(r,0,4);const a=n.getUTCDay()||7,s=(e-1)*7+t+1-a;return n.setUTCDate(n.getUTCDate()+s),n}const Ir=[31,null,31,30,31,30,31,31,30,31,30,31];function $e(r){return r%400===0||r%4===0&&r%100!==0}function Lr(r,e,t){return e>=0&&e<=11&&t>=1&&t<=(Ir[e]||($e(r)?29:28))}function Qr(r,e){return e>=1&&e<=($e(r)?366:365)}function Rr(r,e,t){return e>=1&&e<=53&&t>=0&&t<=6}function Br(r,e,t){return r===24?e===0&&t===0:t>=0&&t<60&&e>=0&&e<60&&r>=0&&r<25}function Xr(r,e){return e>=0&&e<=59}/*! * chartjs-adapter-date-fns v3.0.0 * https://www.chartjs.org * (c) 2022 chartjs-adapter-date-fns Contributors diff --git a/repeater/web/html/assets/index-D-3p9FIW.css b/repeater/web/html/assets/index-D-3p9FIW.css new file mode 100644 index 0000000..6eb1aeb --- /dev/null +++ b/repeater/web/html/assets/index-D-3p9FIW.css @@ -0,0 +1 @@ +@tailwind base;@tailwind components;@tailwind utilities;:root{--vt-c-white: #ffffff;--vt-c-white-soft: #f8f8f8;--vt-c-white-mute: #f2f2f2;--vt-c-black: #181818;--vt-c-black-soft: #222222;--vt-c-black-mute: #282828;--vt-c-indigo: #2c3e50;--vt-c-divider-light-1: rgba(60, 60, 60, .29);--vt-c-divider-light-2: rgba(60, 60, 60, .12);--vt-c-divider-dark-1: rgba(84, 84, 84, .65);--vt-c-divider-dark-2: rgba(84, 84, 84, .48);--vt-c-text-light-1: var(--vt-c-indigo);--vt-c-text-light-2: rgba(60, 60, 60, .66);--vt-c-text-dark-1: var(--vt-c-white);--vt-c-text-dark-2: rgba(235, 235, 235, .64)}:root{--color-surface: #FFFFFF;--color-surface-elevated: #FFFFFF;--color-background: #F5F7FA;--color-background-soft: #F8F8F8;--color-background-mute: #EBEEF2;--color-text-primary: #111827;--color-text-secondary: #374151;--color-text-muted: #6B7280;--color-heading: #030712;--color-text: #374151;--color-border: #9CA3AF;--color-border-subtle: #D1D5DB;--color-border-hover: #6B7280;--color-primary: #0D7377;--color-secondary: #92610A;--color-accent-green: #15803D;--color-accent-purple: #7C3AED;--color-accent-red: #DC2626;--color-accent-cyan: #0E7490;--section-gap: 160px;--color-primary-bg: #AAE8E8;--color-secondary-bg: #FFC246;--color-accent-green-bg: #A5E5B6;--color-accent-purple-bg: #EBA0FC;--color-accent-red-bg: #FB787B;--color-accent-cyan-bg: #D1E6E4;--color-badge-cyan-bg: #D1E6E4;--color-badge-cyan-text: #0D7377;--color-badge-neutral-bg: #E5E7EB;--color-badge-neutral-text: #374151;--color-glass-bg: rgba(255, 255, 255, .75);--color-glass-border: rgba(0, 0, 0, .06);--color-glass-shadow: 0 4px 16px rgba(0, 0, 0, .04), 0 1px 3px rgba(0, 0, 0, .02);--color-glass-green-bg: linear-gradient(91deg, rgba(165, 229, 182, .35) 1.17%, rgba(165, 229, 182, .15) 99.82%);--color-glass-green-border: rgba(165, 229, 182, .3);--color-glass-green-shadow: 0 4px 12px rgba(165, 229, 182, .15);--color-glass-orange-bg: linear-gradient(91deg, rgba(245, 158, 11, .25) 1.17%, rgba(245, 158, 11, .12) 99.82%);--color-glass-orange-border: rgba(245, 158, 11, .25);--color-glass-orange-shadow: 0 4px 12px rgba(245, 158, 11, .15)}.dark{--color-surface: #0F1112;--color-surface-elevated: #1A1E1F;--color-background: #09090B;--color-background-soft: #111314;--color-background-mute: #1A1E1F;--color-text-primary: #F9FAFB;--color-text-secondary: #D1D5DB;--color-text-muted: #9CA3AF;--color-heading: #FFFFFF;--color-text: #ADADAD;--color-border: #4B4B4B;--color-border-subtle: #374151;--color-border-hover: #6B7280;--color-primary: #AAE8E8;--color-secondary: #FFC246;--color-accent-green: #A5E5B6;--color-accent-purple: #EBA0FC;--color-accent-red: #FB787B;--color-accent-cyan: #D1E6E4;--color-primary-bg: #0D7377;--color-secondary-bg: #92610A;--color-accent-green-bg: #15803D;--color-accent-purple-bg: #7C3AED;--color-accent-red-bg: #DC2626;--color-accent-cyan-bg: #0E7490;--color-badge-cyan-bg: #223231;--color-badge-cyan-text: #D1E6E4;--color-badge-neutral-bg: #374151;--color-badge-neutral-text: #D1D5DB;--color-glass-bg: rgba(0, 0, 0, .4);--color-glass-border: rgba(255, 255, 255, .05);--color-glass-shadow: 0 4px 16px rgba(0, 0, 0, .2);--color-glass-green-bg: linear-gradient(91deg, rgba(21, 128, 61, .25) 1.17%, rgba(21, 128, 61, .1) 99.82%);--color-glass-green-border: rgba(165, 229, 182, .15);--color-glass-green-shadow: 0 4px 12px rgba(0, 0, 0, .2);--color-glass-orange-bg: linear-gradient(91deg, rgba(245, 158, 11, .25) 1.17%, rgba(245, 158, 11, .12) 99.82%);--color-glass-orange-border: rgba(245, 158, 11, .15);--color-glass-orange-shadow: 0 4px 12px rgba(0, 0, 0, .2)}*,*:before,*:after{box-sizing:border-box;margin:0;font-weight:400}body{min-height:100vh;color:var(--color-text);background:var(--color-background);transition:color .5s,background-color .5s;line-height:1.6;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;font-size:15px;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Noto Sans,-apple-system,Roboto,Helvetica,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width: 640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width: 768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width: 1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width: 1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width: 1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-left-\[92px\]{left:-92px}.-right-1{right:-.25rem}.-right-6{right:-1.5rem}.-top-1{top:-.25rem}.-top-\[79px\]{top:-79px}.-top-\[94px\]{top:-94px}.bottom-0{bottom:0}.bottom-2{bottom:.5rem}.bottom-3{bottom:.75rem}.bottom-full{bottom:100%}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-3{left:.75rem}.left-\[246px\]{left:246px}.left-\[575px\]{left:575px}.right-1{right:.25rem}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.right-6{right:1.5rem}.top-0{top:0}.top-1\/2{top:50%}.top-14{top:3.5rem}.top-2{top:.5rem}.top-3{top:.75rem}.top-4{top:1rem}.top-\[373px\]{top:373px}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[1001\]{z-index:1001}.z-\[100\]{z-index:100}.z-\[1010\]{z-index:1010}.z-\[60\]{z-index:60}.z-\[9998\]{z-index:9998}.z-\[999999\]{z-index:999999}.z-\[99999\]{z-index:99999}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.-mx-3{margin-left:-.75rem;margin-right:-.75rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.my-8{margin-top:2rem;margin-bottom:2rem}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-1{margin-left:.25rem}.ml-12{margin-left:3rem}.ml-16{margin-left:4rem}.ml-2{margin-left:.5rem}.ml-20{margin-left:5rem}.ml-24{margin-left:6rem}.ml-28{margin-left:7rem}.ml-32{margin-left:8rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mr-6{margin-right:1.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-40{height:10rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[35px\]{height:35px}.h-\[50px\]{height:50px}.h-\[512px\]{height:512px}.h-\[85vh\]{height:85vh}.h-full{height:100%}.h-px{height:1px}.max-h-0{max-height:0px}.max-h-32{max-height:8rem}.max-h-40{max-height:10rem}.max-h-\[600px\]{max-height:600px}.max-h-\[80vh\]{max-height:80vh}.max-h-\[90vh\]{max-height:90vh}.max-h-screen{max-height:100vh}.min-h-\[12rem\]{min-height:12rem}.min-h-\[400px\]{min-height:400px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-\[285px\]{width:285px}.w-\[35px\]{width:35px}.w-\[705px\]{width:705px}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0px}.min-w-48{min-width:12rem}.min-w-\[100px\]{min-width:100px}.min-w-\[120px\]{min-width:120px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-32{max-width:8rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-\[140px\]{max-width:140px}.max-w-\[80px\]{max-width:80px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-full{--tw-translate-x: -100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0\.5{--tw-translate-x: .125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x: 1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-\[24\.22deg\]{--tw-rotate: -24.22deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-0{--tw-rotate: 0deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-90{--tw-rotate: 90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-0{--tw-scale-x: 0;--tw-scale-y: 0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-100{--tw-scale-x: 1;--tw-scale-y: 1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-105{--tw-scale-x: 1.05;--tw-scale-y: 1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-95{--tw-scale-x: .95;--tw-scale-y: .95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes ping{75%,to{transform:scale(2);opacity:0}}.animate-ping{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-help{cursor:help}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.resize{resize:both}.list-inside{list-style-position:inside}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-evenly{justify-content:space-evenly}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.75rem * var(--tw-space-x-reverse));margin-left:calc(.75rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(229 231 235 / var(--tw-divide-opacity, 1))}.self-center{align-self:center}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-\[\.625rem\]{border-radius:.625rem}.rounded-\[10px\]{border-radius:10px}.rounded-\[12px\]{border-radius:12px}.rounded-\[15px\]{border-radius:15px}.rounded-\[16px\]{border-radius:16px}.rounded-\[20px\]{border-radius:20px}.rounded-\[24px\]{border-radius:24px}.rounded-\[5px\]{border-radius:5px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.rounded-xl{border-radius:.75rem}.rounded-b-\[15px\]{border-bottom-right-radius:15px;border-bottom-left-radius:15px}.border{border-width:1px}.border-0{border-width:0px}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-2{border-left-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-accent-cyan{border-color:var(--color-accent-cyan)}.border-accent-green{border-color:var(--color-accent-green)}.border-amber-500\/30{border-color:#f59e0b4d}.border-blue-200{--tw-border-opacity: 1;border-color:rgb(191 219 254 / var(--tw-border-opacity, 1))}.border-blue-500{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.border-blue-500\/30{border-color:#3b82f64d}.border-blue-500\/50{border-color:#3b82f680}.border-cyan-200{--tw-border-opacity: 1;border-color:rgb(165 243 252 / var(--tw-border-opacity, 1))}.border-cyan-400{--tw-border-opacity: 1;border-color:rgb(34 211 238 / var(--tw-border-opacity, 1))}.border-cyan-400\/30{border-color:#22d3ee4d}.border-cyan-400\/40{border-color:#22d3ee66}.border-cyan-500{--tw-border-opacity: 1;border-color:rgb(6 182 212 / var(--tw-border-opacity, 1))}.border-cyan-500\/50{border-color:#06b6d480}.border-cyan-600{--tw-border-opacity: 1;border-color:rgb(8 145 178 / var(--tw-border-opacity, 1))}.border-gray-400{--tw-border-opacity: 1;border-color:rgb(156 163 175 / var(--tw-border-opacity, 1))}.border-gray-400\/30{border-color:#9ca3af4d}.border-gray-500{--tw-border-opacity: 1;border-color:rgb(107 114 128 / var(--tw-border-opacity, 1))}.border-gray-500\/50{border-color:#6b728080}.border-green-200{--tw-border-opacity: 1;border-color:rgb(187 247 208 / var(--tw-border-opacity, 1))}.border-green-300{--tw-border-opacity: 1;border-color:rgb(134 239 172 / var(--tw-border-opacity, 1))}.border-green-500{--tw-border-opacity: 1;border-color:rgb(34 197 94 / var(--tw-border-opacity, 1))}.border-green-500\/20{border-color:#22c55e33}.border-green-500\/30{border-color:#22c55e4d}.border-green-500\/50{border-color:#22c55e80}.border-green-600\/40{border-color:#16a34a66}.border-orange-200{--tw-border-opacity: 1;border-color:rgb(254 215 170 / var(--tw-border-opacity, 1))}.border-orange-500{--tw-border-opacity: 1;border-color:rgb(249 115 22 / var(--tw-border-opacity, 1))}.border-orange-500\/30{border-color:#f973164d}.border-primary{border-color:var(--color-primary)}.border-purple-500\/50{border-color:#a855f780}.border-red-200{--tw-border-opacity: 1;border-color:rgb(254 202 202 / var(--tw-border-opacity, 1))}.border-red-300{--tw-border-opacity: 1;border-color:rgb(252 165 165 / var(--tw-border-opacity, 1))}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-500\/30{border-color:#ef44444d}.border-red-500\/50{border-color:#ef444480}.border-secondary{border-color:var(--color-secondary)}.border-stroke{border-color:var(--color-border)}.border-stroke-subtle{border-color:var(--color-border-subtle)}.border-transparent{border-color:transparent}.border-white{--tw-border-opacity: 1;border-color:rgb(255 255 255 / var(--tw-border-opacity, 1))}.border-white\/10{border-color:#ffffff1a}.border-white\/20{border-color:#fff3}.border-white\/30{border-color:#ffffff4d}.border-yellow-300{--tw-border-opacity: 1;border-color:rgb(253 224 71 / var(--tw-border-opacity, 1))}.border-yellow-500{--tw-border-opacity: 1;border-color:rgb(234 179 8 / var(--tw-border-opacity, 1))}.border-yellow-500\/30{border-color:#eab3084d}.border-yellow-500\/50{border-color:#eab30880}.border-l-accent-cyan{border-left-color:var(--color-accent-cyan)}.border-l-accent-green{border-left-color:var(--color-accent-green)}.border-l-accent-purple{border-left-color:var(--color-accent-purple)}.border-l-accent-red{border-left-color:var(--color-accent-red)}.border-l-gray-500{--tw-border-opacity: 1;border-left-color:rgb(107 114 128 / var(--tw-border-opacity, 1))}.border-l-primary{border-left-color:var(--color-primary)}.border-l-secondary{border-left-color:var(--color-secondary)}.border-t-amber-600{--tw-border-opacity: 1;border-top-color:rgb(217 119 6 / var(--tw-border-opacity, 1))}.border-t-content-primary{border-top-color:var(--color-text-primary)}.border-t-cyan-500{--tw-border-opacity: 1;border-top-color:rgb(6 182 212 / var(--tw-border-opacity, 1))}.border-t-gray-900{--tw-border-opacity: 1;border-top-color:rgb(17 24 39 / var(--tw-border-opacity, 1))}.border-t-green-400{--tw-border-opacity: 1;border-top-color:rgb(74 222 128 / var(--tw-border-opacity, 1))}.border-t-green-600{--tw-border-opacity: 1;border-top-color:rgb(22 163 74 / var(--tw-border-opacity, 1))}.border-t-orange-400{--tw-border-opacity: 1;border-top-color:rgb(251 146 60 / var(--tw-border-opacity, 1))}.border-t-primary{border-top-color:var(--color-primary)}.border-t-purple-600{--tw-border-opacity: 1;border-top-color:rgb(147 51 234 / var(--tw-border-opacity, 1))}.border-t-transparent{border-top-color:transparent}.bg-accent-cyan{background-color:var(--color-accent-cyan)}.bg-accent-green{background-color:var(--color-accent-green)}.bg-accent-purple{background-color:var(--color-accent-purple)}.bg-accent-red{background-color:var(--color-accent-red)}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-background{background-color:var(--color-background)}.bg-background-mute{background-color:var(--color-background-mute)}.bg-badge-cyan-bg{background-color:var(--color-badge-cyan-bg)}.bg-badge-neutral-bg{background-color:var(--color-badge-neutral-bg)}.bg-black\/20{background-color:#0003}.bg-black\/30{background-color:#0000004d}.bg-black\/40{background-color:#0006}.bg-black\/50{background-color:#00000080}.bg-black\/60{background-color:#0009}.bg-black\/70{background-color:#000000b3}.bg-black\/80{background-color:#000c}.bg-blue-100{--tw-bg-opacity: 1;background-color:rgb(219 234 254 / var(--tw-bg-opacity, 1))}.bg-blue-400{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.bg-blue-50{--tw-bg-opacity: 1;background-color:rgb(239 246 255 / var(--tw-bg-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-500\/20{background-color:#3b82f633}.bg-blue-900\/20{background-color:#1e3a8a33}.bg-content-primary{background-color:var(--color-text-primary)}.bg-current{background-color:currentColor}.bg-cyan-400{--tw-bg-opacity: 1;background-color:rgb(34 211 238 / var(--tw-bg-opacity, 1))}.bg-cyan-400\/20{background-color:#22d3ee33}.bg-cyan-50{--tw-bg-opacity: 1;background-color:rgb(236 254 255 / var(--tw-bg-opacity, 1))}.bg-cyan-500\/10{background-color:#06b6d41a}.bg-cyan-500\/20{background-color:#06b6d433}.bg-cyan-600{--tw-bg-opacity: 1;background-color:rgb(8 145 178 / var(--tw-bg-opacity, 1))}.bg-gray-100{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.bg-gray-100\/50{background-color:#f3f4f680}.bg-gray-200{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.bg-gray-300{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity, 1))}.bg-gray-400{--tw-bg-opacity: 1;background-color:rgb(156 163 175 / var(--tw-bg-opacity, 1))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.bg-gray-500{--tw-bg-opacity: 1;background-color:rgb(107 114 128 / var(--tw-bg-opacity, 1))}.bg-gray-500\/20{background-color:#6b728033}.bg-gray-900\/20{background-color:#11182733}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-green-400{--tw-bg-opacity: 1;background-color:rgb(74 222 128 / var(--tw-bg-opacity, 1))}.bg-green-400\/20{background-color:#4ade8033}.bg-green-50{--tw-bg-opacity: 1;background-color:rgb(240 253 244 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-500\/5{background-color:#22c55e0d}.bg-orange-100{--tw-bg-opacity: 1;background-color:rgb(255 237 213 / var(--tw-bg-opacity, 1))}.bg-orange-400{--tw-bg-opacity: 1;background-color:rgb(251 146 60 / var(--tw-bg-opacity, 1))}.bg-orange-400\/20{background-color:#fb923c33}.bg-orange-50{--tw-bg-opacity: 1;background-color:rgb(255 247 237 / var(--tw-bg-opacity, 1))}.bg-primary{background-color:var(--color-primary)}.bg-purple-400{--tw-bg-opacity: 1;background-color:rgb(192 132 252 / var(--tw-bg-opacity, 1))}.bg-purple-500\/20{background-color:#a855f733}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-400{--tw-bg-opacity: 1;background-color:rgb(248 113 113 / var(--tw-bg-opacity, 1))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-900\/20{background-color:#7f1d1d33}.bg-secondary{background-color:var(--color-secondary)}.bg-stroke-subtle{background-color:var(--color-border-subtle)}.bg-surface{background-color:var(--color-surface)}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-white\/10{background-color:#ffffff1a}.bg-white\/20{background-color:#fff3}.bg-white\/5{background-color:#ffffff0d}.bg-white\/50{background-color:#ffffff80}.bg-white\/95{background-color:#fffffff2}.bg-yellow-100{--tw-bg-opacity: 1;background-color:rgb(254 249 195 / var(--tw-bg-opacity, 1))}.bg-yellow-400{--tw-bg-opacity: 1;background-color:rgb(250 204 21 / var(--tw-bg-opacity, 1))}.bg-yellow-400\/20{background-color:#facc1533}.bg-yellow-50{--tw-bg-opacity: 1;background-color:rgb(254 252 232 / var(--tw-bg-opacity, 1))}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.bg-yellow-500\/10{background-color:#eab3081a}.bg-yellow-500\/20{background-color:#eab30833}.bg-yellow-900\/20{background-color:#713f1233}.bg-gradient-to-b{background-image:linear-gradient(to bottom,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-t{background-image:linear-gradient(to top,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.from-blue-500\/20{--tw-gradient-from: rgb(59 130 246 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(59 130 246 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-200\/30{--tw-gradient-from: rgb(165 243 252 / .3) var(--tw-gradient-from-position);--tw-gradient-to: rgb(165 243 252 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-400{--tw-gradient-from: #22d3ee var(--tw-gradient-from-position);--tw-gradient-to: rgb(34 211 238 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-400\/25{--tw-gradient-from: rgb(34 211 238 / .25) var(--tw-gradient-from-position);--tw-gradient-to: rgb(34 211 238 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-400\/90{--tw-gradient-from: rgb(34 211 238 / .9) var(--tw-gradient-from-position);--tw-gradient-to: rgb(34 211 238 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-500\/20{--tw-gradient-from: rgb(6 182 212 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(6 182 212 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-500\/50{--tw-gradient-from: rgb(6 182 212 / .5) var(--tw-gradient-from-position);--tw-gradient-to: rgb(6 182 212 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-emerald-200\/25{--tw-gradient-from: rgb(167 243 208 / .25) var(--tw-gradient-from-position);--tw-gradient-to: rgb(167 243 208 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-gray-100{--tw-gradient-from: #f3f4f6 var(--tw-gradient-from-position);--tw-gradient-to: rgb(243 244 246 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from: #111827 var(--tw-gradient-from-position);--tw-gradient-to: rgb(17 24 39 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-orange-500\/20{--tw-gradient-from: rgb(249 115 22 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(249 115 22 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-orange-500\/50{--tw-gradient-from: rgb(249 115 22 / .5) var(--tw-gradient-from-position);--tw-gradient-to: rgb(249 115 22 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-primary{--tw-gradient-from: var(--color-primary) var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-purple-200\/25{--tw-gradient-from: rgb(233 213 255 / .25) var(--tw-gradient-from-position);--tw-gradient-to: rgb(233 213 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-red-500\/20{--tw-gradient-from: rgb(239 68 68 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(239 68 68 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-red-500\/50{--tw-gradient-from: rgb(239 68 68 / .5) var(--tw-gradient-from-position);--tw-gradient-to: rgb(239 68 68 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-transparent{--tw-gradient-from: transparent var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-white\/5{--tw-gradient-from: rgb(255 255 255 / .05) var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-yellow-400\/30{--tw-gradient-from: rgb(250 204 21 / .3) var(--tw-gradient-from-position);--tw-gradient-to: rgb(250 204 21 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-yellow-500\/50{--tw-gradient-from: rgb(234 179 8 / .5) var(--tw-gradient-from-position);--tw-gradient-to: rgb(234 179 8 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-transparent{--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), transparent var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-white\/5{--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(255 255 255 / .05) var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-accent-green{--tw-gradient-to: var(--color-accent-green) var(--tw-gradient-to-position)}.to-blue-100\/20{--tw-gradient-to: rgb(219 234 254 / .2) var(--tw-gradient-to-position)}.to-cyan-200\/10{--tw-gradient-to: rgb(165 243 252 / .1) var(--tw-gradient-to-position)}.to-cyan-400\/20{--tw-gradient-to: rgb(34 211 238 / .2) var(--tw-gradient-to-position)}.to-cyan-500{--tw-gradient-to: #06b6d4 var(--tw-gradient-to-position)}.to-cyan-500\/20{--tw-gradient-to: rgb(6 182 212 / .2) var(--tw-gradient-to-position)}.to-cyan-500\/90{--tw-gradient-to: rgb(6 182 212 / .9) var(--tw-gradient-to-position)}.to-cyan-600\/50{--tw-gradient-to: rgb(8 145 178 / .5) var(--tw-gradient-to-position)}.to-gray-200{--tw-gradient-to: #e5e7eb var(--tw-gradient-to-position)}.to-gray-700{--tw-gradient-to: #374151 var(--tw-gradient-to-position)}.to-orange-400\/30{--tw-gradient-to: rgb(251 146 60 / .3) var(--tw-gradient-to-position)}.to-orange-600\/50{--tw-gradient-to: rgb(234 88 12 / .5) var(--tw-gradient-to-position)}.to-pink-100\/15{--tw-gradient-to: rgb(252 231 243 / .15) var(--tw-gradient-to-position)}.to-red-500\/10{--tw-gradient-to: rgb(239 68 68 / .1) var(--tw-gradient-to-position)}.to-red-600\/50{--tw-gradient-to: rgb(220 38 38 / .5) var(--tw-gradient-to-position)}.to-teal-100\/15{--tw-gradient-to: rgb(204 251 241 / .15) var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.to-yellow-500\/20{--tw-gradient-to: rgb(234 179 8 / .2) var(--tw-gradient-to-position)}.to-yellow-600\/50{--tw-gradient-to: rgb(202 138 4 / .5) var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.p-\[15px\]{padding:15px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-2{padding-left:.5rem}.pl-9{padding-left:2.25rem}.pr-4{padding-right:1rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:Noto Sans,-apple-system,Roboto,Helvetica,sans-serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-\[1\.25rem\]{font-size:1.25rem}.text-\[10px\]{font-size:10px}.text-\[22px\]{font-size:22px}.text-\[24px\]{font-size:24px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-\[\#212122\]{--tw-text-opacity: 1;color:rgb(33 33 34 / var(--tw-text-opacity, 1))}.text-accent-cyan{color:var(--color-accent-cyan)}.text-accent-green{color:var(--color-accent-green)}.text-accent-purple{color:var(--color-accent-purple)}.text-accent-red{color:var(--color-accent-red)}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-background{color:var(--color-background)}.text-badge-cyan-text{color:var(--color-badge-cyan-text)}.text-badge-neutral-text{color:var(--color-badge-neutral-text)}.text-blue-100{--tw-text-opacity: 1;color:rgb(219 234 254 / var(--tw-text-opacity, 1))}.text-blue-200{--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-blue-700{--tw-text-opacity: 1;color:rgb(29 78 216 / var(--tw-text-opacity, 1))}.text-blue-800{--tw-text-opacity: 1;color:rgb(30 64 175 / var(--tw-text-opacity, 1))}.text-blue-900{--tw-text-opacity: 1;color:rgb(30 58 138 / var(--tw-text-opacity, 1))}.text-content-heading{color:var(--color-heading)}.text-content-muted{color:var(--color-text-muted)}.text-content-primary{color:var(--color-text-primary)}.text-content-secondary{color:var(--color-text-secondary)}.text-cyan-500{--tw-text-opacity: 1;color:rgb(6 182 212 / var(--tw-text-opacity, 1))}.text-cyan-600{--tw-text-opacity: 1;color:rgb(8 145 178 / var(--tw-text-opacity, 1))}.text-cyan-700{--tw-text-opacity: 1;color:rgb(14 116 144 / var(--tw-text-opacity, 1))}.text-cyan-900{--tw-text-opacity: 1;color:rgb(22 78 99 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-green-600{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity, 1))}.text-green-700{--tw-text-opacity: 1;color:rgb(21 128 61 / var(--tw-text-opacity, 1))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity, 1))}.text-green-900{--tw-text-opacity: 1;color:rgb(20 83 45 / var(--tw-text-opacity, 1))}.text-orange-400{--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity, 1))}.text-orange-500{--tw-text-opacity: 1;color:rgb(249 115 22 / var(--tw-text-opacity, 1))}.text-orange-600{--tw-text-opacity: 1;color:rgb(234 88 12 / var(--tw-text-opacity, 1))}.text-orange-700{--tw-text-opacity: 1;color:rgb(194 65 12 / var(--tw-text-opacity, 1))}.text-primary{color:var(--color-primary)}.text-purple-500{--tw-text-opacity: 1;color:rgb(168 85 247 / var(--tw-text-opacity, 1))}.text-purple-600{--tw-text-opacity: 1;color:rgb(147 51 234 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-secondary{color:var(--color-secondary)}.text-transparent{color:transparent}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/30{color:#ffffff4d}.text-white\/40{color:#fff6}.text-white\/50{color:#ffffff80}.text-white\/60{color:#fff9}.text-white\/80{color:#fffc}.text-white\/90{color:#ffffffe6}.text-yellow-200{--tw-text-opacity: 1;color:rgb(254 240 138 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.text-yellow-600{--tw-text-opacity: 1;color:rgb(202 138 4 / var(--tw-text-opacity, 1))}.text-yellow-700{--tw-text-opacity: 1;color:rgb(161 98 7 / var(--tw-text-opacity, 1))}.text-yellow-800{--tw-text-opacity: 1;color:rgb(133 77 14 / var(--tw-text-opacity, 1))}.underline{text-decoration-line:underline}.decoration-gray-400{text-decoration-color:#9ca3af}.decoration-green-400\/60{text-decoration-color:#4ade8099}.underline-offset-2{text-underline-offset:2px}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(156 163 175 / var(--tw-placeholder-opacity, 1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity: 1;color:rgb(156 163 175 / var(--tw-placeholder-opacity, 1))}.placeholder-gray-500::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(107 114 128 / var(--tw-placeholder-opacity, 1))}.placeholder-gray-500::placeholder{--tw-placeholder-opacity: 1;color:rgb(107 114 128 / var(--tw-placeholder-opacity, 1))}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-65{opacity:.65}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-90{opacity:.9}.mix-blend-normal{mix-blend-mode:normal}.mix-blend-multiply{mix-blend-mode:multiply}.mix-blend-screen{mix-blend-mode:screen}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_\.375rem_0_rgba\(170\,232\,232\,0\.20\)\]{--tw-shadow: 0 0 .375rem 0 rgba(170,232,232,.2);--tw-shadow-colored: 0 0 .375rem 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_2px_12px_rgba\(6\,182\,212\,0\.3\)\]{--tw-shadow: 0 2px 12px rgba(6,182,212,.3);--tw-shadow-colored: 0 2px 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_4px_16px_rgba\(6\,182\,212\,0\.4\)\]{--tw-shadow: 0 4px 16px rgba(6,182,212,.4);--tw-shadow-colored: 0 4px 16px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_8px_32px_0_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow: 0 8px 32px 0 rgba(0,0,0,.1);--tw-shadow-colored: 0 8px 32px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_8px_32px_0_rgba\(0\,0\,0\,0\.37\)\]{--tw-shadow: 0 8px 32px 0 rgba(0,0,0,.37);--tw-shadow-colored: 0 8px 32px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px rgb(0 0 0 / .1), 0 8px 10px -6px rgb(0 0 0 / .1);--tw-shadow-colored: 0 20px 25px -5px var(--tw-shadow-color), 0 8px 10px -6px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-yellow-400\/20{--tw-shadow-color: rgb(250 204 21 / .2);--tw-shadow: var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.ring-2{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-cyan-400\/50{--tw-ring-color: rgb(34 211 238 / .5)}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur: blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-\[120px\]{--tw-blur: blur(120px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-xl{--tw-blur: blur(24px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.brightness-0{--tw-brightness: brightness(0);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / .1)) drop-shadow(0 1px 1px rgb(0 0 0 / .06));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow-\[0_0_6px_rgba\(6\,182\,212\,0\.8\)\]{--tw-drop-shadow: drop-shadow(0 0 6px rgba(6,182,212,.8));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.invert{--tw-invert: invert(100%);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur: blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-lg{--tw-backdrop-blur: blur(16px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-md{--tw-backdrop-blur: blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur: blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.duration-700{transition-duration:.7s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.glass-card{border-radius:10px;--tw-backdrop-blur: blur(50px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background:var(--color-glass-bg);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)}.glass-card-green{border-radius:10px;--tw-backdrop-blur: blur(50px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background:var(--color-glass-green-bg);border:1px solid var(--color-glass-green-border);box-shadow:var(--color-glass-green-shadow)}.glass-card-orange{border-radius:10px;--tw-backdrop-blur: blur(50px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background:var(--color-glass-orange-bg);border:1px solid var(--color-glass-orange-border);box-shadow:var(--color-glass-orange-shadow)}.last\:border-b-0:last-child{border-bottom-width:0px}.hover\:scale-105:hover{--tw-scale-x: 1.05;--tw-scale-y: 1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-110:hover{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-\[1\.02\]:hover{--tw-scale-x: 1.02;--tw-scale-y: 1.02;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-cyan-400:hover{--tw-border-opacity: 1;border-color:rgb(34 211 238 / var(--tw-border-opacity, 1))}.hover\:border-cyan-400\/30:hover{border-color:#22d3ee4d}.hover\:border-orange-500:hover{--tw-border-opacity: 1;border-color:rgb(249 115 22 / var(--tw-border-opacity, 1))}.hover\:border-primary:hover{border-color:var(--color-primary)}.hover\:border-stroke:hover{border-color:var(--color-border)}.hover\:border-stroke-subtle:hover{border-color:var(--color-border-subtle)}.hover\:border-yellow-500\/50:hover{border-color:#eab30880}.hover\:bg-amber-600:hover{--tw-bg-opacity: 1;background-color:rgb(217 119 6 / var(--tw-bg-opacity, 1))}.hover\:bg-background-mute:hover{background-color:var(--color-background-mute)}.hover\:bg-black\/90:hover{background-color:#000000e6}.hover\:bg-blue-200:hover{--tw-bg-opacity: 1;background-color:rgb(191 219 254 / var(--tw-bg-opacity, 1))}.hover\:bg-blue-600:hover{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.hover\:bg-cyan-500\/30:hover{background-color:#06b6d44d}.hover\:bg-gray-100:hover{--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-100\/50:hover{background-color:#f3f4f680}.hover\:bg-gray-300:hover{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity, 1))}.hover\:bg-green-600:hover{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.hover\:bg-purple-500\/30:hover{background-color:#a855f74d}.hover\:bg-red-500\/30:hover{background-color:#ef44444d}.hover\:bg-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.hover\:bg-stroke-subtle:hover{background-color:var(--color-border-subtle)}.hover\:bg-white\/10:hover{background-color:#ffffff1a}.hover\:bg-white\/5:hover{background-color:#ffffff0d}.hover\:bg-yellow-50:hover{--tw-bg-opacity: 1;background-color:rgb(254 252 232 / var(--tw-bg-opacity, 1))}.hover\:bg-yellow-600:hover{--tw-bg-opacity: 1;background-color:rgb(202 138 4 / var(--tw-bg-opacity, 1))}.hover\:bg-gradient-to-r:hover{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.hover\:from-cyan-400\/20:hover{--tw-gradient-from: rgb(34 211 238 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(34 211 238 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.hover\:from-cyan-500:hover{--tw-gradient-from: #06b6d4 var(--tw-gradient-from-position);--tw-gradient-to: rgb(6 182 212 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.hover\:from-cyan-500\/30:hover{--tw-gradient-from: rgb(6 182 212 / .3) var(--tw-gradient-from-position);--tw-gradient-to: rgb(6 182 212 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.hover\:from-gray-200:hover{--tw-gradient-from: #e5e7eb var(--tw-gradient-from-position);--tw-gradient-to: rgb(229 231 235 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.hover\:from-red-500\/30:hover{--tw-gradient-from: rgb(239 68 68 / .3) var(--tw-gradient-from-position);--tw-gradient-to: rgb(239 68 68 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.hover\:to-cyan-400\/30:hover{--tw-gradient-to: rgb(34 211 238 / .3) var(--tw-gradient-to-position)}.hover\:to-cyan-500\/20:hover{--tw-gradient-to: rgb(6 182 212 / .2) var(--tw-gradient-to-position)}.hover\:to-cyan-600:hover{--tw-gradient-to: #0891b2 var(--tw-gradient-to-position)}.hover\:to-gray-300:hover{--tw-gradient-to: #d1d5db var(--tw-gradient-to-position)}.hover\:to-red-500\/20:hover{--tw-gradient-to: rgb(239 68 68 / .2) var(--tw-gradient-to-position)}.hover\:text-blue-600:hover{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.hover\:text-content-heading:hover{color:var(--color-heading)}.hover\:text-content-primary:hover{color:var(--color-text-primary)}.hover\:text-content-secondary:hover{color:var(--color-text-secondary)}.hover\:text-cyan-800:hover{--tw-text-opacity: 1;color:rgb(21 94 117 / var(--tw-text-opacity, 1))}.hover\:text-gray-900:hover{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.hover\:text-primary:hover{color:var(--color-primary)}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:text-white\/80:hover{color:#fffc}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-\[0_2px_12px_rgba\(6\,182\,212\,0\.2\)\]:hover{--tw-shadow: 0 2px 12px rgba(6,182,212,.2);--tw-shadow-colored: 0 2px 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus\:border-cyan-500:focus{--tw-border-opacity: 1;border-color:rgb(6 182 212 / var(--tw-border-opacity, 1))}.focus\:border-primary:focus{border-color:var(--color-primary)}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-white:focus{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-accent-cyan:focus{--tw-ring-color: var(--color-accent-cyan)}.focus\:ring-primary:focus{--tw-ring-color: var(--color-primary)}.focus\:ring-offset-background:focus{--tw-ring-offset-color: var(--color-background)}.active\:scale-\[0\.98\]:active{--tw-scale-x: .98;--tw-scale-y: .98;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:border-gray-500\/20:disabled{border-color:#6b728033}.disabled\:bg-amber-500\/50:disabled{background-color:#f59e0b80}.disabled\:bg-gray-500\/10:disabled{background-color:#6b72801a}.disabled\:text-gray-400:disabled{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:translate-x-1{--tw-translate-x: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:translate-y-1{--tw-translate-y: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group\/delete:hover .group-hover\/delete\:rotate-12,.group:hover .group-hover\:rotate-12{--tw-rotate: 12deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-110{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:border-stroke{border-color:var(--color-border)}.group:hover .group-hover\:bg-background-mute{background-color:var(--color-background-mute)}.group:hover .group-hover\:text-primary{color:var(--color-primary)}.group:hover .group-hover\:text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.group:hover .group-hover\:text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:scale-100{--tw-scale-x: 1;--tw-scale-y: 1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:border-primary{border-color:var(--color-primary)}.group:has(:checked) .group-has-\[\:checked\]\:scale-100{--tw-scale-x: 1;--tw-scale-y: 1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:has(:checked) .group-has-\[\:checked\]\:border-accent-green{border-color:var(--color-accent-green)}.group:has(:checked) .group-has-\[\:checked\]\:border-accent-red{border-color:var(--color-accent-red)}.group:has(:checked) .group-has-\[\:checked\]\:bg-accent-green{background-color:var(--color-accent-green)}.group:has(:checked) .group-has-\[\:checked\]\:bg-accent-red{background-color:var(--color-accent-red)}.dark\:block:is(.dark *){display:block}.dark\:hidden:is(.dark *){display:none}.dark\:divide-white\/5:is(.dark *)>:not([hidden])~:not([hidden]){border-color:#ffffff0d}.dark\:border:is(.dark *){border-width:1px}.dark\:border-blue-400\/50:is(.dark *){border-color:#60a5fa80}.dark\:border-blue-500\/30:is(.dark *){border-color:#3b82f64d}.dark\:border-cyan-500\/30:is(.dark *){border-color:#06b6d44d}.dark\:border-dark-border:is(.dark *){border-color:var(--color-border)}.dark\:border-gray-600:is(.dark *){--tw-border-opacity: 1;border-color:rgb(75 85 99 / var(--tw-border-opacity, 1))}.dark\:border-green-400\/30:is(.dark *){border-color:#4ade804d}.dark\:border-green-500\/30:is(.dark *){border-color:#22c55e4d}.dark\:border-green-500\/50:is(.dark *){border-color:#22c55e80}.dark\:border-orange-400\/30:is(.dark *){border-color:#fb923c4d}.dark\:border-orange-400\/40:is(.dark *){border-color:#fb923c66}.dark\:border-orange-400\/60:is(.dark *){border-color:#fb923c99}.dark\:border-orange-500\/30:is(.dark *){border-color:#f973164d}.dark\:border-primary:is(.dark *){border-color:var(--color-primary)}.dark\:border-red-500\/30:is(.dark *){border-color:#ef44444d}.dark\:border-red-500\/50:is(.dark *){border-color:#ef444480}.dark\:border-stroke:is(.dark *){border-color:var(--color-border)}.dark\:border-teal-500:is(.dark *){--tw-border-opacity: 1;border-color:rgb(20 184 166 / var(--tw-border-opacity, 1))}.dark\:border-transparent:is(.dark *){border-color:transparent}.dark\:border-white\/10:is(.dark *){border-color:#ffffff1a}.dark\:border-white\/20:is(.dark *){border-color:#fff3}.dark\:border-white\/5:is(.dark *){border-color:#ffffff0d}.dark\:border-yellow-400\/30:is(.dark *){border-color:#facc154d}.dark\:border-yellow-500\/30:is(.dark *){border-color:#eab3084d}.dark\:border-t-amber-400:is(.dark *){--tw-border-opacity: 1;border-top-color:rgb(251 191 36 / var(--tw-border-opacity, 1))}.dark\:border-t-green-400:is(.dark *){--tw-border-opacity: 1;border-top-color:rgb(74 222 128 / var(--tw-border-opacity, 1))}.dark\:border-t-primary:is(.dark *){border-top-color:var(--color-primary)}.dark\:border-t-purple-400:is(.dark *){--tw-border-opacity: 1;border-top-color:rgb(192 132 252 / var(--tw-border-opacity, 1))}.dark\:border-t-white\/70:is(.dark *){border-top-color:#ffffffb3}.dark\:bg-accent-purple:is(.dark *){background-color:var(--color-accent-purple)}.dark\:bg-accent-red:is(.dark *){background-color:var(--color-accent-red)}.dark\:bg-background:is(.dark *){background-color:var(--color-background)}.dark\:bg-background-mute:is(.dark *){background-color:var(--color-background-mute)}.dark\:bg-black\/20:is(.dark *){background-color:#0003}.dark\:bg-black\/30:is(.dark *){background-color:#0000004d}.dark\:bg-black\/40:is(.dark *){background-color:#0006}.dark\:bg-blue-400\/20:is(.dark *){background-color:#60a5fa33}.dark\:bg-blue-500\/10:is(.dark *){background-color:#3b82f61a}.dark\:bg-blue-500\/20:is(.dark *){background-color:#3b82f633}.dark\:bg-cyan-500\/10:is(.dark *){background-color:#06b6d41a}.dark\:bg-gray-500\/20:is(.dark *){background-color:#6b728033}.dark\:bg-gray-600:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.dark\:bg-gray-700:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.dark\:bg-gray-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.dark\:bg-green-500\/10:is(.dark *){background-color:#22c55e1a}.dark\:bg-green-500\/20:is(.dark *){background-color:#22c55e33}.dark\:bg-green-600\/20:is(.dark *){background-color:#16a34a33}.dark\:bg-orange-500\/10:is(.dark *){background-color:#f973161a}.dark\:bg-orange-500\/20:is(.dark *){background-color:#f9731633}.dark\:bg-primary:is(.dark *){background-color:var(--color-primary)}.dark\:bg-red-500\/10:is(.dark *){background-color:#ef44441a}.dark\:bg-red-500\/20:is(.dark *){background-color:#ef444433}.dark\:bg-secondary:is(.dark *){background-color:var(--color-secondary)}.dark\:bg-surface-elevated:is(.dark *){background-color:var(--color-surface-elevated)}.dark\:bg-teal-500:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(20 184 166 / var(--tw-bg-opacity, 1))}.dark\:bg-transparent:is(.dark *){background-color:transparent}.dark\:bg-white\/10:is(.dark *){background-color:#ffffff1a}.dark\:bg-white\/5:is(.dark *){background-color:#ffffff0d}.dark\:bg-yellow-500\/10:is(.dark *){background-color:#eab3081a}.dark\:bg-yellow-500\/20:is(.dark *){background-color:#eab30833}.dark\:from-transparent:is(.dark *){--tw-gradient-from: transparent var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.dark\:from-white:is(.dark *){--tw-gradient-from: #fff var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.dark\:from-white\/5:is(.dark *){--tw-gradient-from: rgb(255 255 255 / .05) var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.dark\:to-transparent:is(.dark *){--tw-gradient-to: transparent var(--tw-gradient-to-position)}.dark\:to-white\/10:is(.dark *){--tw-gradient-to: rgb(255 255 255 / .1) var(--tw-gradient-to-position)}.dark\:to-white\/70:is(.dark *){--tw-gradient-to: rgb(255 255 255 / .7) var(--tw-gradient-to-position)}.dark\:text-\[\#C3C3C3\]:is(.dark *){--tw-text-opacity: 1;color:rgb(195 195 195 / var(--tw-text-opacity, 1))}.dark\:text-accent-green:is(.dark *){color:var(--color-accent-green)}.dark\:text-accent-purple:is(.dark *){color:var(--color-accent-purple)}.dark\:text-accent-red:is(.dark *){color:var(--color-accent-red)}.dark\:text-amber-400:is(.dark *){--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.dark\:text-amber-400\/80:is(.dark *){color:#fbbf24cc}.dark\:text-background:is(.dark *){color:var(--color-background)}.dark\:text-blue-200:is(.dark *){--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity, 1))}.dark\:text-blue-300:is(.dark *){--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.dark\:text-blue-400:is(.dark *){--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.dark\:text-blue-500:is(.dark *){--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.dark\:text-content:is(.dark *){color:var(--color-text)}.dark\:text-content-muted:is(.dark *){color:var(--color-text-muted)}.dark\:text-content-primary:is(.dark *){color:var(--color-text-primary)}.dark\:text-content-secondary:is(.dark *){color:var(--color-text-secondary)}.dark\:text-cyan-300:is(.dark *){--tw-text-opacity: 1;color:rgb(103 232 249 / var(--tw-text-opacity, 1))}.dark\:text-cyan-400:is(.dark *){--tw-text-opacity: 1;color:rgb(34 211 238 / var(--tw-text-opacity, 1))}.dark\:text-cyan-400\/60:is(.dark *){color:#22d3ee99}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.dark\:text-gray-500:is(.dark *){--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.dark\:text-green-300:is(.dark *){--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.dark\:text-green-400:is(.dark *){--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.dark\:text-green-500:is(.dark *){--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.dark\:text-orange-400:is(.dark *){--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity, 1))}.dark\:text-orange-400\/60:is(.dark *){color:#fb923c99}.dark\:text-primary:is(.dark *){color:var(--color-primary)}.dark\:text-purple-400:is(.dark *){--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.dark\:text-red-200:is(.dark *){--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity, 1))}.dark\:text-red-400:is(.dark *){--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.dark\:text-red-500:is(.dark *){--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.dark\:text-secondary:is(.dark *){color:var(--color-secondary)}.dark\:text-white:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.dark\:text-white\/60:is(.dark *){color:#fff9}.dark\:text-yellow-200:is(.dark *){--tw-text-opacity: 1;color:rgb(254 240 138 / var(--tw-text-opacity, 1))}.dark\:text-yellow-300:is(.dark *){--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.dark\:text-yellow-400:is(.dark *){--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.dark\:text-yellow-500:is(.dark *){--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.dark\:decoration-white\/30:is(.dark *){text-decoration-color:#ffffff4d}.dark\:placeholder-white\/30:is(.dark *)::-moz-placeholder{color:#ffffff4d}.dark\:placeholder-white\/30:is(.dark *)::placeholder{color:#ffffff4d}.dark\:placeholder-white\/40:is(.dark *)::-moz-placeholder{color:#fff6}.dark\:placeholder-white\/40:is(.dark *)::placeholder{color:#fff6}.dark\:placeholder-white\/50:is(.dark *)::-moz-placeholder{color:#ffffff80}.dark\:placeholder-white\/50:is(.dark *)::placeholder{color:#ffffff80}.dark\:mix-blend-screen:is(.dark *){mix-blend-mode:screen}.dark\:shadow-\[0_4px_12px_rgba\(170\,232\,232\,0\.25\)\]:is(.dark *){--tw-shadow: 0 4px 12px rgba(170,232,232,.25);--tw-shadow-colored: 0 4px 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:shadow-\[0_8px_32px_0_rgba\(0\,0\,0\,0\.37\)\]:is(.dark *){--tw-shadow: 0 8px 32px 0 rgba(0,0,0,.37);--tw-shadow-colored: 0 8px 32px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:shadow-none:is(.dark *){--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:brightness-100:is(.dark *){--tw-brightness: brightness(1);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.dark\:drop-shadow-\[0_0_6px_rgba\(59\,130\,246\,0\.8\)\]:is(.dark *){--tw-drop-shadow: drop-shadow(0 0 6px rgba(59,130,246,.8));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.dark\:invert-0:is(.dark *){--tw-invert: invert(0);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.dark\:hover\:border-stroke:hover:is(.dark *){border-color:var(--color-border)}.dark\:hover\:bg-blue-500\/30:hover:is(.dark *){background-color:#3b82f64d}.dark\:hover\:bg-gray-600:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.dark\:hover\:bg-white\/10:hover:is(.dark *){background-color:#ffffff1a}.dark\:hover\:bg-white\/20:hover:is(.dark *){background-color:#fff3}.dark\:hover\:bg-white\/5:hover:is(.dark *){background-color:#ffffff0d}.dark\:hover\:bg-yellow-500\/20:hover:is(.dark *){background-color:#eab30833}.dark\:hover\:from-white\/10:hover:is(.dark *){--tw-gradient-from: rgb(255 255 255 / .1) var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.dark\:hover\:to-white\/15:hover:is(.dark *){--tw-gradient-to: rgb(255 255 255 / .15) var(--tw-gradient-to-position)}.dark\:hover\:text-content-muted:hover:is(.dark *){color:var(--color-text-muted)}.dark\:hover\:text-content-primary:hover:is(.dark *){color:var(--color-text-primary)}.dark\:hover\:text-content-secondary:hover:is(.dark *){color:var(--color-text-secondary)}.dark\:hover\:text-primary:hover:is(.dark *){color:var(--color-primary)}.dark\:hover\:text-white:hover:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.dark\:hover\:shadow-\[0_2px_8px_rgba\(170\,232\,212\,0\.15\)\]:hover:is(.dark *){--tw-shadow: 0 2px 8px rgba(170,232,212,.15);--tw-shadow-colored: 0 2px 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:hover\:shadow-\[0_2px_8px_rgba\(170\,232\,232\,0\.15\)\]:hover:is(.dark *){--tw-shadow: 0 2px 8px rgba(170,232,232,.15);--tw-shadow-colored: 0 2px 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:focus\:bg-white\/10:focus:is(.dark *){background-color:#ffffff1a}.group:hover .dark\:group-hover\:text-white:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}@media (min-width: 640px){.sm\:mx-0{margin-left:0;margin-right:0}.sm\:mb-10{margin-bottom:2.5rem}.sm\:mb-2{margin-bottom:.5rem}.sm\:mb-3{margin-bottom:.75rem}.sm\:mb-4{margin-bottom:1rem}.sm\:mb-6{margin-bottom:1.5rem}.sm\:ml-4{margin-left:1rem}.sm\:mr-6{margin-right:1.5rem}.sm\:mt-2{margin-top:.5rem}.sm\:mt-8{margin-top:2rem}.sm\:block{display:block}.sm\:inline-block{display:inline-block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:h-10{height:2.5rem}.sm\:h-3{height:.75rem}.sm\:h-4{height:1rem}.sm\:h-48{height:12rem}.sm\:h-5{height:1.25rem}.sm\:h-6{height:1.5rem}.sm\:h-8{height:2rem}.sm\:h-9{height:2.25rem}.sm\:min-h-\[16rem\]{min-height:16rem}.sm\:w-10{width:2.5rem}.sm\:w-24{width:6rem}.sm\:w-3{width:.75rem}.sm\:w-32{width:8rem}.sm\:w-4{width:1rem}.sm\:w-48{width:12rem}.sm\:w-5{width:1.25rem}.sm\:w-6{width:1.5rem}.sm\:w-64{width:16rem}.sm\:w-8{width:2rem}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:20rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:flex-nowrap{flex-wrap:nowrap}.sm\:items-start{align-items:flex-start}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:gap-2{gap:.5rem}.sm\:gap-2\.5{gap:.625rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:gap-6{gap:1.5rem}.sm\:space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.sm\:space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.sm\:rounded-\[24px\]{border-radius:24px}.sm\:border{border-width:1px}.sm\:border-stroke-subtle{border-color:var(--color-border-subtle)}.sm\:p-1{padding:.25rem}.sm\:p-10{padding:2.5rem}.sm\:p-3\.5{padding:.875rem}.sm\:p-4{padding:1rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:px-0{padding-left:0;padding-right:0}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-3{padding-left:.75rem;padding-right:.75rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.sm\:py-4{padding-top:1rem;padding-bottom:1rem}.sm\:pt-4{padding-top:1rem}.sm\:pt-6{padding-top:1.5rem}.sm\:text-right{text-align:right}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-\[32px\]{font-size:32px}.sm\:text-base{font-size:1rem;line-height:1.5rem}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}.sm\:text-xs{font-size:.75rem;line-height:1rem}}@media (min-width: 768px){.md\:block{display:block}.md\:grid{display:grid}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.md\:gap-3{gap:.75rem}.md\:space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.md\:p-12{padding:3rem}.md\:p-4{padding:1rem}.md\:px-4{padding-left:1rem;padding-right:1rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width: 1024px){.lg\:mb-4{margin-bottom:1rem}.lg\:mt-3{margin-top:.75rem}.lg\:mt-4{margin-top:1rem}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:h-48{height:12rem}.lg\:h-56{height:14rem}.lg\:w-7{width:1.75rem}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}.lg\:gap-3{gap:.75rem}.lg\:gap-4{gap:1rem}.lg\:gap-6{gap:1.5rem}.lg\:p-6{padding:1.5rem}.lg\:p-\[15px\]{padding:15px}.lg\:text-2xl{font-size:1.5rem;line-height:2rem}.lg\:text-\[35px\]{font-size:35px}.lg\:text-base{font-size:1rem;line-height:1.5rem}.lg\:text-sm{font-size:.875rem;line-height:1.25rem}.lg\:text-xl{font-size:1.25rem;line-height:1.75rem}}.\[\&_path\]\:fill-current path{fill:currentColor}@keyframes sparkline-draw-03250605{0%{stroke-dasharray:1000;stroke-dashoffset:1000}to{stroke-dasharray:1000;stroke-dashoffset:0}}.sparkline-animate[data-v-03250605]{animation:sparkline-draw-03250605 1s ease-out}.glass-card[data-v-2eb89c71]{background:#000000b3;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,.1)}@keyframes ping-2eb89c71{75%,to{transform:scale(2);opacity:0}}@keyframes ping-fast-2eb89c71{0%{transform:scale(1);opacity:1}75%,to{transform:scale(4);opacity:0}}.animate-ping[data-v-2eb89c71]{animation:ping-2eb89c71 cubic-bezier(0,0,.2,1) infinite}.animate-ping-fast[data-v-2eb89c71]{animation:ping-fast-2eb89c71 .8s cubic-bezier(0,0,.2,1) 3}body{margin:0;padding:0;transition:background-color .3s ease,color .3s ease}body{background-color:#f9fafb;color:#1f2937}.dark body{background-color:#09090b;color:#fff}.dark html{scrollbar-width:thin;scrollbar-color:#374151 #1f2937}.dark html::-webkit-scrollbar{width:8px}.dark html::-webkit-scrollbar-track{background:#1f2937}.dark html::-webkit-scrollbar-thumb{background-color:#374151;border-radius:4px}.dark html::-webkit-scrollbar-thumb:hover{background-color:#4b5563}html{scrollbar-width:thin;scrollbar-color:#D1D5DB #F3F4F6}html::-webkit-scrollbar{width:8px}html::-webkit-scrollbar-track{background:#f3f4f6}html::-webkit-scrollbar-thumb{background-color:#d1d5db;border-radius:4px}html::-webkit-scrollbar-thumb:hover{background-color:#9ca3af}.scrollbar-hide{-ms-overflow-style:none;scrollbar-width:none}.scrollbar-hide::-webkit-scrollbar{display:none} diff --git a/repeater/web/html/assets/index-Dk6Oh8NN.css b/repeater/web/html/assets/index-Dk6Oh8NN.css deleted file mode 100644 index 4f01ba3..0000000 --- a/repeater/web/html/assets/index-Dk6Oh8NN.css +++ /dev/null @@ -1 +0,0 @@ -@tailwind base;@tailwind components;@tailwind utilities;:root{--vt-c-white: #ffffff;--vt-c-white-soft: #f8f8f8;--vt-c-white-mute: #f2f2f2;--vt-c-black: #181818;--vt-c-black-soft: #222222;--vt-c-black-mute: #282828;--vt-c-indigo: #2c3e50;--vt-c-divider-light-1: rgba(60, 60, 60, .29);--vt-c-divider-light-2: rgba(60, 60, 60, .12);--vt-c-divider-dark-1: rgba(84, 84, 84, .65);--vt-c-divider-dark-2: rgba(84, 84, 84, .48);--vt-c-text-light-1: var(--vt-c-indigo);--vt-c-text-light-2: rgba(60, 60, 60, .66);--vt-c-text-dark-1: var(--vt-c-white);--vt-c-text-dark-2: rgba(235, 235, 235, .64)}:root{--color-surface: #FFFFFF;--color-surface-elevated: #FFFFFF;--color-background: #F5F7FA;--color-background-soft: #F8F8F8;--color-background-mute: #EBEEF2;--color-text-primary: #111827;--color-text-secondary: #374151;--color-text-muted: #6B7280;--color-heading: #030712;--color-text: #374151;--color-border: #9CA3AF;--color-border-subtle: #D1D5DB;--color-border-hover: #6B7280;--color-primary: #0D7377;--color-secondary: #92610A;--color-accent-green: #15803D;--color-accent-purple: #7C3AED;--color-accent-red: #DC2626;--color-accent-cyan: #0E7490;--section-gap: 160px;--color-primary-bg: #AAE8E8;--color-secondary-bg: #FFC246;--color-accent-green-bg: #A5E5B6;--color-accent-purple-bg: #EBA0FC;--color-accent-red-bg: #FB787B;--color-accent-cyan-bg: #D1E6E4;--color-badge-cyan-bg: #D1E6E4;--color-badge-cyan-text: #0D7377;--color-badge-neutral-bg: #E5E7EB;--color-badge-neutral-text: #374151;--color-glass-bg: rgba(255, 255, 255, .75);--color-glass-border: rgba(0, 0, 0, .06);--color-glass-shadow: 0 4px 16px rgba(0, 0, 0, .04), 0 1px 3px rgba(0, 0, 0, .02);--color-glass-green-bg: linear-gradient(91deg, rgba(165, 229, 182, .35) 1.17%, rgba(165, 229, 182, .15) 99.82%);--color-glass-green-border: rgba(165, 229, 182, .3);--color-glass-green-shadow: 0 4px 12px rgba(165, 229, 182, .15);--color-glass-orange-bg: linear-gradient(91deg, rgba(245, 158, 11, .25) 1.17%, rgba(245, 158, 11, .12) 99.82%);--color-glass-orange-border: rgba(245, 158, 11, .25);--color-glass-orange-shadow: 0 4px 12px rgba(245, 158, 11, .15)}.dark{--color-surface: #0F1112;--color-surface-elevated: #1A1E1F;--color-background: #09090B;--color-background-soft: #111314;--color-background-mute: #1A1E1F;--color-text-primary: #F9FAFB;--color-text-secondary: #D1D5DB;--color-text-muted: #9CA3AF;--color-heading: #FFFFFF;--color-text: #ADADAD;--color-border: #4B4B4B;--color-border-subtle: #374151;--color-border-hover: #6B7280;--color-primary: #AAE8E8;--color-secondary: #FFC246;--color-accent-green: #A5E5B6;--color-accent-purple: #EBA0FC;--color-accent-red: #FB787B;--color-accent-cyan: #D1E6E4;--color-primary-bg: #0D7377;--color-secondary-bg: #92610A;--color-accent-green-bg: #15803D;--color-accent-purple-bg: #7C3AED;--color-accent-red-bg: #DC2626;--color-accent-cyan-bg: #0E7490;--color-badge-cyan-bg: #223231;--color-badge-cyan-text: #D1E6E4;--color-badge-neutral-bg: #374151;--color-badge-neutral-text: #D1D5DB;--color-glass-bg: rgba(0, 0, 0, .4);--color-glass-border: rgba(255, 255, 255, .05);--color-glass-shadow: 0 4px 16px rgba(0, 0, 0, .2);--color-glass-green-bg: linear-gradient(91deg, rgba(21, 128, 61, .25) 1.17%, rgba(21, 128, 61, .1) 99.82%);--color-glass-green-border: rgba(165, 229, 182, .15);--color-glass-green-shadow: 0 4px 12px rgba(0, 0, 0, .2);--color-glass-orange-bg: linear-gradient(91deg, rgba(245, 158, 11, .25) 1.17%, rgba(245, 158, 11, .12) 99.82%);--color-glass-orange-border: rgba(245, 158, 11, .15);--color-glass-orange-shadow: 0 4px 12px rgba(0, 0, 0, .2)}*,*:before,*:after{box-sizing:border-box;margin:0;font-weight:400}body{min-height:100vh;color:var(--color-text);background:var(--color-background);transition:color .5s,background-color .5s;line-height:1.6;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;font-size:15px;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Noto Sans,-apple-system,Roboto,Helvetica,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.\!container{width:100%!important}.container{width:100%}@media (min-width: 640px){.\!container{max-width:640px!important}.container{max-width:640px}}@media (min-width: 768px){.\!container{max-width:768px!important}.container{max-width:768px}}@media (min-width: 1024px){.\!container{max-width:1024px!important}.container{max-width:1024px}}@media (min-width: 1280px){.\!container{max-width:1280px!important}.container{max-width:1280px}}@media (min-width: 1536px){.\!container{max-width:1536px!important}.container{max-width:1536px}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.pointer-events-auto{pointer-events:auto}.visible{visibility:visible}.static{position:static}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{inset:0}.-left-\[92px\]{left:-92px}.-right-1{right:-.25rem}.-right-6{right:-1.5rem}.-top-1{top:-.25rem}.-top-\[79px\]{top:-79px}.-top-\[94px\]{top:-94px}.bottom-0{bottom:0}.bottom-2{bottom:.5rem}.bottom-3{bottom:.75rem}.bottom-full{bottom:100%}.left-0{left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-3{left:.75rem}.left-\[246px\]{left:246px}.left-\[575px\]{left:575px}.right-1{right:.25rem}.right-2{right:.5rem}.right-3{right:.75rem}.right-4{right:1rem}.right-6{right:1.5rem}.top-0{top:0}.top-1\/2{top:50%}.top-14{top:3.5rem}.top-2{top:.5rem}.top-3{top:.75rem}.top-4{top:1rem}.top-\[373px\]{top:373px}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.z-\[1001\]{z-index:1001}.z-\[100\]{z-index:100}.z-\[1010\]{z-index:1010}.z-\[60\]{z-index:60}.z-\[9998\]{z-index:9998}.z-\[999999\]{z-index:999999}.z-\[99999\]{z-index:99999}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.-mx-3{margin-left:-.75rem;margin-right:-.75rem}.mx-1{margin-left:.25rem;margin-right:.25rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.mx-4{margin-left:1rem;margin-right:1rem}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-12{margin-bottom:3rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-0{margin-left:0}.ml-1{margin-left:.25rem}.ml-12{margin-left:3rem}.ml-16{margin-left:4rem}.ml-2{margin-left:.5rem}.ml-20{margin-left:5rem}.ml-24{margin-left:6rem}.ml-28{margin-left:7rem}.ml-32{margin-left:8rem}.ml-4{margin-left:1rem}.ml-8{margin-left:2rem}.ml-auto{margin-left:auto}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mr-3{margin-right:.75rem}.mr-6{margin-right:1.5rem}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-10{height:2.5rem}.h-12{height:3rem}.h-16{height:4rem}.h-2{height:.5rem}.h-2\.5{height:.625rem}.h-20{height:5rem}.h-24{height:6rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-32{height:8rem}.h-4{height:1rem}.h-40{height:10rem}.h-48{height:12rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-96{height:24rem}.h-\[35px\]{height:35px}.h-\[50px\]{height:50px}.h-\[512px\]{height:512px}.h-\[85vh\]{height:85vh}.h-full{height:100%}.h-px{height:1px}.max-h-0{max-height:0px}.max-h-32{max-height:8rem}.max-h-40{max-height:10rem}.max-h-\[600px\]{max-height:600px}.max-h-\[80vh\]{max-height:80vh}.max-h-\[90vh\]{max-height:90vh}.max-h-screen{max-height:100vh}.min-h-\[12rem\]{min-height:12rem}.min-h-\[400px\]{min-height:400px}.min-h-screen{min-height:100vh}.w-1{width:.25rem}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-11{width:2.75rem}.w-12{width:3rem}.w-16{width:4rem}.w-2{width:.5rem}.w-2\.5{width:.625rem}.w-20{width:5rem}.w-24{width:6rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-5{width:1.25rem}.w-6{width:1.5rem}.w-72{width:18rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-\[285px\]{width:285px}.w-\[35px\]{width:35px}.w-\[705px\]{width:705px}.w-full{width:100%}.w-px{width:1px}.min-w-0{min-width:0px}.min-w-48{min-width:12rem}.min-w-\[100px\]{min-width:100px}.min-w-\[120px\]{min-width:120px}.min-w-full{min-width:100%}.max-w-2xl{max-width:42rem}.max-w-32{max-width:8rem}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-\[140px\]{max-width:140px}.max-w-\[80px\]{max-width:80px}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.flex-1{flex:1 1 0%}.flex-shrink{flex-shrink:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.-translate-x-1\/2{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-x-full{--tw-translate-x: -100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-translate-y-1\/2{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0\.5{--tw-translate-x: .125rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-5{--tw-translate-x: 1.25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-\[24\.22deg\]{--tw-rotate: -24.22deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-0{--tw-rotate: 0deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-180{--tw-rotate: 180deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.rotate-90{--tw-rotate: 90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-0{--tw-scale-x: 0;--tw-scale-y: 0;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-100{--tw-scale-x: 1;--tw-scale-y: 1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-105{--tw-scale-x: 1.05;--tw-scale-y: 1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.scale-95{--tw-scale-x: .95;--tw-scale-y: .95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes ping{75%,to{transform:scale(2);opacity:0}}.animate-ping{animation:ping 1s cubic-bezier(0,0,.2,1) infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-help{cursor:help}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.resize{resize:both}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.items-stretch{align-items:stretch}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.justify-evenly{justify-content:space-evenly}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-x-3>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.75rem * var(--tw-space-x-reverse));margin-left:calc(.75rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-gray-200>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(229 231 235 / var(--tw-divide-opacity, 1))}.self-center{align-self:center}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-visible{overflow:visible}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.whitespace-pre-wrap{white-space:pre-wrap}.break-words{overflow-wrap:break-word}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-\[\.625rem\]{border-radius:.625rem}.rounded-\[10px\]{border-radius:10px}.rounded-\[12px\]{border-radius:12px}.rounded-\[15px\]{border-radius:15px}.rounded-\[16px\]{border-radius:16px}.rounded-\[20px\]{border-radius:20px}.rounded-\[24px\]{border-radius:24px}.rounded-\[5px\]{border-radius:5px}.rounded-\[8px\]{border-radius:8px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-sm{border-radius:.125rem}.rounded-xl{border-radius:.75rem}.rounded-b-\[15px\]{border-bottom-right-radius:15px;border-bottom-left-radius:15px}.border{border-width:1px}.border-0{border-width:0px}.border-2{border-width:2px}.border-4{border-width:4px}.border-b{border-bottom-width:1px}.border-b-2{border-bottom-width:2px}.border-l-2{border-left-width:2px}.border-l-4{border-left-width:4px}.border-t{border-top-width:1px}.border-accent-green{border-color:var(--color-accent-green)}.border-amber-500\/30{border-color:#f59e0b4d}.border-blue-500\/30{border-color:#3b82f64d}.border-blue-500\/50{border-color:#3b82f680}.border-cyan-400{--tw-border-opacity: 1;border-color:rgb(34 211 238 / var(--tw-border-opacity, 1))}.border-cyan-400\/30{border-color:#22d3ee4d}.border-cyan-400\/40{border-color:#22d3ee66}.border-cyan-500{--tw-border-opacity: 1;border-color:rgb(6 182 212 / var(--tw-border-opacity, 1))}.border-cyan-500\/50{border-color:#06b6d480}.border-gray-400\/30{border-color:#9ca3af4d}.border-gray-500\/50{border-color:#6b728080}.border-gray-600{--tw-border-opacity: 1;border-color:rgb(75 85 99 / var(--tw-border-opacity, 1))}.border-green-300{--tw-border-opacity: 1;border-color:rgb(134 239 172 / var(--tw-border-opacity, 1))}.border-green-500{--tw-border-opacity: 1;border-color:rgb(34 197 94 / var(--tw-border-opacity, 1))}.border-green-500\/20{border-color:#22c55e33}.border-green-500\/50{border-color:#22c55e80}.border-green-600\/40{border-color:#16a34a66}.border-orange-500{--tw-border-opacity: 1;border-color:rgb(249 115 22 / var(--tw-border-opacity, 1))}.border-orange-500\/30{border-color:#f973164d}.border-primary{border-color:var(--color-primary)}.border-purple-500\/50{border-color:#a855f780}.border-red-300{--tw-border-opacity: 1;border-color:rgb(252 165 165 / var(--tw-border-opacity, 1))}.border-red-500{--tw-border-opacity: 1;border-color:rgb(239 68 68 / var(--tw-border-opacity, 1))}.border-red-500\/30{border-color:#ef44444d}.border-red-500\/50{border-color:#ef444480}.border-secondary{border-color:var(--color-secondary)}.border-stroke{border-color:var(--color-border)}.border-stroke-subtle{border-color:var(--color-border-subtle)}.border-transparent{border-color:transparent}.border-white{--tw-border-opacity: 1;border-color:rgb(255 255 255 / var(--tw-border-opacity, 1))}.border-white\/10{border-color:#ffffff1a}.border-white\/20{border-color:#fff3}.border-white\/30{border-color:#ffffff4d}.border-yellow-300{--tw-border-opacity: 1;border-color:rgb(253 224 71 / var(--tw-border-opacity, 1))}.border-yellow-500{--tw-border-opacity: 1;border-color:rgb(234 179 8 / var(--tw-border-opacity, 1))}.border-yellow-500\/30{border-color:#eab3084d}.border-yellow-500\/50{border-color:#eab30880}.border-l-accent-cyan{border-left-color:var(--color-accent-cyan)}.border-l-accent-green{border-left-color:var(--color-accent-green)}.border-l-accent-purple{border-left-color:var(--color-accent-purple)}.border-l-accent-red{border-left-color:var(--color-accent-red)}.border-l-gray-500{--tw-border-opacity: 1;border-left-color:rgb(107 114 128 / var(--tw-border-opacity, 1))}.border-l-primary{border-left-color:var(--color-primary)}.border-l-secondary{border-left-color:var(--color-secondary)}.border-t-amber-600{--tw-border-opacity: 1;border-top-color:rgb(217 119 6 / var(--tw-border-opacity, 1))}.border-t-content-primary{border-top-color:var(--color-text-primary)}.border-t-cyan-500{--tw-border-opacity: 1;border-top-color:rgb(6 182 212 / var(--tw-border-opacity, 1))}.border-t-gray-900{--tw-border-opacity: 1;border-top-color:rgb(17 24 39 / var(--tw-border-opacity, 1))}.border-t-green-400{--tw-border-opacity: 1;border-top-color:rgb(74 222 128 / var(--tw-border-opacity, 1))}.border-t-green-600{--tw-border-opacity: 1;border-top-color:rgb(22 163 74 / var(--tw-border-opacity, 1))}.border-t-orange-400{--tw-border-opacity: 1;border-top-color:rgb(251 146 60 / var(--tw-border-opacity, 1))}.border-t-primary{border-top-color:var(--color-primary)}.border-t-purple-600{--tw-border-opacity: 1;border-top-color:rgb(147 51 234 / var(--tw-border-opacity, 1))}.border-t-transparent{border-top-color:transparent}.bg-accent-cyan{background-color:var(--color-accent-cyan)}.bg-accent-green{background-color:var(--color-accent-green)}.bg-accent-purple{background-color:var(--color-accent-purple)}.bg-accent-red{background-color:var(--color-accent-red)}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-background{background-color:var(--color-background)}.bg-background-mute{background-color:var(--color-background-mute)}.bg-badge-cyan-bg{background-color:var(--color-badge-cyan-bg)}.bg-badge-neutral-bg{background-color:var(--color-badge-neutral-bg)}.bg-black\/20{background-color:#0003}.bg-black\/30{background-color:#0000004d}.bg-black\/40{background-color:#0006}.bg-black\/50{background-color:#00000080}.bg-black\/60{background-color:#0009}.bg-black\/70{background-color:#000000b3}.bg-black\/80{background-color:#000c}.bg-blue-400{--tw-bg-opacity: 1;background-color:rgb(96 165 250 / var(--tw-bg-opacity, 1))}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-500\/20{background-color:#3b82f633}.bg-blue-900\/20{background-color:#1e3a8a33}.bg-content-heading{background-color:var(--color-heading)}.bg-content-primary{background-color:var(--color-text-primary)}.bg-current{background-color:currentColor}.bg-cyan-400{--tw-bg-opacity: 1;background-color:rgb(34 211 238 / var(--tw-bg-opacity, 1))}.bg-cyan-400\/20{background-color:#22d3ee33}.bg-cyan-500\/10{background-color:#06b6d41a}.bg-cyan-500\/20{background-color:#06b6d433}.bg-gray-100\/50{background-color:#f3f4f680}.bg-gray-200{--tw-bg-opacity: 1;background-color:rgb(229 231 235 / var(--tw-bg-opacity, 1))}.bg-gray-300{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity, 1))}.bg-gray-400{--tw-bg-opacity: 1;background-color:rgb(156 163 175 / var(--tw-bg-opacity, 1))}.bg-gray-50{--tw-bg-opacity: 1;background-color:rgb(249 250 251 / var(--tw-bg-opacity, 1))}.bg-gray-500{--tw-bg-opacity: 1;background-color:rgb(107 114 128 / var(--tw-bg-opacity, 1))}.bg-gray-500\/20{background-color:#6b728033}.bg-gray-600{--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.bg-gray-900\/20{background-color:#11182733}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-green-400{--tw-bg-opacity: 1;background-color:rgb(74 222 128 / var(--tw-bg-opacity, 1))}.bg-green-400\/20{background-color:#4ade8033}.bg-green-50{--tw-bg-opacity: 1;background-color:rgb(240 253 244 / var(--tw-bg-opacity, 1))}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-500\/5{background-color:#22c55e0d}.bg-orange-100{--tw-bg-opacity: 1;background-color:rgb(255 237 213 / var(--tw-bg-opacity, 1))}.bg-orange-400{--tw-bg-opacity: 1;background-color:rgb(251 146 60 / var(--tw-bg-opacity, 1))}.bg-orange-400\/20{background-color:#fb923c33}.bg-primary{background-color:var(--color-primary)}.bg-purple-400{--tw-bg-opacity: 1;background-color:rgb(192 132 252 / var(--tw-bg-opacity, 1))}.bg-purple-500\/20{background-color:#a855f733}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-400{--tw-bg-opacity: 1;background-color:rgb(248 113 113 / var(--tw-bg-opacity, 1))}.bg-red-50{--tw-bg-opacity: 1;background-color:rgb(254 242 242 / var(--tw-bg-opacity, 1))}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-red-500\/10{background-color:#ef44441a}.bg-red-900\/20{background-color:#7f1d1d33}.bg-secondary{background-color:var(--color-secondary)}.bg-stroke-subtle{background-color:var(--color-border-subtle)}.bg-surface{background-color:var(--color-surface)}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-white\/10{background-color:#ffffff1a}.bg-white\/20{background-color:#fff3}.bg-white\/5{background-color:#ffffff0d}.bg-white\/50{background-color:#ffffff80}.bg-white\/95{background-color:#fffffff2}.bg-yellow-100{--tw-bg-opacity: 1;background-color:rgb(254 249 195 / var(--tw-bg-opacity, 1))}.bg-yellow-400{--tw-bg-opacity: 1;background-color:rgb(250 204 21 / var(--tw-bg-opacity, 1))}.bg-yellow-400\/20{background-color:#facc1533}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.bg-yellow-500\/10{background-color:#eab3081a}.bg-yellow-500\/20{background-color:#eab30833}.bg-yellow-900\/20{background-color:#713f1233}.bg-gradient-to-b{background-image:linear-gradient(to bottom,var(--tw-gradient-stops))}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-t{background-image:linear-gradient(to top,var(--tw-gradient-stops))}.bg-gradient-to-tl{background-image:linear-gradient(to top left,var(--tw-gradient-stops))}.from-blue-500\/20{--tw-gradient-from: rgb(59 130 246 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(59 130 246 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-200\/30{--tw-gradient-from: rgb(165 243 252 / .3) var(--tw-gradient-from-position);--tw-gradient-to: rgb(165 243 252 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-400{--tw-gradient-from: #22d3ee var(--tw-gradient-from-position);--tw-gradient-to: rgb(34 211 238 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-400\/25{--tw-gradient-from: rgb(34 211 238 / .25) var(--tw-gradient-from-position);--tw-gradient-to: rgb(34 211 238 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-400\/90{--tw-gradient-from: rgb(34 211 238 / .9) var(--tw-gradient-from-position);--tw-gradient-to: rgb(34 211 238 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-500\/20{--tw-gradient-from: rgb(6 182 212 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(6 182 212 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-cyan-500\/50{--tw-gradient-from: rgb(6 182 212 / .5) var(--tw-gradient-from-position);--tw-gradient-to: rgb(6 182 212 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-emerald-200\/25{--tw-gradient-from: rgb(167 243 208 / .25) var(--tw-gradient-from-position);--tw-gradient-to: rgb(167 243 208 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-gray-100{--tw-gradient-from: #f3f4f6 var(--tw-gradient-from-position);--tw-gradient-to: rgb(243 244 246 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-gray-900{--tw-gradient-from: #111827 var(--tw-gradient-from-position);--tw-gradient-to: rgb(17 24 39 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-orange-500\/20{--tw-gradient-from: rgb(249 115 22 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(249 115 22 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-orange-500\/50{--tw-gradient-from: rgb(249 115 22 / .5) var(--tw-gradient-from-position);--tw-gradient-to: rgb(249 115 22 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-primary{--tw-gradient-from: var(--color-primary) var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-purple-200\/25{--tw-gradient-from: rgb(233 213 255 / .25) var(--tw-gradient-from-position);--tw-gradient-to: rgb(233 213 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-red-500\/20{--tw-gradient-from: rgb(239 68 68 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(239 68 68 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-red-500\/50{--tw-gradient-from: rgb(239 68 68 / .5) var(--tw-gradient-from-position);--tw-gradient-to: rgb(239 68 68 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-transparent{--tw-gradient-from: transparent var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-white\/5{--tw-gradient-from: rgb(255 255 255 / .05) var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-yellow-400\/30{--tw-gradient-from: rgb(250 204 21 / .3) var(--tw-gradient-from-position);--tw-gradient-to: rgb(250 204 21 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-yellow-500\/50{--tw-gradient-from: rgb(234 179 8 / .5) var(--tw-gradient-from-position);--tw-gradient-to: rgb(234 179 8 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-transparent{--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), transparent var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-white\/5{--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(255 255 255 / .05) var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-accent-green{--tw-gradient-to: var(--color-accent-green) var(--tw-gradient-to-position)}.to-blue-100\/20{--tw-gradient-to: rgb(219 234 254 / .2) var(--tw-gradient-to-position)}.to-cyan-200\/10{--tw-gradient-to: rgb(165 243 252 / .1) var(--tw-gradient-to-position)}.to-cyan-400\/20{--tw-gradient-to: rgb(34 211 238 / .2) var(--tw-gradient-to-position)}.to-cyan-500{--tw-gradient-to: #06b6d4 var(--tw-gradient-to-position)}.to-cyan-500\/20{--tw-gradient-to: rgb(6 182 212 / .2) var(--tw-gradient-to-position)}.to-cyan-500\/90{--tw-gradient-to: rgb(6 182 212 / .9) var(--tw-gradient-to-position)}.to-cyan-600\/50{--tw-gradient-to: rgb(8 145 178 / .5) var(--tw-gradient-to-position)}.to-gray-200{--tw-gradient-to: #e5e7eb var(--tw-gradient-to-position)}.to-gray-700{--tw-gradient-to: #374151 var(--tw-gradient-to-position)}.to-orange-400\/30{--tw-gradient-to: rgb(251 146 60 / .3) var(--tw-gradient-to-position)}.to-orange-600\/50{--tw-gradient-to: rgb(234 88 12 / .5) var(--tw-gradient-to-position)}.to-pink-100\/15{--tw-gradient-to: rgb(252 231 243 / .15) var(--tw-gradient-to-position)}.to-red-500\/10{--tw-gradient-to: rgb(239 68 68 / .1) var(--tw-gradient-to-position)}.to-red-600\/50{--tw-gradient-to: rgb(220 38 38 / .5) var(--tw-gradient-to-position)}.to-teal-100\/15{--tw-gradient-to: rgb(204 251 241 / .15) var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.to-yellow-500\/20{--tw-gradient-to: rgb(234 179 8 / .2) var(--tw-gradient-to-position)}.to-yellow-600\/50{--tw-gradient-to: rgb(202 138 4 / .5) var(--tw-gradient-to-position)}.bg-clip-text{-webkit-background-clip:text;background-clip:text}.p-0\.5{padding:.125rem}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.p-\[15px\]{padding:15px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-8{padding-left:2rem;padding-right:2rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-2{padding-left:.5rem}.pl-9{padding-left:2.25rem}.pr-4{padding-right:1rem}.pt-2{padding-top:.5rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.pt-6{padding-top:1.5rem}.pt-8{padding-top:2rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.font-sans{font-family:Noto Sans,-apple-system,Roboto,Helvetica,sans-serif}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-\[1\.25rem\]{font-size:1.25rem}.text-\[10px\]{font-size:10px}.text-\[22px\]{font-size:22px}.text-\[24px\]{font-size:24px}.text-\[8px\]{font-size:8px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-extrabold{font-weight:800}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.capitalize{text-transform:capitalize}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal) var(--tw-slashed-zero) var(--tw-numeric-figure) var(--tw-numeric-spacing) var(--tw-numeric-fraction)}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.text-\[\#212122\]{--tw-text-opacity: 1;color:rgb(33 33 34 / var(--tw-text-opacity, 1))}.text-accent-green{color:var(--color-accent-green)}.text-accent-purple{color:var(--color-accent-purple)}.text-accent-red{color:var(--color-accent-red)}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-700{--tw-text-opacity: 1;color:rgb(180 83 9 / var(--tw-text-opacity, 1))}.text-background{color:var(--color-background)}.text-badge-cyan-text{color:var(--color-badge-cyan-text)}.text-badge-neutral-text{color:var(--color-badge-neutral-text)}.text-blue-100{--tw-text-opacity: 1;color:rgb(219 234 254 / var(--tw-text-opacity, 1))}.text-blue-200{--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-blue-600{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.text-blue-700{--tw-text-opacity: 1;color:rgb(29 78 216 / var(--tw-text-opacity, 1))}.text-content-heading{color:var(--color-heading)}.text-content-muted{color:var(--color-text-muted)}.text-content-primary{color:var(--color-text-primary)}.text-content-secondary{color:var(--color-text-secondary)}.text-cyan-500{--tw-text-opacity: 1;color:rgb(6 182 212 / var(--tw-text-opacity, 1))}.text-cyan-600{--tw-text-opacity: 1;color:rgb(8 145 178 / var(--tw-text-opacity, 1))}.text-cyan-700{--tw-text-opacity: 1;color:rgb(14 116 144 / var(--tw-text-opacity, 1))}.text-cyan-900{--tw-text-opacity: 1;color:rgb(22 78 99 / var(--tw-text-opacity, 1))}.text-emerald-600{--tw-text-opacity: 1;color:rgb(5 150 105 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.text-green-600{--tw-text-opacity: 1;color:rgb(22 163 74 / var(--tw-text-opacity, 1))}.text-green-700{--tw-text-opacity: 1;color:rgb(21 128 61 / var(--tw-text-opacity, 1))}.text-orange-500{--tw-text-opacity: 1;color:rgb(249 115 22 / var(--tw-text-opacity, 1))}.text-orange-600{--tw-text-opacity: 1;color:rgb(234 88 12 / var(--tw-text-opacity, 1))}.text-primary{color:var(--color-primary)}.text-purple-500{--tw-text-opacity: 1;color:rgb(168 85 247 / var(--tw-text-opacity, 1))}.text-purple-600{--tw-text-opacity: 1;color:rgb(147 51 234 / var(--tw-text-opacity, 1))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-600{--tw-text-opacity: 1;color:rgb(220 38 38 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-secondary{color:var(--color-secondary)}.text-transparent{color:transparent}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/30{color:#ffffff4d}.text-white\/40{color:#fff6}.text-white\/50{color:#ffffff80}.text-white\/60{color:#fff9}.text-white\/80{color:#fffc}.text-white\/90{color:#ffffffe6}.text-yellow-200{--tw-text-opacity: 1;color:rgb(254 240 138 / var(--tw-text-opacity, 1))}.text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.text-yellow-600{--tw-text-opacity: 1;color:rgb(202 138 4 / var(--tw-text-opacity, 1))}.text-yellow-700{--tw-text-opacity: 1;color:rgb(161 98 7 / var(--tw-text-opacity, 1))}.text-yellow-800{--tw-text-opacity: 1;color:rgb(133 77 14 / var(--tw-text-opacity, 1))}.underline{text-decoration-line:underline}.decoration-gray-400{text-decoration-color:#9ca3af}.decoration-green-400\/60{text-decoration-color:#4ade8099}.underline-offset-2{text-underline-offset:2px}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(156 163 175 / var(--tw-placeholder-opacity, 1))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity: 1;color:rgb(156 163 175 / var(--tw-placeholder-opacity, 1))}.placeholder-gray-500::-moz-placeholder{--tw-placeholder-opacity: 1;color:rgb(107 114 128 / var(--tw-placeholder-opacity, 1))}.placeholder-gray-500::placeholder{--tw-placeholder-opacity: 1;color:rgb(107 114 128 / var(--tw-placeholder-opacity, 1))}.opacity-0{opacity:0}.opacity-100{opacity:1}.opacity-25{opacity:.25}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-65{opacity:.65}.opacity-70{opacity:.7}.opacity-75{opacity:.75}.opacity-80{opacity:.8}.opacity-90{opacity:.9}.mix-blend-normal{mix-blend-mode:normal}.mix-blend-multiply{mix-blend-mode:multiply}.mix-blend-screen{mix-blend-mode:screen}.shadow-2xl{--tw-shadow: 0 25px 50px -12px rgb(0 0 0 / .25);--tw-shadow-colored: 0 25px 50px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_0_\.375rem_0_rgba\(170\,232\,232\,0\.20\)\]{--tw-shadow: 0 0 .375rem 0 rgba(170,232,232,.2);--tw-shadow-colored: 0 0 .375rem 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_2px_12px_rgba\(6\,182\,212\,0\.3\)\]{--tw-shadow: 0 2px 12px rgba(6,182,212,.3);--tw-shadow-colored: 0 2px 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_4px_16px_rgba\(6\,182\,212\,0\.4\)\]{--tw-shadow: 0 4px 16px rgba(6,182,212,.4);--tw-shadow-colored: 0 4px 16px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_8px_32px_0_rgba\(0\,0\,0\,0\.1\)\]{--tw-shadow: 0 8px 32px 0 rgba(0,0,0,.1);--tw-shadow-colored: 0 8px 32px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-\[0_8px_32px_0_rgba\(0\,0\,0\,0\.37\)\]{--tw-shadow: 0 8px 32px 0 rgba(0,0,0,.37);--tw-shadow-colored: 0 8px 32px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-yellow-400\/20{--tw-shadow-color: rgb(250 204 21 / .2);--tw-shadow: var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.ring-2{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-cyan-400\/50{--tw-ring-color: rgb(34 211 238 / .5)}.blur{--tw-blur: blur(8px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-3xl{--tw-blur: blur(64px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-\[120px\]{--tw-blur: blur(120px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.blur-xl{--tw-blur: blur(24px);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.brightness-0{--tw-brightness: brightness(0);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow{--tw-drop-shadow: drop-shadow(0 1px 2px rgb(0 0 0 / .1)) drop-shadow(0 1px 1px rgb(0 0 0 / .06));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.drop-shadow-\[0_0_6px_rgba\(6\,182\,212\,0\.8\)\]{--tw-drop-shadow: drop-shadow(0 0 6px rgba(6,182,212,.8));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.invert{--tw-invert: invert(100%);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur: blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-lg{--tw-backdrop-blur: blur(16px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-md{--tw-backdrop-blur: blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-xl{--tw-backdrop-blur: blur(24px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.duration-500{transition-duration:.5s}.duration-700{transition-duration:.7s}.ease-in{transition-timing-function:cubic-bezier(.4,0,1,1)}.ease-in-out{transition-timing-function:cubic-bezier(.4,0,.2,1)}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}.glass-card{border-radius:10px;--tw-backdrop-blur: blur(50px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background:var(--color-glass-bg);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)}.glass-card-green{border-radius:10px;--tw-backdrop-blur: blur(50px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background:var(--color-glass-green-bg);border:1px solid var(--color-glass-green-border);box-shadow:var(--color-glass-green-shadow)}.glass-card-orange{border-radius:10px;--tw-backdrop-blur: blur(50px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);background:var(--color-glass-orange-bg);border:1px solid var(--color-glass-orange-border);box-shadow:var(--color-glass-orange-shadow)}.last\:border-b-0:last-child{border-bottom-width:0px}.hover\:scale-105:hover{--tw-scale-x: 1.05;--tw-scale-y: 1.05;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-110:hover{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:scale-\[1\.02\]:hover{--tw-scale-x: 1.02;--tw-scale-y: 1.02;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.hover\:border-cyan-400:hover{--tw-border-opacity: 1;border-color:rgb(34 211 238 / var(--tw-border-opacity, 1))}.hover\:border-cyan-400\/30:hover{border-color:#22d3ee4d}.hover\:border-orange-500:hover{--tw-border-opacity: 1;border-color:rgb(249 115 22 / var(--tw-border-opacity, 1))}.hover\:border-primary:hover{border-color:var(--color-primary)}.hover\:border-stroke:hover{border-color:var(--color-border)}.hover\:border-stroke-subtle:hover{border-color:var(--color-border-subtle)}.hover\:border-yellow-500\/50:hover{border-color:#eab30880}.hover\:bg-amber-600:hover{--tw-bg-opacity: 1;background-color:rgb(217 119 6 / var(--tw-bg-opacity, 1))}.hover\:bg-background-mute:hover{background-color:var(--color-background-mute)}.hover\:bg-black\/90:hover{background-color:#000000e6}.hover\:bg-blue-600:hover{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.hover\:bg-content-primary:hover{background-color:var(--color-text-primary)}.hover\:bg-cyan-500\/30:hover{background-color:#06b6d44d}.hover\:bg-gray-100\/50:hover{background-color:#f3f4f680}.hover\:bg-gray-300:hover{--tw-bg-opacity: 1;background-color:rgb(209 213 219 / var(--tw-bg-opacity, 1))}.hover\:bg-green-600:hover{--tw-bg-opacity: 1;background-color:rgb(22 163 74 / var(--tw-bg-opacity, 1))}.hover\:bg-purple-500\/30:hover{background-color:#a855f74d}.hover\:bg-red-500\/30:hover{background-color:#ef44444d}.hover\:bg-red-600:hover{--tw-bg-opacity: 1;background-color:rgb(220 38 38 / var(--tw-bg-opacity, 1))}.hover\:bg-stroke-subtle:hover{background-color:var(--color-border-subtle)}.hover\:bg-white\/10:hover{background-color:#ffffff1a}.hover\:bg-white\/5:hover{background-color:#ffffff0d}.hover\:bg-yellow-50:hover{--tw-bg-opacity: 1;background-color:rgb(254 252 232 / var(--tw-bg-opacity, 1))}.hover\:bg-yellow-600:hover{--tw-bg-opacity: 1;background-color:rgb(202 138 4 / var(--tw-bg-opacity, 1))}.hover\:bg-gradient-to-r:hover{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.hover\:from-cyan-400\/20:hover{--tw-gradient-from: rgb(34 211 238 / .2) var(--tw-gradient-from-position);--tw-gradient-to: rgb(34 211 238 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.hover\:from-cyan-500:hover{--tw-gradient-from: #06b6d4 var(--tw-gradient-from-position);--tw-gradient-to: rgb(6 182 212 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.hover\:from-cyan-500\/30:hover{--tw-gradient-from: rgb(6 182 212 / .3) var(--tw-gradient-from-position);--tw-gradient-to: rgb(6 182 212 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.hover\:from-gray-200:hover{--tw-gradient-from: #e5e7eb var(--tw-gradient-from-position);--tw-gradient-to: rgb(229 231 235 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.hover\:from-red-500\/30:hover{--tw-gradient-from: rgb(239 68 68 / .3) var(--tw-gradient-from-position);--tw-gradient-to: rgb(239 68 68 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.hover\:to-cyan-400\/30:hover{--tw-gradient-to: rgb(34 211 238 / .3) var(--tw-gradient-to-position)}.hover\:to-cyan-500\/20:hover{--tw-gradient-to: rgb(6 182 212 / .2) var(--tw-gradient-to-position)}.hover\:to-cyan-600:hover{--tw-gradient-to: #0891b2 var(--tw-gradient-to-position)}.hover\:to-gray-300:hover{--tw-gradient-to: #d1d5db var(--tw-gradient-to-position)}.hover\:to-red-500\/20:hover{--tw-gradient-to: rgb(239 68 68 / .2) var(--tw-gradient-to-position)}.hover\:text-blue-600:hover{--tw-text-opacity: 1;color:rgb(37 99 235 / var(--tw-text-opacity, 1))}.hover\:text-content-heading:hover{color:var(--color-heading)}.hover\:text-content-primary:hover{color:var(--color-text-primary)}.hover\:text-content-secondary:hover{color:var(--color-text-secondary)}.hover\:text-cyan-800:hover{--tw-text-opacity: 1;color:rgb(21 94 117 / var(--tw-text-opacity, 1))}.hover\:text-gray-900:hover{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.hover\:text-primary:hover{color:var(--color-primary)}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:text-white\/80:hover{color:#fffc}.hover\:opacity-90:hover{opacity:.9}.hover\:shadow-\[0_2px_12px_rgba\(6\,182\,212\,0\.2\)\]:hover{--tw-shadow: 0 2px 12px rgba(6,182,212,.2);--tw-shadow-colored: 0 2px 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.hover\:shadow-lg:hover{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.focus\:border-cyan-500:focus{--tw-border-opacity: 1;border-color:rgb(6 182 212 / var(--tw-border-opacity, 1))}.focus\:border-primary:focus{border-color:var(--color-primary)}.focus\:border-transparent:focus{border-color:transparent}.focus\:bg-white:focus{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-2:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-primary:focus{--tw-ring-color: var(--color-primary)}.focus\:ring-offset-background:focus{--tw-ring-offset-color: var(--color-background)}.active\:scale-\[0\.98\]:active{--tw-scale-x: .98;--tw-scale-y: .98;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:border-gray-500\/20:disabled{border-color:#6b728033}.disabled\:bg-amber-500\/50:disabled{background-color:#f59e0b80}.disabled\:bg-gray-500\/10:disabled{background-color:#6b72801a}.disabled\:text-gray-400:disabled{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.disabled\:opacity-50:disabled{opacity:.5}.group:hover .group-hover\:translate-x-1{--tw-translate-x: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:translate-y-1{--tw-translate-y: .25rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group\/delete:hover .group-hover\/delete\:rotate-12,.group:hover .group-hover\:rotate-12{--tw-rotate: 12deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:scale-110{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:border-stroke{border-color:var(--color-border)}.group:hover .group-hover\:bg-background-mute{background-color:var(--color-background-mute)}.group:hover .group-hover\:text-primary{color:var(--color-primary)}.group:hover .group-hover\:text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.group:hover .group-hover\:text-yellow-500{--tw-text-opacity: 1;color:rgb(234 179 8 / var(--tw-text-opacity, 1))}.group:hover .group-hover\:opacity-100{opacity:1}.peer:checked~.peer-checked\:scale-100{--tw-scale-x: 1;--tw-scale-y: 1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.peer:checked~.peer-checked\:border-primary{border-color:var(--color-primary)}.group:has(:checked) .group-has-\[\:checked\]\:scale-100{--tw-scale-x: 1;--tw-scale-y: 1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:has(:checked) .group-has-\[\:checked\]\:border-accent-green{border-color:var(--color-accent-green)}.group:has(:checked) .group-has-\[\:checked\]\:border-accent-red{border-color:var(--color-accent-red)}.group:has(:checked) .group-has-\[\:checked\]\:bg-accent-green{background-color:var(--color-accent-green)}.group:has(:checked) .group-has-\[\:checked\]\:bg-accent-red{background-color:var(--color-accent-red)}.dark\:block:is(.dark *){display:block}.dark\:hidden:is(.dark *){display:none}.dark\:divide-white\/5:is(.dark *)>:not([hidden])~:not([hidden]){border-color:#ffffff0d}.dark\:border:is(.dark *){border-width:1px}.dark\:border-blue-400\/50:is(.dark *){border-color:#60a5fa80}.dark\:border-dark-border:is(.dark *){border-color:var(--color-border)}.dark\:border-green-400\/30:is(.dark *){border-color:#4ade804d}.dark\:border-green-500\/30:is(.dark *){border-color:#22c55e4d}.dark\:border-green-500\/50:is(.dark *){border-color:#22c55e80}.dark\:border-orange-400\/30:is(.dark *){border-color:#fb923c4d}.dark\:border-orange-400\/40:is(.dark *){border-color:#fb923c66}.dark\:border-orange-400\/60:is(.dark *){border-color:#fb923c99}.dark\:border-primary:is(.dark *){border-color:var(--color-primary)}.dark\:border-red-500\/30:is(.dark *){border-color:#ef44444d}.dark\:border-red-500\/50:is(.dark *){border-color:#ef444480}.dark\:border-stroke:is(.dark *){border-color:var(--color-border)}.dark\:border-transparent:is(.dark *){border-color:transparent}.dark\:border-white\/10:is(.dark *){border-color:#ffffff1a}.dark\:border-white\/20:is(.dark *){border-color:#fff3}.dark\:border-white\/5:is(.dark *){border-color:#ffffff0d}.dark\:border-yellow-400\/30:is(.dark *){border-color:#facc154d}.dark\:border-yellow-500\/30:is(.dark *){border-color:#eab3084d}.dark\:border-t-amber-400:is(.dark *){--tw-border-opacity: 1;border-top-color:rgb(251 191 36 / var(--tw-border-opacity, 1))}.dark\:border-t-green-400:is(.dark *){--tw-border-opacity: 1;border-top-color:rgb(74 222 128 / var(--tw-border-opacity, 1))}.dark\:border-t-primary:is(.dark *){border-top-color:var(--color-primary)}.dark\:border-t-purple-400:is(.dark *){--tw-border-opacity: 1;border-top-color:rgb(192 132 252 / var(--tw-border-opacity, 1))}.dark\:border-t-white\/70:is(.dark *){border-top-color:#ffffffb3}.dark\:bg-accent-purple:is(.dark *){background-color:var(--color-accent-purple)}.dark\:bg-accent-red:is(.dark *){background-color:var(--color-accent-red)}.dark\:bg-background:is(.dark *){background-color:var(--color-background)}.dark\:bg-background-mute:is(.dark *){background-color:var(--color-background-mute)}.dark\:bg-black\/20:is(.dark *){background-color:#0003}.dark\:bg-black\/30:is(.dark *){background-color:#0000004d}.dark\:bg-black\/40:is(.dark *){background-color:#0006}.dark\:bg-blue-400\/20:is(.dark *){background-color:#60a5fa33}.dark\:bg-gray-700:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.dark\:bg-gray-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.dark\:bg-green-500\/10:is(.dark *){background-color:#22c55e1a}.dark\:bg-green-500\/20:is(.dark *){background-color:#22c55e33}.dark\:bg-green-600\/20:is(.dark *){background-color:#16a34a33}.dark\:bg-orange-500\/20:is(.dark *){background-color:#f9731633}.dark\:bg-primary:is(.dark *){background-color:var(--color-primary)}.dark\:bg-red-500\/10:is(.dark *){background-color:#ef44441a}.dark\:bg-red-500\/20:is(.dark *){background-color:#ef444433}.dark\:bg-secondary:is(.dark *){background-color:var(--color-secondary)}.dark\:bg-surface-elevated:is(.dark *){background-color:var(--color-surface-elevated)}.dark\:bg-transparent:is(.dark *){background-color:transparent}.dark\:bg-white:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.dark\:bg-white\/10:is(.dark *){background-color:#ffffff1a}.dark\:bg-white\/5:is(.dark *){background-color:#ffffff0d}.dark\:bg-yellow-500\/10:is(.dark *){background-color:#eab3081a}.dark\:bg-yellow-500\/20:is(.dark *){background-color:#eab30833}.dark\:from-transparent:is(.dark *){--tw-gradient-from: transparent var(--tw-gradient-from-position);--tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.dark\:from-white:is(.dark *){--tw-gradient-from: #fff var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.dark\:from-white\/5:is(.dark *){--tw-gradient-from: rgb(255 255 255 / .05) var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.dark\:to-transparent:is(.dark *){--tw-gradient-to: transparent var(--tw-gradient-to-position)}.dark\:to-white\/10:is(.dark *){--tw-gradient-to: rgb(255 255 255 / .1) var(--tw-gradient-to-position)}.dark\:to-white\/70:is(.dark *){--tw-gradient-to: rgb(255 255 255 / .7) var(--tw-gradient-to-position)}.dark\:text-\[\#212122\]:is(.dark *){--tw-text-opacity: 1;color:rgb(33 33 34 / var(--tw-text-opacity, 1))}.dark\:text-\[\#C3C3C3\]:is(.dark *){--tw-text-opacity: 1;color:rgb(195 195 195 / var(--tw-text-opacity, 1))}.dark\:text-accent-green:is(.dark *){color:var(--color-accent-green)}.dark\:text-accent-purple:is(.dark *){color:var(--color-accent-purple)}.dark\:text-accent-red:is(.dark *){color:var(--color-accent-red)}.dark\:text-amber-400:is(.dark *){--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.dark\:text-amber-400\/80:is(.dark *){color:#fbbf24cc}.dark\:text-background:is(.dark *){color:var(--color-background)}.dark\:text-blue-200:is(.dark *){--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity, 1))}.dark\:text-blue-400:is(.dark *){--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.dark\:text-content:is(.dark *){color:var(--color-text)}.dark\:text-content-muted:is(.dark *){color:var(--color-text-muted)}.dark\:text-content-primary:is(.dark *){color:var(--color-text-primary)}.dark\:text-content-secondary:is(.dark *){color:var(--color-text-secondary)}.dark\:text-cyan-300:is(.dark *){--tw-text-opacity: 1;color:rgb(103 232 249 / var(--tw-text-opacity, 1))}.dark\:text-cyan-400:is(.dark *){--tw-text-opacity: 1;color:rgb(34 211 238 / var(--tw-text-opacity, 1))}.dark\:text-cyan-400\/60:is(.dark *){color:#22d3ee99}.dark\:text-emerald-400:is(.dark *){--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.dark\:text-gray-400:is(.dark *){--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.dark\:text-gray-500:is(.dark *){--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.dark\:text-green-300:is(.dark *){--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.dark\:text-green-400:is(.dark *){--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.dark\:text-green-500:is(.dark *){--tw-text-opacity: 1;color:rgb(34 197 94 / var(--tw-text-opacity, 1))}.dark\:text-orange-400:is(.dark *){--tw-text-opacity: 1;color:rgb(251 146 60 / var(--tw-text-opacity, 1))}.dark\:text-orange-400\/60:is(.dark *){color:#fb923c99}.dark\:text-primary:is(.dark *){color:var(--color-primary)}.dark\:text-purple-400:is(.dark *){--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.dark\:text-red-200:is(.dark *){--tw-text-opacity: 1;color:rgb(254 202 202 / var(--tw-text-opacity, 1))}.dark\:text-red-400:is(.dark *){--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.dark\:text-secondary:is(.dark *){color:var(--color-secondary)}.dark\:text-white:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.dark\:text-white\/60:is(.dark *){color:#fff9}.dark\:text-yellow-200:is(.dark *){--tw-text-opacity: 1;color:rgb(254 240 138 / var(--tw-text-opacity, 1))}.dark\:text-yellow-300:is(.dark *){--tw-text-opacity: 1;color:rgb(253 224 71 / var(--tw-text-opacity, 1))}.dark\:text-yellow-400:is(.dark *){--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.dark\:decoration-white\/30:is(.dark *){text-decoration-color:#ffffff4d}.dark\:placeholder-white\/30:is(.dark *)::-moz-placeholder{color:#ffffff4d}.dark\:placeholder-white\/30:is(.dark *)::placeholder{color:#ffffff4d}.dark\:placeholder-white\/40:is(.dark *)::-moz-placeholder{color:#fff6}.dark\:placeholder-white\/40:is(.dark *)::placeholder{color:#fff6}.dark\:placeholder-white\/50:is(.dark *)::-moz-placeholder{color:#ffffff80}.dark\:placeholder-white\/50:is(.dark *)::placeholder{color:#ffffff80}.dark\:mix-blend-screen:is(.dark *){mix-blend-mode:screen}.dark\:shadow-\[0_4px_12px_rgba\(170\,232\,232\,0\.25\)\]:is(.dark *){--tw-shadow: 0 4px 12px rgba(170,232,232,.25);--tw-shadow-colored: 0 4px 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:shadow-\[0_8px_32px_0_rgba\(0\,0\,0\,0\.37\)\]:is(.dark *){--tw-shadow: 0 8px 32px 0 rgba(0,0,0,.37);--tw-shadow-colored: 0 8px 32px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:shadow-none:is(.dark *){--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:brightness-100:is(.dark *){--tw-brightness: brightness(1);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.dark\:drop-shadow-\[0_0_6px_rgba\(59\,130\,246\,0\.8\)\]:is(.dark *){--tw-drop-shadow: drop-shadow(0 0 6px rgba(59,130,246,.8));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.dark\:invert-0:is(.dark *){--tw-invert: invert(0);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.dark\:hover\:border-stroke:hover:is(.dark *){border-color:var(--color-border)}.dark\:hover\:bg-background-mute:hover:is(.dark *){background-color:var(--color-background-mute)}.dark\:hover\:bg-gray-100:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(243 244 246 / var(--tw-bg-opacity, 1))}.dark\:hover\:bg-gray-600:hover:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(75 85 99 / var(--tw-bg-opacity, 1))}.dark\:hover\:bg-white\/10:hover:is(.dark *){background-color:#ffffff1a}.dark\:hover\:bg-white\/20:hover:is(.dark *){background-color:#fff3}.dark\:hover\:bg-white\/5:hover:is(.dark *){background-color:#ffffff0d}.dark\:hover\:bg-yellow-500\/20:hover:is(.dark *){background-color:#eab30833}.dark\:hover\:from-white\/10:hover:is(.dark *){--tw-gradient-from: rgb(255 255 255 / .1) var(--tw-gradient-from-position);--tw-gradient-to: rgb(255 255 255 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.dark\:hover\:to-white\/15:hover:is(.dark *){--tw-gradient-to: rgb(255 255 255 / .15) var(--tw-gradient-to-position)}.dark\:hover\:text-content-muted:hover:is(.dark *){color:var(--color-text-muted)}.dark\:hover\:text-content-primary:hover:is(.dark *){color:var(--color-text-primary)}.dark\:hover\:text-content-secondary:hover:is(.dark *){color:var(--color-text-secondary)}.dark\:hover\:text-primary:hover:is(.dark *){color:var(--color-primary)}.dark\:hover\:text-white:hover:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.dark\:hover\:shadow-\[0_2px_8px_rgba\(170\,232\,212\,0\.15\)\]:hover:is(.dark *){--tw-shadow: 0 2px 8px rgba(170,232,212,.15);--tw-shadow-colored: 0 2px 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:hover\:shadow-\[0_2px_8px_rgba\(170\,232\,232\,0\.15\)\]:hover:is(.dark *){--tw-shadow: 0 2px 8px rgba(170,232,232,.15);--tw-shadow-colored: 0 2px 8px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:focus\:bg-white\/10:focus:is(.dark *){background-color:#ffffff1a}.group:hover .dark\:group-hover\:text-white:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}@media (min-width: 640px){.sm\:mx-0{margin-left:0;margin-right:0}.sm\:mb-10{margin-bottom:2.5rem}.sm\:mb-2{margin-bottom:.5rem}.sm\:mb-3{margin-bottom:.75rem}.sm\:mb-4{margin-bottom:1rem}.sm\:mb-6{margin-bottom:1.5rem}.sm\:ml-4{margin-left:1rem}.sm\:mr-6{margin-right:1.5rem}.sm\:mt-2{margin-top:.5rem}.sm\:mt-8{margin-top:2rem}.sm\:block{display:block}.sm\:inline-block{display:inline-block}.sm\:inline{display:inline}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:h-10{height:2.5rem}.sm\:h-3{height:.75rem}.sm\:h-4{height:1rem}.sm\:h-48{height:12rem}.sm\:h-5{height:1.25rem}.sm\:h-6{height:1.5rem}.sm\:h-8{height:2rem}.sm\:h-9{height:2.25rem}.sm\:min-h-\[16rem\]{min-height:16rem}.sm\:w-10{width:2.5rem}.sm\:w-3{width:.75rem}.sm\:w-32{width:8rem}.sm\:w-4{width:1rem}.sm\:w-48{width:12rem}.sm\:w-5{width:1.25rem}.sm\:w-6{width:1.5rem}.sm\:w-64{width:16rem}.sm\:w-8{width:2rem}.sm\:w-auto{width:auto}.sm\:max-w-xs{max-width:20rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:flex-nowrap{flex-wrap:nowrap}.sm\:items-start{align-items:flex-start}.sm\:items-center{align-items:center}.sm\:justify-between{justify-content:space-between}.sm\:gap-2{gap:.5rem}.sm\:gap-2\.5{gap:.625rem}.sm\:gap-3{gap:.75rem}.sm\:gap-4{gap:1rem}.sm\:gap-6{gap:1.5rem}.sm\:space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.sm\:space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.sm\:rounded-\[24px\]{border-radius:24px}.sm\:border{border-width:1px}.sm\:border-stroke-subtle{border-color:var(--color-border-subtle)}.sm\:p-1{padding:.25rem}.sm\:p-10{padding:2.5rem}.sm\:p-3\.5{padding:.875rem}.sm\:p-4{padding:1rem}.sm\:p-6{padding:1.5rem}.sm\:p-8{padding:2rem}.sm\:px-0{padding-left:0;padding-right:0}.sm\:px-2{padding-left:.5rem;padding-right:.5rem}.sm\:px-3{padding-left:.75rem;padding-right:.75rem}.sm\:px-4{padding-left:1rem;padding-right:1rem}.sm\:py-2{padding-top:.5rem;padding-bottom:.5rem}.sm\:py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.sm\:py-4{padding-top:1rem;padding-bottom:1rem}.sm\:pt-4{padding-top:1rem}.sm\:pt-6{padding-top:1.5rem}.sm\:text-right{text-align:right}.sm\:text-2xl{font-size:1.5rem;line-height:2rem}.sm\:text-3xl{font-size:1.875rem;line-height:2.25rem}.sm\:text-\[32px\]{font-size:32px}.sm\:text-base{font-size:1rem;line-height:1.5rem}.sm\:text-lg{font-size:1.125rem;line-height:1.75rem}.sm\:text-sm{font-size:.875rem;line-height:1.25rem}.sm\:text-xl{font-size:1.25rem;line-height:1.75rem}.sm\:text-xs{font-size:.75rem;line-height:1rem}}@media (min-width: 768px){.md\:block{display:block}.md\:grid{display:grid}.md\:hidden{display:none}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.md\:gap-3{gap:.75rem}.md\:space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.md\:p-12{padding:3rem}.md\:p-4{padding:1rem}.md\:px-4{padding-left:1rem;padding-right:1rem}.md\:text-xl{font-size:1.25rem;line-height:1.75rem}}@media (min-width: 1024px){.lg\:mb-4{margin-bottom:1rem}.lg\:mt-3{margin-top:.75rem}.lg\:mt-4{margin-top:1rem}.lg\:block{display:block}.lg\:flex{display:flex}.lg\:grid{display:grid}.lg\:hidden{display:none}.lg\:h-48{height:12rem}.lg\:h-56{height:14rem}.lg\:w-7{width:1.75rem}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.lg\:flex-row{flex-direction:row}.lg\:items-center{align-items:center}.lg\:justify-between{justify-content:space-between}.lg\:gap-3{gap:.75rem}.lg\:gap-4{gap:1rem}.lg\:gap-6{gap:1.5rem}.lg\:p-6{padding:1.5rem}.lg\:p-\[15px\]{padding:15px}.lg\:text-2xl{font-size:1.5rem;line-height:2rem}.lg\:text-\[35px\]{font-size:35px}.lg\:text-base{font-size:1rem;line-height:1.5rem}.lg\:text-sm{font-size:.875rem;line-height:1.25rem}.lg\:text-xl{font-size:1.25rem;line-height:1.75rem}}.\[\&_path\]\:fill-current path{fill:currentColor}@keyframes sparkline-draw-03250605{0%{stroke-dasharray:1000;stroke-dashoffset:1000}to{stroke-dasharray:1000;stroke-dashoffset:0}}.sparkline-animate[data-v-03250605]{animation:sparkline-draw-03250605 1s ease-out}.glass-card[data-v-2eb89c71]{background:#000000b3;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);border:1px solid rgba(255,255,255,.1)}@keyframes ping-2eb89c71{75%,to{transform:scale(2);opacity:0}}@keyframes ping-fast-2eb89c71{0%{transform:scale(1);opacity:1}75%,to{transform:scale(4);opacity:0}}.animate-ping[data-v-2eb89c71]{animation:ping-2eb89c71 cubic-bezier(0,0,.2,1) infinite}.animate-ping-fast[data-v-2eb89c71]{animation:ping-fast-2eb89c71 .8s cubic-bezier(0,0,.2,1) 3}body{margin:0;padding:0;transition:background-color .3s ease,color .3s ease}body{background-color:#f9fafb;color:#1f2937}.dark body{background-color:#09090b;color:#fff}.dark html{scrollbar-width:thin;scrollbar-color:#374151 #1f2937}.dark html::-webkit-scrollbar{width:8px}.dark html::-webkit-scrollbar-track{background:#1f2937}.dark html::-webkit-scrollbar-thumb{background-color:#374151;border-radius:4px}.dark html::-webkit-scrollbar-thumb:hover{background-color:#4b5563}html{scrollbar-width:thin;scrollbar-color:#D1D5DB #F3F4F6}html::-webkit-scrollbar{width:8px}html::-webkit-scrollbar-track{background:#f3f4f6}html::-webkit-scrollbar-thumb{background-color:#d1d5db;border-radius:4px}html::-webkit-scrollbar-thumb:hover{background-color:#9ca3af}.scrollbar-hide{-ms-overflow-style:none;scrollbar-width:none}.scrollbar-hide::-webkit-scrollbar{display:none} diff --git a/repeater/web/html/assets/index-DyUIpN7m.js b/repeater/web/html/assets/index-DyUIpN7m.js new file mode 100644 index 0000000..50f0437 --- /dev/null +++ b/repeater/web/html/assets/index-DyUIpN7m.js @@ -0,0 +1,35 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Setup-CLJIlSKT.js","assets/Setup-RshMWyiL.css","assets/Login-BwBtx78C.js","assets/Login-BiyTDci2.css","assets/Dashboard-BqBlpKE8.js","assets/chart-B185MtDy.js","assets/useSignalQuality-DR_wpBbb.js","assets/preferences-DtwbSSgO.js","assets/Dashboard-CZYwlk3m.css","assets/Neighbors-CXfm_tfh.js","assets/leaflet-src-BtisrQHC.js","assets/_commonjsHelpers-CqkleIqs.js","assets/Neighbors-BPsas1hQ.css","assets/leaflet-Dgihpmma.css","assets/Statistics-JA9qMWm0.js","assets/chartjs-adapter-date-fns.esm-BYg_FBhT.js","assets/chartjs-adapter-date-fns-kwjCs6JU.css","assets/plotly.min-DO11Gp-n.js","assets/Statistics-C56LjnFt.css","assets/SystemStats-DiLdS6K6.js","assets/SystemStats-B8-MXEai.css","assets/Configuration-CkZchTBn.js","assets/ConfirmDialog.vue_vue_type_script_setup_true_lang-CVxh_fqf.js","assets/Configuration-DCyoN75P.css","assets/CADCalibration-Dc9AglAf.js","assets/CADCalibration-DnmufMQ0.css","assets/RoomServers-V3porqGE.js","assets/MessageDialog.vue_vue_type_script_setup_true_lang-DAIhF3Fs.js","assets/Companions-X5GdqESj.js","assets/Terminal-BAoTtMQy.js","assets/Terminal-NOfYg9Od.css"])))=>i.map(i=>d[i]); +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))r(s);new MutationObserver(s=>{for(const o of s)if(o.type==="childList")for(const i of o.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&r(i)}).observe(document,{childList:!0,subtree:!0});function n(s){const o={};return s.integrity&&(o.integrity=s.integrity),s.referrerPolicy&&(o.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?o.credentials="include":s.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function r(s){if(s.ep)return;s.ep=!0;const o=n(s);fetch(s.href,o)}})();/** +* @vue/shared v3.5.18 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**//*! #__NO_SIDE_EFFECTS__ */function so(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const Se={},Cn=[],kt=()=>{},Pc=()=>!1,Zr=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),oo=e=>e.startsWith("onUpdate:"),$e=Object.assign,io=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},Lc=Object.prototype.hasOwnProperty,ke=(e,t)=>Lc.call(e,t),le=Array.isArray,_n=e=>ir(e)==="[object Map]",Gr=e=>ir(e)==="[object Set]",Bo=e=>ir(e)==="[object Date]",fe=e=>typeof e=="function",Pe=e=>typeof e=="string",St=e=>typeof e=="symbol",Ae=e=>e!==null&&typeof e=="object",da=e=>(Ae(e)||fe(e))&&fe(e.then)&&fe(e.catch),fa=Object.prototype.toString,ir=e=>fa.call(e),Nc=e=>ir(e).slice(8,-1),pa=e=>ir(e)==="[object Object]",ao=e=>Pe(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,jn=so(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),zr=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},Ic=/-(\w)/g,ut=zr(e=>e.replace(Ic,(t,n)=>n?n.toUpperCase():"")),Dc=/\B([A-Z])/g,Qt=zr(e=>e.replace(Dc,"-$1").toLowerCase()),Jr=zr(e=>e.charAt(0).toUpperCase()+e.slice(1)),ps=zr(e=>e?`on${Jr(e)}`:""),Gt=(e,t)=>!Object.is(e,t),Er=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:r,value:n})},Nr=e=>{const t=parseFloat(e);return isNaN(t)?e:t},$c=e=>{const t=Pe(e)?Number(e):NaN;return isNaN(t)?e:t};let Ho;const Yr=()=>Ho||(Ho=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function On(e){if(le(e)){const t={};for(let n=0;n{if(n){const r=n.split(Vc);r.length>1&&(t[r[0].trim()]=r[1].trim())}}),t}function ce(e){let t="";if(Pe(e))t=e;else if(le(e))for(let n=0;nkn(n,t))}const ma=e=>!!(e&&e.__v_isRef===!0),W=e=>Pe(e)?e:e==null?"":le(e)||Ae(e)&&(e.toString===fa||!fe(e.toString))?ma(e)?W(e.value):JSON.stringify(e,ga,2):String(e),ga=(e,t)=>ma(t)?ga(e,t.value):_n(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[r,s],o)=>(n[hs(r,o)+" =>"]=s,n),{})}:Gr(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>hs(n))}:St(t)?hs(t):Ae(t)&&!le(t)&&!pa(t)?String(t):t,hs=(e,t="")=>{var n;return St(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/** +* @vue/reactivity v3.5.18 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let He;class ya{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.parent=He,!t&&He&&(this.index=(He.scopes||(He.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t0&&--this._on===0&&(He=this.prevScope,this.prevScope=void 0)}stop(t){if(this._active){this._active=!1;let n,r;for(n=0,r=this.effects.length;n0)return;if(qn){let t=qn;for(qn=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;Un;){let t=Un;for(Un=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(r){e||(e=r)}t=n}}if(e)throw e}function wa(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function ka(e){let t,n=e.depsTail,r=n;for(;r;){const s=r.prevDep;r.version===-1?(r===n&&(n=s),uo(r),Zc(r)):t=r,r.dep.activeLink=r.prevActiveLink,r.prevActiveLink=void 0,r=s}e.deps=t,e.depsTail=n}function Ds(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(Ea(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function Ea(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===Qn)||(e.globalVersion=Qn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Ds(e))))return;e.flags|=2;const t=e.dep,n=Te,r=dt;Te=e,dt=!0;try{wa(e);const s=e.fn(e._value);(t.version===0||Gt(s,e._value))&&(e.flags|=128,e._value=s,t.version++)}catch(s){throw t.version++,s}finally{Te=n,dt=r,ka(e),e.flags&=-3}}function uo(e,t=!1){const{dep:n,prevSub:r,nextSub:s}=e;if(r&&(r.nextSub=s,e.prevSub=void 0),s&&(s.prevSub=r,e.nextSub=void 0),n.subs===e&&(n.subs=r,!r&&n.computed)){n.computed.flags&=-5;for(let o=n.computed.deps;o;o=o.nextDep)uo(o,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function Zc(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let dt=!0;const Sa=[];function It(){Sa.push(dt),dt=!1}function Dt(){const e=Sa.pop();dt=e===void 0?!0:e}function jo(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=Te;Te=void 0;try{t()}finally{Te=n}}}let Qn=0;class Gc{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class fo{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!Te||!dt||Te===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==Te)n=this.activeLink=new Gc(Te,this),Te.deps?(n.prevDep=Te.depsTail,Te.depsTail.nextDep=n,Te.depsTail=n):Te.deps=Te.depsTail=n,Aa(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const r=n.nextDep;r.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=r),n.prevDep=Te.depsTail,n.nextDep=void 0,Te.depsTail.nextDep=n,Te.depsTail=n,Te.deps===n&&(Te.deps=r)}return n}trigger(t){this.version++,Qn++,this.notify(t)}notify(t){lo();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{co()}}}function Aa(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let r=t.deps;r;r=r.nextDep)Aa(r)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const Ir=new WeakMap,un=Symbol(""),$s=Symbol(""),Xn=Symbol("");function je(e,t,n){if(dt&&Te){let r=Ir.get(e);r||Ir.set(e,r=new Map);let s=r.get(n);s||(r.set(n,s=new fo),s.map=r,s.key=n),s.track()}}function Pt(e,t,n,r,s,o){const i=Ir.get(e);if(!i){Qn++;return}const a=l=>{l&&l.trigger()};if(lo(),t==="clear")i.forEach(a);else{const l=le(e),u=l&&ao(n);if(l&&n==="length"){const c=Number(r);i.forEach((d,f)=>{(f==="length"||f===Xn||!St(f)&&f>=c)&&a(d)})}else switch((n!==void 0||i.has(void 0))&&a(i.get(n)),u&&a(i.get(Xn)),t){case"add":l?u&&a(i.get("length")):(a(i.get(un)),_n(e)&&a(i.get($s)));break;case"delete":l||(a(i.get(un)),_n(e)&&a(i.get($s)));break;case"set":_n(e)&&a(i.get(un));break}}co()}function zc(e,t){const n=Ir.get(e);return n&&n.get(t)}function gn(e){const t=_e(e);return t===e?t:(je(t,"iterate",Xn),lt(e)?t:t.map(Ve))}function Qr(e){return je(e=_e(e),"iterate",Xn),e}const Jc={__proto__:null,[Symbol.iterator](){return gs(this,Symbol.iterator,Ve)},concat(...e){return gn(this).concat(...e.map(t=>le(t)?gn(t):t))},entries(){return gs(this,"entries",e=>(e[1]=Ve(e[1]),e))},every(e,t){return Rt(this,"every",e,t,void 0,arguments)},filter(e,t){return Rt(this,"filter",e,t,n=>n.map(Ve),arguments)},find(e,t){return Rt(this,"find",e,t,Ve,arguments)},findIndex(e,t){return Rt(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return Rt(this,"findLast",e,t,Ve,arguments)},findLastIndex(e,t){return Rt(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return Rt(this,"forEach",e,t,void 0,arguments)},includes(...e){return ys(this,"includes",e)},indexOf(...e){return ys(this,"indexOf",e)},join(e){return gn(this).join(e)},lastIndexOf(...e){return ys(this,"lastIndexOf",e)},map(e,t){return Rt(this,"map",e,t,void 0,arguments)},pop(){return Nn(this,"pop")},push(...e){return Nn(this,"push",e)},reduce(e,...t){return Uo(this,"reduce",e,t)},reduceRight(e,...t){return Uo(this,"reduceRight",e,t)},shift(){return Nn(this,"shift")},some(e,t){return Rt(this,"some",e,t,void 0,arguments)},splice(...e){return Nn(this,"splice",e)},toReversed(){return gn(this).toReversed()},toSorted(e){return gn(this).toSorted(e)},toSpliced(...e){return gn(this).toSpliced(...e)},unshift(...e){return Nn(this,"unshift",e)},values(){return gs(this,"values",Ve)}};function gs(e,t,n){const r=Qr(e),s=r[t]();return r!==e&&!lt(e)&&(s._next=s.next,s.next=()=>{const o=s._next();return o.value&&(o.value=n(o.value)),o}),s}const Yc=Array.prototype;function Rt(e,t,n,r,s,o){const i=Qr(e),a=i!==e&&!lt(e),l=i[t];if(l!==Yc[t]){const d=l.apply(e,o);return a?Ve(d):d}let u=n;i!==e&&(a?u=function(d,f){return n.call(this,Ve(d),f,e)}:n.length>2&&(u=function(d,f){return n.call(this,d,f,e)}));const c=l.call(i,u,r);return a&&s?s(c):c}function Uo(e,t,n,r){const s=Qr(e);let o=n;return s!==e&&(lt(e)?n.length>3&&(o=function(i,a,l){return n.call(this,i,a,l,e)}):o=function(i,a,l){return n.call(this,i,Ve(a),l,e)}),s[t](o,...r)}function ys(e,t,n){const r=_e(e);je(r,"iterate",Xn);const s=r[t](...n);return(s===-1||s===!1)&&mo(n[0])?(n[0]=_e(n[0]),r[t](...n)):s}function Nn(e,t,n=[]){It(),lo();const r=_e(e)[t].apply(e,n);return co(),Dt(),r}const Qc=so("__proto__,__v_isRef,__isVue"),Ra=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(St));function Xc(e){St(e)||(e=String(e));const t=_e(this);return je(t,"has",e),t.hasOwnProperty(e)}class Ta{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,r){if(n==="__v_skip")return t.__v_skip;const s=this._isReadonly,o=this._isShallow;if(n==="__v_isReactive")return!s;if(n==="__v_isReadonly")return s;if(n==="__v_isShallow")return o;if(n==="__v_raw")return r===(s?o?cu:La:o?Pa:Ma).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(r)?t:void 0;const i=le(t);if(!s){let l;if(i&&(l=Jc[n]))return l;if(n==="hasOwnProperty")return Xc}const a=Reflect.get(t,n,Ie(t)?t:r);return(St(n)?Ra.has(n):Qc(n))||(s||je(t,"get",n),o)?a:Ie(a)?i&&ao(n)?a:a.value:Ae(a)?s?Ia(a):ar(a):a}}class Oa extends Ta{constructor(t=!1){super(!1,t)}set(t,n,r,s){let o=t[n];if(!this._isShallow){const l=Jt(o);if(!lt(r)&&!Jt(r)&&(o=_e(o),r=_e(r)),!le(t)&&Ie(o)&&!Ie(r))return l?!1:(o.value=r,!0)}const i=le(t)&&ao(n)?Number(n)e,vr=e=>Reflect.getPrototypeOf(e);function su(e,t,n){return function(...r){const s=this.__v_raw,o=_e(s),i=_n(o),a=e==="entries"||e===Symbol.iterator&&i,l=e==="keys"&&i,u=s[e](...r),c=n?Fs:t?Dr:Ve;return!t&&je(o,"iterate",l?$s:un),{next(){const{value:d,done:f}=u.next();return f?{value:d,done:f}:{value:a?[c(d[0]),c(d[1])]:c(d),done:f}},[Symbol.iterator](){return this}}}}function br(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function ou(e,t){const n={get(s){const o=this.__v_raw,i=_e(o),a=_e(s);e||(Gt(s,a)&&je(i,"get",s),je(i,"get",a));const{has:l}=vr(i),u=t?Fs:e?Dr:Ve;if(l.call(i,s))return u(o.get(s));if(l.call(i,a))return u(o.get(a));o!==i&&o.get(s)},get size(){const s=this.__v_raw;return!e&&je(_e(s),"iterate",un),Reflect.get(s,"size",s)},has(s){const o=this.__v_raw,i=_e(o),a=_e(s);return e||(Gt(s,a)&&je(i,"has",s),je(i,"has",a)),s===a?o.has(s):o.has(s)||o.has(a)},forEach(s,o){const i=this,a=i.__v_raw,l=_e(a),u=t?Fs:e?Dr:Ve;return!e&&je(l,"iterate",un),a.forEach((c,d)=>s.call(o,u(c),u(d),i))}};return $e(n,e?{add:br("add"),set:br("set"),delete:br("delete"),clear:br("clear")}:{add(s){!t&&!lt(s)&&!Jt(s)&&(s=_e(s));const o=_e(this);return vr(o).has.call(o,s)||(o.add(s),Pt(o,"add",s,s)),this},set(s,o){!t&&!lt(o)&&!Jt(o)&&(o=_e(o));const i=_e(this),{has:a,get:l}=vr(i);let u=a.call(i,s);u||(s=_e(s),u=a.call(i,s));const c=l.call(i,s);return i.set(s,o),u?Gt(o,c)&&Pt(i,"set",s,o):Pt(i,"add",s,o),this},delete(s){const o=_e(this),{has:i,get:a}=vr(o);let l=i.call(o,s);l||(s=_e(s),l=i.call(o,s)),a&&a.call(o,s);const u=o.delete(s);return l&&Pt(o,"delete",s,void 0),u},clear(){const s=_e(this),o=s.size!==0,i=s.clear();return o&&Pt(s,"clear",void 0,void 0),i}}),["keys","values","entries",Symbol.iterator].forEach(s=>{n[s]=su(s,e,t)}),n}function po(e,t){const n=ou(e,t);return(r,s,o)=>s==="__v_isReactive"?!e:s==="__v_isReadonly"?e:s==="__v_raw"?r:Reflect.get(ke(n,s)&&s in r?n:r,s,o)}const iu={get:po(!1,!1)},au={get:po(!1,!0)},lu={get:po(!0,!1)};const Ma=new WeakMap,Pa=new WeakMap,La=new WeakMap,cu=new WeakMap;function uu(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function du(e){return e.__v_skip||!Object.isExtensible(e)?0:uu(Nc(e))}function ar(e){return Jt(e)?e:ho(e,!1,tu,iu,Ma)}function Na(e){return ho(e,!1,ru,au,Pa)}function Ia(e){return ho(e,!0,nu,lu,La)}function ho(e,t,n,r,s){if(!Ae(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;const o=du(e);if(o===0)return e;const i=s.get(e);if(i)return i;const a=new Proxy(e,o===2?r:n);return s.set(e,a),a}function zt(e){return Jt(e)?zt(e.__v_raw):!!(e&&e.__v_isReactive)}function Jt(e){return!!(e&&e.__v_isReadonly)}function lt(e){return!!(e&&e.__v_isShallow)}function mo(e){return e?!!e.__v_raw:!1}function _e(e){const t=e&&e.__v_raw;return t?_e(t):e}function go(e){return!ke(e,"__v_skip")&&Object.isExtensible(e)&&Is(e,"__v_skip",!0),e}const Ve=e=>Ae(e)?ar(e):e,Dr=e=>Ae(e)?Ia(e):e;function Ie(e){return e?e.__v_isRef===!0:!1}function Z(e){return Da(e,!1)}function fu(e){return Da(e,!0)}function Da(e,t){return Ie(e)?e:new pu(e,t)}class pu{constructor(t,n){this.dep=new fo,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:_e(t),this._value=n?t:Ve(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,r=this.__v_isShallow||lt(t)||Jt(t);t=r?t:_e(t),Gt(t,n)&&(this._rawValue=t,this._value=r?t:Ve(t),this.dep.trigger())}}function de(e){return Ie(e)?e.value:e}const hu={get:(e,t,n)=>t==="__v_raw"?e:de(Reflect.get(e,t,n)),set:(e,t,n,r)=>{const s=e[t];return Ie(s)&&!Ie(n)?(s.value=n,!0):Reflect.set(e,t,n,r)}};function $a(e){return zt(e)?e:new Proxy(e,hu)}function mu(e){const t=le(e)?new Array(e.length):{};for(const n in e)t[n]=yu(e,n);return t}class gu{constructor(t,n,r){this._object=t,this._key=n,this._defaultValue=r,this.__v_isRef=!0,this._value=void 0}get value(){const t=this._object[this._key];return this._value=t===void 0?this._defaultValue:t}set value(t){this._object[this._key]=t}get dep(){return zc(_e(this._object),this._key)}}function yu(e,t,n){const r=e[t];return Ie(r)?r:new gu(e,t,n)}class vu{constructor(t,n,r){this.fn=t,this.setter=n,this._value=void 0,this.dep=new fo(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=Qn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=r}notify(){if(this.flags|=16,!(this.flags&8)&&Te!==this)return xa(this,!0),!0}get value(){const t=this.dep.track();return Ea(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function bu(e,t,n=!1){let r,s;return fe(e)?r=e:(r=e.get,s=e.set),new vu(r,s,n)}const Cr={},$r=new WeakMap;let sn;function Cu(e,t=!1,n=sn){if(n){let r=$r.get(n);r||$r.set(n,r=[]),r.push(e)}}function _u(e,t,n=Se){const{immediate:r,deep:s,once:o,scheduler:i,augmentJob:a,call:l}=n,u=M=>s?M:lt(M)||s===!1||s===0?Lt(M,1):Lt(M);let c,d,f,y,h=!1,v=!1;if(Ie(e)?(d=()=>e.value,h=lt(e)):zt(e)?(d=()=>u(e),h=!0):le(e)?(v=!0,h=e.some(M=>zt(M)||lt(M)),d=()=>e.map(M=>{if(Ie(M))return M.value;if(zt(M))return u(M);if(fe(M))return l?l(M,2):M()})):fe(e)?t?d=l?()=>l(e,2):e:d=()=>{if(f){It();try{f()}finally{Dt()}}const M=sn;sn=c;try{return l?l(e,3,[y]):e(y)}finally{sn=M}}:d=kt,t&&s){const M=d,F=s===!0?1/0:s;d=()=>Lt(M(),F)}const w=ba(),S=()=>{c.stop(),w&&w.active&&io(w.effects,c)};if(o&&t){const M=t;t=(...F)=>{M(...F),S()}}let L=v?new Array(e.length).fill(Cr):Cr;const R=M=>{if(!(!(c.flags&1)||!c.dirty&&!M))if(t){const F=c.run();if(s||h||(v?F.some((Y,q)=>Gt(Y,L[q])):Gt(F,L))){f&&f();const Y=sn;sn=c;try{const q=[F,L===Cr?void 0:v&&L[0]===Cr?[]:L,y];L=F,l?l(t,3,q):t(...q)}finally{sn=Y}}}else c.run()};return a&&a(R),c=new Ca(d),c.scheduler=i?()=>i(R,!1):R,y=M=>Cu(M,!1,c),f=c.onStop=()=>{const M=$r.get(c);if(M){if(l)l(M,4);else for(const F of M)F();$r.delete(c)}},t?r?R(!0):L=c.run():i?i(R.bind(null,!0),!0):c.run(),S.pause=c.pause.bind(c),S.resume=c.resume.bind(c),S.stop=S,S}function Lt(e,t=1/0,n){if(t<=0||!Ae(e)||e.__v_skip||(n=n||new Set,n.has(e)))return e;if(n.add(e),t--,Ie(e))Lt(e.value,t,n);else if(le(e))for(let r=0;r{Lt(r,t,n)});else if(pa(e)){for(const r in e)Lt(e[r],t,n);for(const r of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,r)&&Lt(e[r],t,n)}return e}/** +* @vue/runtime-core v3.5.18 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function lr(e,t,n,r){try{return r?e(...r):e()}catch(s){cr(s,t,n)}}function ft(e,t,n,r){if(fe(e)){const s=lr(e,t,n,r);return s&&da(s)&&s.catch(o=>{cr(o,t,n)}),s}if(le(e)){const s=[];for(let o=0;o>>1,s=ze[r],o=er(s);o=er(n)?ze.push(e):ze.splice(wu(t),0,e),e.flags|=1,Va()}}function Va(){Fr||(Fr=Fa.then(Ha))}function ku(e){le(e)?xn.push(...e):jt&&e.id===-1?jt.splice(vn+1,0,e):e.flags&1||(xn.push(e),e.flags|=1),Va()}function qo(e,t,n=_t+1){for(;ner(n)-er(r));if(xn.length=0,jt){jt.push(...t);return}for(jt=t,vn=0;vne.id==null?e.flags&2?-1:1/0:e.id;function Ha(e){try{for(_t=0;_t{r._d&&oi(-1);const o=Vr(t);let i;try{i=e(...s)}finally{Vr(o),r._d&&oi(1)}return i};return r._n=!0,r._c=!0,r._d=!0,r}function mh(e,t){if(st===null)return e;const n=ss(st),r=e.dirs||(e.dirs=[]);for(let s=0;se.__isTeleport,Kn=e=>e&&(e.disabled||e.disabled===""),Ko=e=>e&&(e.defer||e.defer===""),Wo=e=>typeof SVGElement<"u"&&e instanceof SVGElement,Zo=e=>typeof MathMLElement=="function"&&e instanceof MathMLElement,Vs=(e,t)=>{const n=e&&e.to;return Pe(n)?t?t(n):null:n},Ka={name:"Teleport",__isTeleport:!0,process(e,t,n,r,s,o,i,a,l,u){const{mc:c,pc:d,pbc:f,o:{insert:y,querySelector:h,createText:v,createComment:w}}=u,S=Kn(t.props);let{shapeFlag:L,children:R,dynamicChildren:M}=t;if(e==null){const F=t.el=v(""),Y=t.anchor=v("");y(F,n,r),y(Y,n,r);const q=(N,Q)=>{L&16&&(s&&s.isCE&&(s.ce._teleportTarget=N),c(R,N,Q,s,o,i,a,l))},X=()=>{const N=t.target=Vs(t.props,h),Q=Wa(N,t,v,y);N&&(i!=="svg"&&Wo(N)?i="svg":i!=="mathml"&&Zo(N)&&(i="mathml"),S||(q(N,Q),Sr(t,!1)))};S&&(q(n,Y),Sr(t,!0)),Ko(t.props)?(t.el.__isMounted=!1,Ze(()=>{X(),delete t.el.__isMounted},o)):X()}else{if(Ko(t.props)&&e.el.__isMounted===!1){Ze(()=>{Ka.process(e,t,n,r,s,o,i,a,l,u)},o);return}t.el=e.el,t.targetStart=e.targetStart;const F=t.anchor=e.anchor,Y=t.target=e.target,q=t.targetAnchor=e.targetAnchor,X=Kn(e.props),N=X?n:Y,Q=X?F:q;if(i==="svg"||Wo(Y)?i="svg":(i==="mathml"||Zo(Y))&&(i="mathml"),M?(f(e.dynamicChildren,M,N,s,o,i,a),_o(e,t,!0)):l||d(e,t,N,Q,s,o,i,a,!1),S)X?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):_r(t,n,F,u,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const j=t.target=Vs(t.props,h);j&&_r(t,j,null,u,0)}else X&&_r(t,Y,q,u,1);Sr(t,S)}},remove(e,t,n,{um:r,o:{remove:s}},o){const{shapeFlag:i,children:a,anchor:l,targetStart:u,targetAnchor:c,target:d,props:f}=e;if(d&&(s(u),s(c)),o&&s(l),i&16){const y=o||!Kn(f);for(let h=0;h{e.isMounted=!0}),ts(()=>{e.isUnmounting=!0}),e}const it=[Function,Array],Za={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:it,onEnter:it,onAfterEnter:it,onEnterCancelled:it,onBeforeLeave:it,onLeave:it,onAfterLeave:it,onLeaveCancelled:it,onBeforeAppear:it,onAppear:it,onAfterAppear:it,onAppearCancelled:it},Ga=e=>{const t=e.subTree;return t.component?Ga(t.component):t},Tu={name:"BaseTransition",props:Za,setup(e,{slots:t}){const n=wo(),r=Ru();return()=>{const s=t.default&&Ya(t.default(),!0);if(!s||!s.length)return;const o=za(s),i=_e(e),{mode:a}=i;if(r.isLeaving)return vs(o);const l=Go(o);if(!l)return vs(o);let u=Bs(l,i,r,n,d=>u=d);l.type!==Je&&tr(l,u);let c=n.subTree&&Go(n.subTree);if(c&&c.type!==Je&&!on(l,c)&&Ga(n).type!==Je){let d=Bs(c,i,r,n);if(tr(c,d),a==="out-in"&&l.type!==Je)return r.isLeaving=!0,d.afterLeave=()=>{r.isLeaving=!1,n.job.flags&8||n.update(),delete d.afterLeave,c=void 0},vs(o);a==="in-out"&&l.type!==Je?d.delayLeave=(f,y,h)=>{const v=Ja(r,c);v[String(c.key)]=c,f[Ut]=()=>{y(),f[Ut]=void 0,delete u.delayedLeave,c=void 0},u.delayedLeave=()=>{h(),delete u.delayedLeave,c=void 0}}:c=void 0}else c&&(c=void 0);return o}}};function za(e){let t=e[0];if(e.length>1){for(const n of e)if(n.type!==Je){t=n;break}}return t}const Ou=Tu;function Ja(e,t){const{leavingVNodes:n}=e;let r=n.get(t.type);return r||(r=Object.create(null),n.set(t.type,r)),r}function Bs(e,t,n,r,s){const{appear:o,mode:i,persisted:a=!1,onBeforeEnter:l,onEnter:u,onAfterEnter:c,onEnterCancelled:d,onBeforeLeave:f,onLeave:y,onAfterLeave:h,onLeaveCancelled:v,onBeforeAppear:w,onAppear:S,onAfterAppear:L,onAppearCancelled:R}=t,M=String(e.key),F=Ja(n,e),Y=(N,Q)=>{N&&ft(N,r,9,Q)},q=(N,Q)=>{const j=Q[1];Y(N,Q),le(N)?N.every(_=>_.length<=1)&&j():N.length<=1&&j()},X={mode:i,persisted:a,beforeEnter(N){let Q=l;if(!n.isMounted)if(o)Q=w||l;else return;N[Ut]&&N[Ut](!0);const j=F[M];j&&on(e,j)&&j.el[Ut]&&j.el[Ut](),Y(Q,[N])},enter(N){let Q=u,j=c,_=d;if(!n.isMounted)if(o)Q=S||u,j=L||c,_=R||d;else return;let K=!1;const I=N[xr]=ne=>{K||(K=!0,ne?Y(_,[N]):Y(j,[N]),X.delayedLeave&&X.delayedLeave(),N[xr]=void 0)};Q?q(Q,[N,I]):I()},leave(N,Q){const j=String(e.key);if(N[xr]&&N[xr](!0),n.isUnmounting)return Q();Y(f,[N]);let _=!1;const K=N[Ut]=I=>{_||(_=!0,Q(),I?Y(v,[N]):Y(h,[N]),N[Ut]=void 0,F[j]===e&&delete F[j])};F[j]=e,y?q(y,[N,K]):K()},clone(N){const Q=Bs(N,t,n,r,s);return s&&s(Q),Q}};return X}function vs(e){if(ur(e))return e=Yt(e),e.children=null,e}function Go(e){if(!ur(e))return qa(e.type)&&e.children?za(e.children):e;if(e.component)return e.component.subTree;const{shapeFlag:t,children:n}=e;if(n){if(t&16)return n[0];if(t&32&&fe(n.default))return n.default()}}function tr(e,t){e.shapeFlag&6&&e.component?(e.transition=t,tr(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Ya(e,t=!1,n){let r=[],s=0;for(let o=0;o1)for(let o=0;oWn(h,t&&(le(t)?t[v]:t),n,r,s));return}if(Zn(r)&&!s){r.shapeFlag&512&&r.type.__asyncResolved&&r.component.subTree.component&&Wn(e,t,n,r.component.subTree);return}const o=r.shapeFlag&4?ss(r.component):r.el,i=s?null:o,{i:a,r:l}=e,u=t&&t.r,c=a.refs===Se?a.refs={}:a.refs,d=a.setupState,f=_e(d),y=d===Se?()=>!1:h=>ke(f,h);if(u!=null&&u!==l&&(Pe(u)?(c[u]=null,y(u)&&(d[u]=null)):Ie(u)&&(u.value=null)),fe(l))lr(l,a,12,[i,c]);else{const h=Pe(l),v=Ie(l);if(h||v){const w=()=>{if(e.f){const S=h?y(l)?d[l]:c[l]:l.value;s?le(S)&&io(S,o):le(S)?S.includes(o)||S.push(o):h?(c[l]=[o],y(l)&&(d[l]=c[l])):(l.value=[o],e.k&&(c[e.k]=l.value))}else h?(c[l]=i,y(l)&&(d[l]=i)):v&&(l.value=i,e.k&&(c[e.k]=i))};i?(w.id=-1,Ze(w,n)):w()}}}const zo=e=>e.nodeType===8;Yr().requestIdleCallback;Yr().cancelIdleCallback;function Mu(e,t){if(zo(e)&&e.data==="["){let n=1,r=e.nextSibling;for(;r;){if(r.nodeType===1){if(t(r)===!1)break}else if(zo(r))if(r.data==="]"){if(--n===0)break}else r.data==="["&&n++;r=r.nextSibling}}else t(e)}const Zn=e=>!!e.type.__asyncLoader;/*! #__NO_SIDE_EFFECTS__ */function Pu(e){fe(e)&&(e={loader:e});const{loader:t,loadingComponent:n,errorComponent:r,delay:s=200,hydrate:o,timeout:i,suspensible:a=!0,onError:l}=e;let u=null,c,d=0;const f=()=>(d++,u=null,y()),y=()=>{let h;return u||(h=u=t().catch(v=>{if(v=v instanceof Error?v:new Error(String(v)),l)return new Promise((w,S)=>{l(v,()=>w(f()),()=>S(v),d+1)});throw v}).then(v=>h!==u&&u?u:(v&&(v.__esModule||v[Symbol.toStringTag]==="Module")&&(v=v.default),c=v,v)))};return ht({name:"AsyncComponentWrapper",__asyncLoader:y,__asyncHydrate(h,v,w){let S=!1;(v.bu||(v.bu=[])).push(()=>S=!0);const L=()=>{S||w()},R=o?()=>{const M=o(L,F=>Mu(h,F));M&&(v.bum||(v.bum=[])).push(M)}:L;c?R():y().then(()=>!v.isUnmounted&&R())},get __asyncResolved(){return c},setup(){const h=Be;if(vo(h),c)return()=>bs(c,h);const v=R=>{u=null,cr(R,h,13,!r)};if(a&&h.suspense||En)return y().then(R=>()=>bs(R,h)).catch(R=>(v(R),()=>r?xe(r,{error:R}):null));const w=Z(!1),S=Z(),L=Z(!!s);return s&&setTimeout(()=>{L.value=!1},s),i!=null&&setTimeout(()=>{if(!w.value&&!S.value){const R=new Error(`Async component timed out after ${i}ms.`);v(R),S.value=R}},i),y().then(()=>{w.value=!0,h.parent&&ur(h.parent.vnode)&&h.parent.update()}).catch(R=>{v(R),S.value=R}),()=>{if(w.value&&c)return bs(c,h);if(S.value&&r)return xe(r,{error:S.value});if(n&&!L.value)return xe(n)}}})}function bs(e,t){const{ref:n,props:r,children:s,ce:o}=t.vnode,i=xe(e,r,s);return i.ref=n,i.ce=o,delete t.vnode.ce,i}const ur=e=>e.type.__isKeepAlive;function Lu(e,t){Qa(e,"a",t)}function Nu(e,t){Qa(e,"da",t)}function Qa(e,t,n=Be){const r=e.__wdc||(e.__wdc=()=>{let s=n;for(;s;){if(s.isDeactivated)return;s=s.parent}return e()});if(es(t,r,n),n){let s=n.parent;for(;s&&s.parent;)ur(s.parent.vnode)&&Iu(r,t,n,s),s=s.parent}}function Iu(e,t,n,r){const s=es(t,e,r,!0);dr(()=>{io(r[t],s)},n)}function es(e,t,n=Be,r=!1){if(n){const s=n[e]||(n[e]=[]),o=t.__weh||(t.__weh=(...i)=>{It();const a=fr(n),l=ft(t,n,e,i);return a(),Dt(),l});return r?s.unshift(o):s.push(o),o}}const Ft=e=>(t,n=Be)=>{(!En||e==="sp")&&es(e,(...r)=>t(...r),n)},Du=Ft("bm"),hn=Ft("m"),$u=Ft("bu"),Fu=Ft("u"),ts=Ft("bum"),dr=Ft("um"),Vu=Ft("sp"),Bu=Ft("rtg"),Hu=Ft("rtc");function ju(e,t=Be){es("ec",e,t)}const Xa="components";function el(e,t){return nl(Xa,e,!0,t)||e}const tl=Symbol.for("v-ndc");function Zt(e){return Pe(e)?nl(Xa,e,!1)||e:e||tl}function nl(e,t,n=!0,r=!1){const s=st||Be;if(s){const o=s.type;{const a=T1(o,!1);if(a&&(a===t||a===ut(t)||a===Jr(ut(t))))return o}const i=Jo(s[e]||o[e],t)||Jo(s.appContext[e],t);return!i&&r?o:i}}function Jo(e,t){return e&&(e[t]||e[ut(t)]||e[Jr(ut(t))])}function at(e,t,n,r){let s;const o=n,i=le(e);if(i||Pe(e)){const a=i&&zt(e);let l=!1,u=!1;a&&(l=!lt(e),u=Jt(e),e=Qr(e)),s=new Array(e.length);for(let c=0,d=e.length;ct(a,l,void 0,o));else{const a=Object.keys(e);s=new Array(a.length);for(let l=0,u=a.length;le?_l(e)?ss(e):Hs(e.parent):null,Gn=$e(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Hs(e.parent),$root:e=>Hs(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>sl(e),$forceUpdate:e=>e.f||(e.f=()=>{yo(e.update)}),$nextTick:e=>e.n||(e.n=Xr.bind(e.proxy)),$watch:e=>u1.bind(e)}),Cs=(e,t)=>e!==Se&&!e.__isScriptSetup&&ke(e,t),Uu={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:r,data:s,props:o,accessCache:i,type:a,appContext:l}=e;let u;if(t[0]!=="$"){const y=i[t];if(y!==void 0)switch(y){case 1:return r[t];case 2:return s[t];case 4:return n[t];case 3:return o[t]}else{if(Cs(r,t))return i[t]=1,r[t];if(s!==Se&&ke(s,t))return i[t]=2,s[t];if((u=e.propsOptions[0])&&ke(u,t))return i[t]=3,o[t];if(n!==Se&&ke(n,t))return i[t]=4,n[t];js&&(i[t]=0)}}const c=Gn[t];let d,f;if(c)return t==="$attrs"&&je(e.attrs,"get",""),c(e);if((d=a.__cssModules)&&(d=d[t]))return d;if(n!==Se&&ke(n,t))return i[t]=4,n[t];if(f=l.config.globalProperties,ke(f,t))return f[t]},set({_:e},t,n){const{data:r,setupState:s,ctx:o}=e;return Cs(s,t)?(s[t]=n,!0):r!==Se&&ke(r,t)?(r[t]=n,!0):ke(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(o[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:r,appContext:s,propsOptions:o}},i){let a;return!!n[i]||e!==Se&&ke(e,i)||Cs(t,i)||(a=o[0])&&ke(a,i)||ke(r,i)||ke(Gn,i)||ke(s.config.globalProperties,i)},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:ke(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Yo(e){return le(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let js=!0;function qu(e){const t=sl(e),n=e.proxy,r=e.ctx;js=!1,t.beforeCreate&&Qo(t.beforeCreate,e,"bc");const{data:s,computed:o,methods:i,watch:a,provide:l,inject:u,created:c,beforeMount:d,mounted:f,beforeUpdate:y,updated:h,activated:v,deactivated:w,beforeDestroy:S,beforeUnmount:L,destroyed:R,unmounted:M,render:F,renderTracked:Y,renderTriggered:q,errorCaptured:X,serverPrefetch:N,expose:Q,inheritAttrs:j,components:_,directives:K,filters:I}=t;if(u&&Ku(u,r,null),i)for(const ee in i){const te=i[ee];fe(te)&&(r[ee]=te.bind(n))}if(s){const ee=s.call(n,n);Ae(ee)&&(e.data=ar(ee))}if(js=!0,o)for(const ee in o){const te=o[ee],pe=fe(te)?te.bind(n,n):fe(te.get)?te.get.bind(n,n):kt,H=!fe(te)&&fe(te.set)?te.set.bind(n):kt,re=oe({get:pe,set:H});Object.defineProperty(r,ee,{enumerable:!0,configurable:!0,get:()=>re.value,set:ve=>re.value=ve})}if(a)for(const ee in a)rl(a[ee],r,n,ee);if(l){const ee=fe(l)?l.call(n):l;Reflect.ownKeys(ee).forEach(te=>{Ar(te,ee[te])})}c&&Qo(c,e,"c");function se(ee,te){le(te)?te.forEach(pe=>ee(pe.bind(n))):te&&ee(te.bind(n))}if(se(Du,d),se(hn,f),se($u,y),se(Fu,h),se(Lu,v),se(Nu,w),se(ju,X),se(Hu,Y),se(Bu,q),se(ts,L),se(dr,M),se(Vu,N),le(Q))if(Q.length){const ee=e.exposed||(e.exposed={});Q.forEach(te=>{Object.defineProperty(ee,te,{get:()=>n[te],set:pe=>n[te]=pe,enumerable:!0})})}else e.exposed||(e.exposed={});F&&e.render===kt&&(e.render=F),j!=null&&(e.inheritAttrs=j),_&&(e.components=_),K&&(e.directives=K),N&&vo(e)}function Ku(e,t,n=kt){le(e)&&(e=Us(e));for(const r in e){const s=e[r];let o;Ae(s)?"default"in s?o=ct(s.from||r,s.default,!0):o=ct(s.from||r):o=ct(s),Ie(o)?Object.defineProperty(t,r,{enumerable:!0,configurable:!0,get:()=>o.value,set:i=>o.value=i}):t[r]=o}}function Qo(e,t,n){ft(le(e)?e.map(r=>r.bind(t.proxy)):e.bind(t.proxy),t,n)}function rl(e,t,n,r){let s=r.includes(".")?gl(n,r):()=>n[r];if(Pe(e)){const o=t[e];fe(o)&&Et(s,o)}else if(fe(e))Et(s,e.bind(n));else if(Ae(e))if(le(e))e.forEach(o=>rl(o,t,n,r));else{const o=fe(e.handler)?e.handler.bind(n):t[e.handler];fe(o)&&Et(s,o,e)}}function sl(e){const t=e.type,{mixins:n,extends:r}=t,{mixins:s,optionsCache:o,config:{optionMergeStrategies:i}}=e.appContext,a=o.get(t);let l;return a?l=a:!s.length&&!n&&!r?l=t:(l={},s.length&&s.forEach(u=>Br(l,u,i,!0)),Br(l,t,i)),Ae(t)&&o.set(t,l),l}function Br(e,t,n,r=!1){const{mixins:s,extends:o}=t;o&&Br(e,o,n,!0),s&&s.forEach(i=>Br(e,i,n,!0));for(const i in t)if(!(r&&i==="expose")){const a=Wu[i]||n&&n[i];e[i]=a?a(e[i],t[i]):t[i]}return e}const Wu={data:Xo,props:ei,emits:ei,methods:Hn,computed:Hn,beforeCreate:Ke,created:Ke,beforeMount:Ke,mounted:Ke,beforeUpdate:Ke,updated:Ke,beforeDestroy:Ke,beforeUnmount:Ke,destroyed:Ke,unmounted:Ke,activated:Ke,deactivated:Ke,errorCaptured:Ke,serverPrefetch:Ke,components:Hn,directives:Hn,watch:Gu,provide:Xo,inject:Zu};function Xo(e,t){return t?e?function(){return $e(fe(e)?e.call(this,this):e,fe(t)?t.call(this,this):t)}:t:e}function Zu(e,t){return Hn(Us(e),Us(t))}function Us(e){if(le(e)){const t={};for(let n=0;n1)return n&&fe(t)?t.call(r&&r.proxy):t}}function Yu(){return!!(wo()||dn)}const il={},al=()=>Object.create(il),ll=e=>Object.getPrototypeOf(e)===il;function Qu(e,t,n,r=!1){const s={},o=al();e.propsDefaults=Object.create(null),cl(e,t,s,o);for(const i in e.propsOptions[0])i in s||(s[i]=void 0);n?e.props=r?s:Na(s):e.type.props?e.props=s:e.props=o,e.attrs=o}function Xu(e,t,n,r){const{props:s,attrs:o,vnode:{patchFlag:i}}=e,a=_e(s),[l]=e.propsOptions;let u=!1;if((r||i>0)&&!(i&16)){if(i&8){const c=e.vnode.dynamicProps;for(let d=0;d{l=!0;const[f,y]=ul(d,t,!0);$e(i,f),y&&a.push(...y)};!n&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}if(!o&&!l)return Ae(e)&&r.set(e,Cn),Cn;if(le(o))for(let c=0;ce==="_"||e==="__"||e==="_ctx"||e==="$stable",Co=e=>le(e)?e.map(xt):[xt(e)],t1=(e,t,n)=>{if(t._n)return t;const r=Eu((...s)=>Co(t(...s)),n);return r._c=!1,r},dl=(e,t,n)=>{const r=e._ctx;for(const s in e){if(bo(s))continue;const o=e[s];if(fe(o))t[s]=t1(s,o,r);else if(o!=null){const i=Co(o);t[s]=()=>i}}},fl=(e,t)=>{const n=Co(t);e.slots.default=()=>n},pl=(e,t,n)=>{for(const r in t)(n||!bo(r))&&(e[r]=t[r])},n1=(e,t,n)=>{const r=e.slots=al();if(e.vnode.shapeFlag&32){const s=t.__;s&&Is(r,"__",s,!0);const o=t._;o?(pl(r,t,n),n&&Is(r,"_",o,!0)):dl(t,r)}else t&&fl(e,t)},r1=(e,t,n)=>{const{vnode:r,slots:s}=e;let o=!0,i=Se;if(r.shapeFlag&32){const a=t._;a?n&&a===1?o=!1:pl(s,t,n):(o=!t.$stable,dl(t,s)),i=t}else t&&(fl(e,t),i={default:1});if(o)for(const a in s)!bo(a)&&i[a]==null&&delete s[a]},Ze=y1;function s1(e){return o1(e)}function o1(e,t){const n=Yr();n.__VUE__=!0;const{insert:r,remove:s,patchProp:o,createElement:i,createText:a,createComment:l,setText:u,setElementText:c,parentNode:d,nextSibling:f,setScopeId:y=kt,insertStaticContent:h}=e,v=(m,g,b,A=null,P=null,T=null,z=void 0,U=null,B=!!g.dynamicChildren)=>{if(m===g)return;m&&!on(m,g)&&(A=E(m),ve(m,P,T,!0),m=null),g.patchFlag===-2&&(B=!1,g.dynamicChildren=null);const{type:$,ref:ae,shapeFlag:J}=g;switch($){case rs:w(m,g,b,A);break;case Je:S(m,g,b,A);break;case Rr:m==null&&L(g,b,A,z);break;case Oe:_(m,g,b,A,P,T,z,U,B);break;default:J&1?F(m,g,b,A,P,T,z,U,B):J&6?K(m,g,b,A,P,T,z,U,B):(J&64||J&128)&&$.process(m,g,b,A,P,T,z,U,B,V)}ae!=null&&P?Wn(ae,m&&m.ref,T,g||m,!g):ae==null&&m&&m.ref!=null&&Wn(m.ref,null,T,m,!0)},w=(m,g,b,A)=>{if(m==null)r(g.el=a(g.children),b,A);else{const P=g.el=m.el;g.children!==m.children&&u(P,g.children)}},S=(m,g,b,A)=>{m==null?r(g.el=l(g.children||""),b,A):g.el=m.el},L=(m,g,b,A)=>{[m.el,m.anchor]=h(m.children,g,b,A,m.el,m.anchor)},R=({el:m,anchor:g},b,A)=>{let P;for(;m&&m!==g;)P=f(m),r(m,b,A),m=P;r(g,b,A)},M=({el:m,anchor:g})=>{let b;for(;m&&m!==g;)b=f(m),s(m),m=b;s(g)},F=(m,g,b,A,P,T,z,U,B)=>{g.type==="svg"?z="svg":g.type==="math"&&(z="mathml"),m==null?Y(g,b,A,P,T,z,U,B):N(m,g,P,T,z,U,B)},Y=(m,g,b,A,P,T,z,U)=>{let B,$;const{props:ae,shapeFlag:J,transition:ie,dirs:ue}=m;if(B=m.el=i(m.type,T,ae&&ae.is,ae),J&8?c(B,m.children):J&16&&X(m.children,B,null,A,P,_s(m,T),z,U),ue&&en(m,null,A,"created"),q(B,m,m.scopeId,z,A),ae){for(const Re in ae)Re!=="value"&&!jn(Re)&&o(B,Re,null,ae[Re],T,A);"value"in ae&&o(B,"value",null,ae.value,T),($=ae.onVnodeBeforeMount)&&bt($,A,m)}ue&&en(m,null,A,"beforeMount");const Ce=i1(P,ie);Ce&&ie.beforeEnter(B),r(B,g,b),(($=ae&&ae.onVnodeMounted)||Ce||ue)&&Ze(()=>{$&&bt($,A,m),Ce&&ie.enter(B),ue&&en(m,null,A,"mounted")},P)},q=(m,g,b,A,P)=>{if(b&&y(m,b),A)for(let T=0;T{for(let $=B;${const U=g.el=m.el;let{patchFlag:B,dynamicChildren:$,dirs:ae}=g;B|=m.patchFlag&16;const J=m.props||Se,ie=g.props||Se;let ue;if(b&&tn(b,!1),(ue=ie.onVnodeBeforeUpdate)&&bt(ue,b,g,m),ae&&en(g,m,b,"beforeUpdate"),b&&tn(b,!0),(J.innerHTML&&ie.innerHTML==null||J.textContent&&ie.textContent==null)&&c(U,""),$?Q(m.dynamicChildren,$,U,b,A,_s(g,P),T):z||te(m,g,U,null,b,A,_s(g,P),T,!1),B>0){if(B&16)j(U,J,ie,b,P);else if(B&2&&J.class!==ie.class&&o(U,"class",null,ie.class,P),B&4&&o(U,"style",J.style,ie.style,P),B&8){const Ce=g.dynamicProps;for(let Re=0;Re{ue&&bt(ue,b,g,m),ae&&en(g,m,b,"updated")},A)},Q=(m,g,b,A,P,T,z)=>{for(let U=0;U{if(g!==b){if(g!==Se)for(const T in g)!jn(T)&&!(T in b)&&o(m,T,g[T],null,P,A);for(const T in b){if(jn(T))continue;const z=b[T],U=g[T];z!==U&&T!=="value"&&o(m,T,U,z,P,A)}"value"in b&&o(m,"value",g.value,b.value,P)}},_=(m,g,b,A,P,T,z,U,B)=>{const $=g.el=m?m.el:a(""),ae=g.anchor=m?m.anchor:a("");let{patchFlag:J,dynamicChildren:ie,slotScopeIds:ue}=g;ue&&(U=U?U.concat(ue):ue),m==null?(r($,b,A),r(ae,b,A),X(g.children||[],b,ae,P,T,z,U,B)):J>0&&J&64&&ie&&m.dynamicChildren?(Q(m.dynamicChildren,ie,b,P,T,z,U),(g.key!=null||P&&g===P.subTree)&&_o(m,g,!0)):te(m,g,b,ae,P,T,z,U,B)},K=(m,g,b,A,P,T,z,U,B)=>{g.slotScopeIds=U,m==null?g.shapeFlag&512?P.ctx.activate(g,b,A,z,B):I(g,b,A,P,T,z,B):ne(m,g,B)},I=(m,g,b,A,P,T,z)=>{const U=m.component=k1(m,A,P);if(ur(m)&&(U.ctx.renderer=V),E1(U,!1,z),U.asyncDep){if(P&&P.registerDep(U,se,z),!m.el){const B=U.subTree=xe(Je);S(null,B,g,b),m.placeholder=B.el}}else se(U,m,g,b,P,T,z)},ne=(m,g,b)=>{const A=g.component=m.component;if(m1(m,g,b))if(A.asyncDep&&!A.asyncResolved){ee(A,g,b);return}else A.next=g,A.update();else g.el=m.el,A.vnode=g},se=(m,g,b,A,P,T,z)=>{const U=()=>{if(m.isMounted){let{next:J,bu:ie,u:ue,parent:Ce,vnode:Re}=m;{const yt=hl(m);if(yt){J&&(J.el=Re.el,ee(m,J,z)),yt.asyncDep.then(()=>{m.isUnmounted||U()});return}}let Ee=J,Qe;tn(m,!1),J?(J.el=Re.el,ee(m,J,z)):J=Re,ie&&Er(ie),(Qe=J.props&&J.props.onVnodeBeforeUpdate)&&bt(Qe,Ce,J,Re),tn(m,!0);const Xe=ri(m),gt=m.subTree;m.subTree=Xe,v(gt,Xe,d(gt.el),E(gt),m,P,T),J.el=Xe.el,Ee===null&&g1(m,Xe.el),ue&&Ze(ue,P),(Qe=J.props&&J.props.onVnodeUpdated)&&Ze(()=>bt(Qe,Ce,J,Re),P)}else{let J;const{el:ie,props:ue}=g,{bm:Ce,m:Re,parent:Ee,root:Qe,type:Xe}=m,gt=Zn(g);tn(m,!1),Ce&&Er(Ce),!gt&&(J=ue&&ue.onVnodeBeforeMount)&&bt(J,Ee,g),tn(m,!0);{Qe.ce&&Qe.ce._def.shadowRoot!==!1&&Qe.ce._injectChildStyle(Xe);const yt=m.subTree=ri(m);v(null,yt,b,A,m,P,T),g.el=yt.el}if(Re&&Ze(Re,P),!gt&&(J=ue&&ue.onVnodeMounted)){const yt=g;Ze(()=>bt(J,Ee,yt),P)}(g.shapeFlag&256||Ee&&Zn(Ee.vnode)&&Ee.vnode.shapeFlag&256)&&m.a&&Ze(m.a,P),m.isMounted=!0,g=b=A=null}};m.scope.on();const B=m.effect=new Ca(U);m.scope.off();const $=m.update=B.run.bind(B),ae=m.job=B.runIfDirty.bind(B);ae.i=m,ae.id=m.uid,B.scheduler=()=>yo(ae),tn(m,!0),$()},ee=(m,g,b)=>{g.component=m;const A=m.vnode.props;m.vnode=g,m.next=null,Xu(m,g.props,A,b),r1(m,g.children,b),It(),qo(m),Dt()},te=(m,g,b,A,P,T,z,U,B=!1)=>{const $=m&&m.children,ae=m?m.shapeFlag:0,J=g.children,{patchFlag:ie,shapeFlag:ue}=g;if(ie>0){if(ie&128){H($,J,b,A,P,T,z,U,B);return}else if(ie&256){pe($,J,b,A,P,T,z,U,B);return}}ue&8?(ae&16&&ge($,P,T),J!==$&&c(b,J)):ae&16?ue&16?H($,J,b,A,P,T,z,U,B):ge($,P,T,!0):(ae&8&&c(b,""),ue&16&&X(J,b,A,P,T,z,U,B))},pe=(m,g,b,A,P,T,z,U,B)=>{m=m||Cn,g=g||Cn;const $=m.length,ae=g.length,J=Math.min($,ae);let ie;for(ie=0;ieae?ge(m,P,T,!0,!1,J):X(g,b,A,P,T,z,U,B,J)},H=(m,g,b,A,P,T,z,U,B)=>{let $=0;const ae=g.length;let J=m.length-1,ie=ae-1;for(;$<=J&&$<=ie;){const ue=m[$],Ce=g[$]=B?qt(g[$]):xt(g[$]);if(on(ue,Ce))v(ue,Ce,b,null,P,T,z,U,B);else break;$++}for(;$<=J&&$<=ie;){const ue=m[J],Ce=g[ie]=B?qt(g[ie]):xt(g[ie]);if(on(ue,Ce))v(ue,Ce,b,null,P,T,z,U,B);else break;J--,ie--}if($>J){if($<=ie){const ue=ie+1,Ce=ueie)for(;$<=J;)ve(m[$],P,T,!0),$++;else{const ue=$,Ce=$,Re=new Map;for($=Ce;$<=ie;$++){const nt=g[$]=B?qt(g[$]):xt(g[$]);nt.key!=null&&Re.set(nt.key,$)}let Ee,Qe=0;const Xe=ie-Ce+1;let gt=!1,yt=0;const Ln=new Array(Xe);for($=0;$=Xe){ve(nt,P,T,!0);continue}let vt;if(nt.key!=null)vt=Re.get(nt.key);else for(Ee=Ce;Ee<=ie;Ee++)if(Ln[Ee-Ce]===0&&on(nt,g[Ee])){vt=Ee;break}vt===void 0?ve(nt,P,T,!0):(Ln[vt-Ce]=$+1,vt>=yt?yt=vt:gt=!0,v(nt,g[vt],b,null,P,T,z,U,B),Qe++)}const $o=gt?a1(Ln):Cn;for(Ee=$o.length-1,$=Xe-1;$>=0;$--){const nt=Ce+$,vt=g[nt],Fo=g[nt+1],Vo=nt+1{const{el:T,type:z,transition:U,children:B,shapeFlag:$}=m;if($&6){re(m.component.subTree,g,b,A);return}if($&128){m.suspense.move(g,b,A);return}if($&64){z.move(m,g,b,V);return}if(z===Oe){r(T,g,b);for(let J=0;JU.enter(T),P);else{const{leave:J,delayLeave:ie,afterLeave:ue}=U,Ce=()=>{m.ctx.isUnmounted?s(T):r(T,g,b)},Re=()=>{J(T,()=>{Ce(),ue&&ue()})};ie?ie(T,Ce,Re):Re()}else r(T,g,b)},ve=(m,g,b,A=!1,P=!1)=>{const{type:T,props:z,ref:U,children:B,dynamicChildren:$,shapeFlag:ae,patchFlag:J,dirs:ie,cacheIndex:ue}=m;if(J===-2&&(P=!1),U!=null&&(It(),Wn(U,null,b,m,!0),Dt()),ue!=null&&(g.renderCache[ue]=void 0),ae&256){g.ctx.deactivate(m);return}const Ce=ae&1&&ie,Re=!Zn(m);let Ee;if(Re&&(Ee=z&&z.onVnodeBeforeUnmount)&&bt(Ee,g,m),ae&6)G(m.component,b,A);else{if(ae&128){m.suspense.unmount(b,A);return}Ce&&en(m,null,g,"beforeUnmount"),ae&64?m.type.remove(m,g,b,V,A):$&&!$.hasOnce&&(T!==Oe||J>0&&J&64)?ge($,g,b,!1,!0):(T===Oe&&J&384||!P&&ae&16)&&ge(B,g,b),A&&Ye(m)}(Re&&(Ee=z&&z.onVnodeUnmounted)||Ce)&&Ze(()=>{Ee&&bt(Ee,g,m),Ce&&en(m,null,g,"unmounted")},b)},Ye=m=>{const{type:g,el:b,anchor:A,transition:P}=m;if(g===Oe){me(b,A);return}if(g===Rr){M(m);return}const T=()=>{s(b),P&&!P.persisted&&P.afterLeave&&P.afterLeave()};if(m.shapeFlag&1&&P&&!P.persisted){const{leave:z,delayLeave:U}=P,B=()=>z(b,T);U?U(m.el,T,B):B()}else T()},me=(m,g)=>{let b;for(;m!==g;)b=f(m),s(m),m=b;s(g)},G=(m,g,b)=>{const{bum:A,scope:P,job:T,subTree:z,um:U,m:B,a:$,parent:ae,slots:{__:J}}=m;ni(B),ni($),A&&Er(A),ae&&le(J)&&J.forEach(ie=>{ae.renderCache[ie]=void 0}),P.stop(),T&&(T.flags|=8,ve(z,m,g,b)),U&&Ze(U,g),Ze(()=>{m.isUnmounted=!0},g),g&&g.pendingBranch&&!g.isUnmounted&&m.asyncDep&&!m.asyncResolved&&m.suspenseId===g.pendingId&&(g.deps--,g.deps===0&&g.resolve())},ge=(m,g,b,A=!1,P=!1,T=0)=>{for(let z=T;z{if(m.shapeFlag&6)return E(m.component.subTree);if(m.shapeFlag&128)return m.suspense.next();const g=f(m.anchor||m.el),b=g&&g[Ua];return b?f(b):g};let k=!1;const C=(m,g,b)=>{m==null?g._vnode&&ve(g._vnode,null,null,!0):v(g._vnode||null,m,g,null,null,null,b),g._vnode=m,k||(k=!0,qo(),Ba(),k=!1)},V={p:v,um:ve,m:re,r:Ye,mt:I,mc:X,pc:te,pbc:Q,n:E,o:e};return{render:C,hydrate:void 0,createApp:Ju(C)}}function _s({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function tn({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function i1(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function _o(e,t,n=!1){const r=e.children,s=t.children;if(le(r)&&le(s))for(let o=0;o>1,e[n[a]]0&&(t[r]=n[o-1]),n[o]=r)}}for(o=n.length,i=n[o-1];o-- >0;)n[o]=i,i=t[i];return n}function hl(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:hl(t)}function ni(e){if(e)for(let t=0;tct(l1);function Et(e,t,n){return ml(e,t,n)}function ml(e,t,n=Se){const{immediate:r,deep:s,flush:o,once:i}=n,a=$e({},n),l=t&&r||!t&&o!=="post";let u;if(En){if(o==="sync"){const y=c1();u=y.__watcherHandles||(y.__watcherHandles=[])}else if(!l){const y=()=>{};return y.stop=kt,y.resume=kt,y.pause=kt,y}}const c=Be;a.call=(y,h,v)=>ft(y,c,h,v);let d=!1;o==="post"?a.scheduler=y=>{Ze(y,c&&c.suspense)}:o!=="sync"&&(d=!0,a.scheduler=(y,h)=>{h?y():yo(y)}),a.augmentJob=y=>{t&&(y.flags|=4),d&&(y.flags|=2,c&&(y.id=c.uid,y.i=c))};const f=_u(e,t,a);return En&&(u?u.push(f):l&&f()),f}function u1(e,t,n){const r=this.proxy,s=Pe(e)?e.includes(".")?gl(r,e):()=>r[e]:e.bind(r,r);let o;fe(t)?o=t:(o=t.handler,n=t);const i=fr(this),a=ml(s,o.bind(r),n);return i(),a}function gl(e,t){const n=t.split(".");return()=>{let r=e;for(let s=0;st==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${ut(t)}Modifiers`]||e[`${Qt(t)}Modifiers`];function f1(e,t,...n){if(e.isUnmounted)return;const r=e.vnode.props||Se;let s=n;const o=t.startsWith("update:"),i=o&&d1(r,t.slice(7));i&&(i.trim&&(s=n.map(c=>Pe(c)?c.trim():c)),i.number&&(s=n.map(Nr)));let a,l=r[a=ps(t)]||r[a=ps(ut(t))];!l&&o&&(l=r[a=ps(Qt(t))]),l&&ft(l,e,6,s);const u=r[a+"Once"];if(u){if(!e.emitted)e.emitted={};else if(e.emitted[a])return;e.emitted[a]=!0,ft(u,e,6,s)}}function yl(e,t,n=!1){const r=t.emitsCache,s=r.get(e);if(s!==void 0)return s;const o=e.emits;let i={},a=!1;if(!fe(e)){const l=u=>{const c=yl(u,t,!0);c&&(a=!0,$e(i,c))};!n&&t.mixins.length&&t.mixins.forEach(l),e.extends&&l(e.extends),e.mixins&&e.mixins.forEach(l)}return!o&&!a?(Ae(e)&&r.set(e,null),null):(le(o)?o.forEach(l=>i[l]=null):$e(i,o),Ae(e)&&r.set(e,i),i)}function ns(e,t){return!e||!Zr(t)?!1:(t=t.slice(2).replace(/Once$/,""),ke(e,t[0].toLowerCase()+t.slice(1))||ke(e,Qt(t))||ke(e,t))}function ri(e){const{type:t,vnode:n,proxy:r,withProxy:s,propsOptions:[o],slots:i,attrs:a,emit:l,render:u,renderCache:c,props:d,data:f,setupState:y,ctx:h,inheritAttrs:v}=e,w=Vr(e);let S,L;try{if(n.shapeFlag&4){const M=s||r,F=M;S=xt(u.call(F,M,c,d,y,f,h)),L=a}else{const M=t;S=xt(M.length>1?M(d,{attrs:a,slots:i,emit:l}):M(d,null)),L=t.props?a:p1(a)}}catch(M){zn.length=0,cr(M,e,1),S=xe(Je)}let R=S;if(L&&v!==!1){const M=Object.keys(L),{shapeFlag:F}=R;M.length&&F&7&&(o&&M.some(oo)&&(L=h1(L,o)),R=Yt(R,L,!1,!0))}return n.dirs&&(R=Yt(R,null,!1,!0),R.dirs=R.dirs?R.dirs.concat(n.dirs):n.dirs),n.transition&&tr(R,n.transition),S=R,Vr(w),S}const p1=e=>{let t;for(const n in e)(n==="class"||n==="style"||Zr(n))&&((t||(t={}))[n]=e[n]);return t},h1=(e,t)=>{const n={};for(const r in e)(!oo(r)||!(r.slice(9)in t))&&(n[r]=e[r]);return n};function m1(e,t,n){const{props:r,children:s,component:o}=e,{props:i,children:a,patchFlag:l}=t,u=o.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&l>=0){if(l&1024)return!0;if(l&16)return r?si(r,i,u):!!i;if(l&8){const c=t.dynamicProps;for(let d=0;de.__isSuspense;function y1(e,t){t&&t.pendingBranch?le(e)?t.effects.push(...e):t.effects.push(e):ku(e)}const Oe=Symbol.for("v-fgt"),rs=Symbol.for("v-txt"),Je=Symbol.for("v-cmt"),Rr=Symbol.for("v-stc"),zn=[];let ot=null;function O(e=!1){zn.push(ot=e?null:[])}function v1(){zn.pop(),ot=zn[zn.length-1]||null}let nr=1;function oi(e,t=!1){nr+=e,e<0&&ot&&t&&(ot.hasOnce=!0)}function bl(e){return e.dynamicChildren=nr>0?ot||Cn:null,v1(),nr>0&&ot&&ot.push(e),e}function D(e,t,n,r,s,o){return bl(p(e,t,n,r,s,o,!0))}function rt(e,t,n,r,s){return bl(xe(e,t,n,r,s,!0))}function Hr(e){return e?e.__v_isVNode===!0:!1}function on(e,t){return e.type===t.type&&e.key===t.key}const Cl=({key:e})=>e??null,Tr=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?Pe(e)||Ie(e)||fe(e)?{i:st,r:e,k:t,f:!!n}:e:null);function p(e,t=null,n=null,r=0,s=null,o=e===Oe?0:1,i=!1,a=!1){const l={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&Cl(t),ref:t&&Tr(t),scopeId:ja,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:o,patchFlag:r,dynamicProps:s,dynamicChildren:null,appContext:null,ctx:st};return a?(xo(l,n),o&128&&e.normalize(l)):n&&(l.shapeFlag|=Pe(n)?8:16),nr>0&&!i&&ot&&(l.patchFlag>0||o&6)&&l.patchFlag!==32&&ot.push(l),l}const xe=b1;function b1(e,t=null,n=null,r=0,s=null,o=!1){if((!e||e===tl)&&(e=Je),Hr(e)){const a=Yt(e,t,!0);return n&&xo(a,n),nr>0&&!o&&ot&&(a.shapeFlag&6?ot[ot.indexOf(e)]=a:ot.push(a)),a.patchFlag=-2,a}if(O1(e)&&(e=e.__vccOpts),t){t=C1(t);let{class:a,style:l}=t;a&&!Pe(a)&&(t.class=ce(a)),Ae(l)&&(mo(l)&&!le(l)&&(l=$e({},l)),t.style=On(l))}const i=Pe(e)?1:vl(e)?128:qa(e)?64:Ae(e)?4:fe(e)?2:0;return p(e,t,n,r,s,i,o,!0)}function C1(e){return e?mo(e)||ll(e)?$e({},e):e:null}function Yt(e,t,n=!1,r=!1){const{props:s,ref:o,patchFlag:i,children:a,transition:l}=e,u=t?_1(s||{},t):s,c={__v_isVNode:!0,__v_skip:!0,type:e.type,props:u,key:u&&Cl(u),ref:t&&t.ref?n&&o?le(o)?o.concat(Tr(t)):[o,Tr(t)]:Tr(t):o,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:a,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==Oe?i===-1?16:i|16:i,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:l,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&Yt(e.ssContent),ssFallback:e.ssFallback&&Yt(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return l&&r&&tr(c,l.clone(c)),c}function Ne(e=" ",t=0){return xe(rs,null,e,t)}function At(e,t){const n=xe(Rr,null,e);return n.staticCount=t,n}function be(e="",t=!1){return t?(O(),rt(Je,null,e)):xe(Je,null,e)}function xt(e){return e==null||typeof e=="boolean"?xe(Je):le(e)?xe(Oe,null,e.slice()):Hr(e)?qt(e):xe(rs,null,String(e))}function qt(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:Yt(e)}function xo(e,t){let n=0;const{shapeFlag:r}=e;if(t==null)t=null;else if(le(t))n=16;else if(typeof t=="object")if(r&65){const s=t.default;s&&(s._c&&(s._d=!1),xo(e,s()),s._c&&(s._d=!0));return}else{n=32;const s=t._;!s&&!ll(t)?t._ctx=st:s===3&&st&&(st.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else fe(t)?(t={default:t,_ctx:st},n=32):(t=String(t),r&64?(n=16,t=[Ne(t)]):n=8);e.children=t,e.shapeFlag|=n}function _1(...e){const t={};for(let n=0;nBe||st;let jr,Ks;{const e=Yr(),t=(n,r)=>{let s;return(s=e[n])||(s=e[n]=[]),s.push(r),o=>{s.length>1?s.forEach(i=>i(o)):s[0](o)}};jr=t("__VUE_INSTANCE_SETTERS__",n=>Be=n),Ks=t("__VUE_SSR_SETTERS__",n=>En=n)}const fr=e=>{const t=Be;return jr(e),e.scope.on(),()=>{e.scope.off(),jr(t)}},ii=()=>{Be&&Be.scope.off(),jr(null)};function _l(e){return e.vnode.shapeFlag&4}let En=!1;function E1(e,t=!1,n=!1){t&&Ks(t);const{props:r,children:s}=e.vnode,o=_l(e);Qu(e,r,o,t),n1(e,s,n||t);const i=o?S1(e,t):void 0;return t&&Ks(!1),i}function S1(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Uu);const{setup:r}=n;if(r){It();const s=e.setupContext=r.length>1?R1(e):null,o=fr(e),i=lr(r,e,0,[e.props,s]),a=da(i);if(Dt(),o(),(a||e.sp)&&!Zn(e)&&vo(e),a){if(i.then(ii,ii),t)return i.then(l=>{ai(e,l)}).catch(l=>{cr(l,e,0)});e.asyncDep=i}else ai(e,i)}else xl(e)}function ai(e,t,n){fe(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:Ae(t)&&(e.setupState=$a(t)),xl(e)}function xl(e,t,n){const r=e.type;e.render||(e.render=r.render||kt);{const s=fr(e);It();try{qu(e)}finally{Dt(),s()}}}const A1={get(e,t){return je(e,"get",""),e[t]}};function R1(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,A1),slots:e.slots,emit:e.emit,expose:t}}function ss(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy($a(go(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in Gn)return Gn[n](e)},has(t,n){return n in t||n in Gn}})):e.proxy}function T1(e,t=!0){return fe(e)?e.displayName||e.name:e.name||t&&e.__name}function O1(e){return fe(e)&&"__vccOpts"in e}const oe=(e,t)=>bu(e,t,En);function ko(e,t,n){const r=arguments.length;return r===2?Ae(t)&&!le(t)?Hr(t)?xe(e,null,[t]):xe(e,t):xe(e,null,t):(r>3?n=Array.prototype.slice.call(arguments,2):r===3&&Hr(n)&&(n=[n]),xe(e,t,n))}const M1="3.5.18";/** +* @vue/runtime-dom v3.5.18 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Ws;const li=typeof window<"u"&&window.trustedTypes;if(li)try{Ws=li.createPolicy("vue",{createHTML:e=>e})}catch{}const wl=Ws?e=>Ws.createHTML(e):e=>e,P1="http://www.w3.org/2000/svg",L1="http://www.w3.org/1998/Math/MathML",Mt=typeof document<"u"?document:null,ci=Mt&&Mt.createElement("template"),N1={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{const s=t==="svg"?Mt.createElementNS(P1,e):t==="mathml"?Mt.createElementNS(L1,e):n?Mt.createElement(e,{is:n}):Mt.createElement(e);return e==="select"&&r&&r.multiple!=null&&s.setAttribute("multiple",r.multiple),s},createText:e=>Mt.createTextNode(e),createComment:e=>Mt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Mt.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,r,s,o){const i=n?n.previousSibling:t.lastChild;if(s&&(s===o||s.nextSibling))for(;t.insertBefore(s.cloneNode(!0),n),!(s===o||!(s=s.nextSibling)););else{ci.innerHTML=wl(r==="svg"?`${e}`:r==="mathml"?`${e}`:e);const a=ci.content;if(r==="svg"||r==="mathml"){const l=a.firstChild;for(;l.firstChild;)a.appendChild(l.firstChild);a.removeChild(l)}t.insertBefore(a,n)}return[i?i.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Vt="transition",In="animation",rr=Symbol("_vtc"),kl={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},I1=$e({},Za,kl),D1=e=>(e.displayName="Transition",e.props=I1,e),gh=D1((e,{slots:t})=>ko(Ou,$1(e),t)),nn=(e,t=[])=>{le(e)?e.forEach(n=>n(...t)):e&&e(...t)},ui=e=>e?le(e)?e.some(t=>t.length>1):e.length>1:!1;function $1(e){const t={};for(const _ in e)_ in kl||(t[_]=e[_]);if(e.css===!1)return t;const{name:n="v",type:r,duration:s,enterFromClass:o=`${n}-enter-from`,enterActiveClass:i=`${n}-enter-active`,enterToClass:a=`${n}-enter-to`,appearFromClass:l=o,appearActiveClass:u=i,appearToClass:c=a,leaveFromClass:d=`${n}-leave-from`,leaveActiveClass:f=`${n}-leave-active`,leaveToClass:y=`${n}-leave-to`}=e,h=F1(s),v=h&&h[0],w=h&&h[1],{onBeforeEnter:S,onEnter:L,onEnterCancelled:R,onLeave:M,onLeaveCancelled:F,onBeforeAppear:Y=S,onAppear:q=L,onAppearCancelled:X=R}=t,N=(_,K,I,ne)=>{_._enterCancelled=ne,rn(_,K?c:a),rn(_,K?u:i),I&&I()},Q=(_,K)=>{_._isLeaving=!1,rn(_,d),rn(_,y),rn(_,f),K&&K()},j=_=>(K,I)=>{const ne=_?q:L,se=()=>N(K,_,I);nn(ne,[K,se]),di(()=>{rn(K,_?l:o),Tt(K,_?c:a),ui(ne)||fi(K,r,v,se)})};return $e(t,{onBeforeEnter(_){nn(S,[_]),Tt(_,o),Tt(_,i)},onBeforeAppear(_){nn(Y,[_]),Tt(_,l),Tt(_,u)},onEnter:j(!1),onAppear:j(!0),onLeave(_,K){_._isLeaving=!0;const I=()=>Q(_,K);Tt(_,d),_._enterCancelled?(Tt(_,f),mi()):(mi(),Tt(_,f)),di(()=>{_._isLeaving&&(rn(_,d),Tt(_,y),ui(M)||fi(_,r,w,I))}),nn(M,[_,I])},onEnterCancelled(_){N(_,!1,void 0,!0),nn(R,[_])},onAppearCancelled(_){N(_,!0,void 0,!0),nn(X,[_])},onLeaveCancelled(_){Q(_),nn(F,[_])}})}function F1(e){if(e==null)return null;if(Ae(e))return[xs(e.enter),xs(e.leave)];{const t=xs(e);return[t,t]}}function xs(e){return $c(e)}function Tt(e,t){t.split(/\s+/).forEach(n=>n&&e.classList.add(n)),(e[rr]||(e[rr]=new Set)).add(t)}function rn(e,t){t.split(/\s+/).forEach(r=>r&&e.classList.remove(r));const n=e[rr];n&&(n.delete(t),n.size||(e[rr]=void 0))}function di(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let V1=0;function fi(e,t,n,r){const s=e._endId=++V1,o=()=>{s===e._endId&&r()};if(n!=null)return setTimeout(o,n);const{type:i,timeout:a,propCount:l}=B1(e,t);if(!i)return r();const u=i+"end";let c=0;const d=()=>{e.removeEventListener(u,f),o()},f=y=>{y.target===e&&++c>=l&&d()};setTimeout(()=>{c(n[h]||"").split(", "),s=r(`${Vt}Delay`),o=r(`${Vt}Duration`),i=pi(s,o),a=r(`${In}Delay`),l=r(`${In}Duration`),u=pi(a,l);let c=null,d=0,f=0;t===Vt?i>0&&(c=Vt,d=i,f=o.length):t===In?u>0&&(c=In,d=u,f=l.length):(d=Math.max(i,u),c=d>0?i>u?Vt:In:null,f=c?c===Vt?o.length:l.length:0);const y=c===Vt&&/\b(transform|all)(,|$)/.test(r(`${Vt}Property`).toString());return{type:c,timeout:d,propCount:f,hasTransform:y}}function pi(e,t){for(;e.lengthhi(n)+hi(e[r])))}function hi(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function mi(){return document.body.offsetHeight}function H1(e,t,n){const r=e[rr];r&&(t=(t?[t,...r]:[...r]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const Ur=Symbol("_vod"),El=Symbol("_vsh"),yh={beforeMount(e,{value:t},{transition:n}){e[Ur]=e.style.display==="none"?"":e.style.display,n&&t?n.beforeEnter(e):Dn(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:r}){!t!=!n&&(r?t?(r.beforeEnter(e),Dn(e,!0),r.enter(e)):r.leave(e,()=>{Dn(e,!1)}):Dn(e,t))},beforeUnmount(e,{value:t}){Dn(e,t)}};function Dn(e,t){e.style.display=t?e[Ur]:"none",e[El]=!t}const j1=Symbol(""),U1=/(^|;)\s*display\s*:/;function q1(e,t,n){const r=e.style,s=Pe(n);let o=!1;if(n&&!s){if(t)if(Pe(t))for(const i of t.split(";")){const a=i.slice(0,i.indexOf(":")).trim();n[a]==null&&Or(r,a,"")}else for(const i in t)n[i]==null&&Or(r,i,"");for(const i in n)i==="display"&&(o=!0),Or(r,i,n[i])}else if(s){if(t!==n){const i=r[j1];i&&(n+=";"+i),r.cssText=n,o=U1.test(n)}}else t&&e.removeAttribute("style");Ur in e&&(e[Ur]=o?r.display:"",e[El]&&(r.display="none"))}const gi=/\s*!important$/;function Or(e,t,n){if(le(n))n.forEach(r=>Or(e,t,r));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const r=K1(e,t);gi.test(n)?e.setProperty(Qt(r),n.replace(gi,""),"important"):e[r]=n}}const yi=["Webkit","Moz","ms"],ws={};function K1(e,t){const n=ws[t];if(n)return n;let r=ut(t);if(r!=="filter"&&r in e)return ws[t]=r;r=Jr(r);for(let s=0;sks||(z1.then(()=>ks=0),ks=Date.now());function Y1(e,t){const n=r=>{if(!r._vts)r._vts=Date.now();else if(r._vts<=n.attached)return;ft(Q1(r,n.value),t,5,[r])};return n.value=e,n.attached=J1(),n}function Q1(e,t){if(le(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(r=>s=>!s._stopped&&r&&r(s))}else return t}const wi=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,X1=(e,t,n,r,s,o)=>{const i=s==="svg";t==="class"?H1(e,r,i):t==="style"?q1(e,n,r):Zr(t)?oo(t)||Z1(e,t,n,r,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):ed(e,t,r,i))?(Ci(e,t,r),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&bi(e,t,r,i,o,t!=="value")):e._isVueCE&&(/[A-Z]/.test(t)||!Pe(r))?Ci(e,ut(t),r,o,t):(t==="true-value"?e._trueValue=r:t==="false-value"&&(e._falseValue=r),bi(e,t,r,i))};function ed(e,t,n,r){if(r)return!!(t==="innerHTML"||t==="textContent"||t in e&&wi(t)&&fe(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const s=e.tagName;if(s==="IMG"||s==="VIDEO"||s==="CANVAS"||s==="SOURCE")return!1}return wi(t)&&Pe(n)?!1:t in e}const Sn=e=>{const t=e.props["onUpdate:modelValue"]||!1;return le(t)?n=>Er(t,n):t};function td(e){e.target.composing=!0}function ki(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const Nt=Symbol("_assign"),vh={created(e,{modifiers:{lazy:t,trim:n,number:r}},s){e[Nt]=Sn(s);const o=r||s.props&&s.props.type==="number";Wt(e,t?"change":"input",i=>{if(i.target.composing)return;let a=e.value;n&&(a=a.trim()),o&&(a=Nr(a)),e[Nt](a)}),n&&Wt(e,"change",()=>{e.value=e.value.trim()}),t||(Wt(e,"compositionstart",td),Wt(e,"compositionend",ki),Wt(e,"change",ki))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:s,number:o}},i){if(e[Nt]=Sn(i),e.composing)return;const a=(o||e.type==="number")&&!/^0\d/.test(e.value)?Nr(e.value):e.value,l=t??"";a!==l&&(document.activeElement===e&&e.type!=="range"&&(r&&t===n||s&&e.value.trim()===l)||(e.value=l))}},bh={created(e,{value:t},n){e.checked=kn(t,n.props.value),e[Nt]=Sn(n),Wt(e,"change",()=>{e[Nt](sr(e))})},beforeUpdate(e,{value:t,oldValue:n},r){e[Nt]=Sn(r),t!==n&&(e.checked=kn(t,r.props.value))}},Ch={deep:!0,created(e,{value:t,modifiers:{number:n}},r){const s=Gr(t);Wt(e,"change",()=>{const o=Array.prototype.filter.call(e.options,i=>i.selected).map(i=>n?Nr(sr(i)):sr(i));e[Nt](e.multiple?s?new Set(o):o:o[0]),e._assigning=!0,Xr(()=>{e._assigning=!1})}),e[Nt]=Sn(r)},mounted(e,{value:t}){Ei(e,t)},beforeUpdate(e,t,n){e[Nt]=Sn(n)},updated(e,{value:t}){e._assigning||Ei(e,t)}};function Ei(e,t){const n=e.multiple,r=le(t);if(!(n&&!r&&!Gr(t))){for(let s=0,o=e.options.length;sString(u)===String(a)):i.selected=Kc(t,a)>-1}else i.selected=t.has(a);else if(kn(sr(i),t)){e.selectedIndex!==s&&(e.selectedIndex=s);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function sr(e){return"_value"in e?e._value:e.value}const nd=["ctrl","shift","alt","meta"],rd={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>nd.some(n=>e[`${n}Key`]&&!t.includes(n))},Zs=(e,t)=>{const n=e._withMods||(e._withMods={}),r=t.join(".");return n[r]||(n[r]=(s,...o)=>{for(let i=0;i{const n=e._withKeys||(e._withKeys={}),r=t.join(".");return n[r]||(n[r]=s=>{if(!("key"in s))return;const o=Qt(s.key);if(t.some(i=>i===o||sd[i]===o))return e(s)})},od=$e({patchProp:X1},N1);let Si;function id(){return Si||(Si=s1(od))}const ad=(...e)=>{const t=id().createApp(...e),{mount:n}=t;return t.mount=r=>{const s=cd(r);if(!s)return;const o=t._component;!fe(o)&&!o.render&&!o.template&&(o.template=s.innerHTML),s.nodeType===1&&(s.textContent="");const i=n(s,!1,ld(s));return s instanceof Element&&(s.removeAttribute("v-cloak"),s.setAttribute("data-v-app","")),i},t};function ld(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function cd(e){return Pe(e)?document.querySelector(e):e}/*! + * pinia v3.0.4 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */let Sl;const os=e=>Sl=e,Al=Symbol();function Gs(e){return e&&typeof e=="object"&&Object.prototype.toString.call(e)==="[object Object]"&&typeof e.toJSON!="function"}var Jn;(function(e){e.direct="direct",e.patchObject="patch object",e.patchFunction="patch function"})(Jn||(Jn={}));function ud(){const e=va(!0),t=e.run(()=>Z({}));let n=[],r=[];const s=go({install(o){os(s),s._a=o,o.provide(Al,s),o.config.globalProperties.$pinia=s,r.forEach(i=>n.push(i)),r=[]},use(o){return this._a?n.push(o):r.push(o),this},_p:n,_a:null,_e:e,_s:new Map,state:t});return s}const Rl=()=>{};function Ai(e,t,n,r=Rl){e.add(t);const s=()=>{e.delete(t)&&r()};return!n&&ba()&&Wc(s),s}function yn(e,...t){e.forEach(n=>{n(...t)})}const dd=e=>e(),Ri=Symbol(),Es=Symbol();function zs(e,t){e instanceof Map&&t instanceof Map?t.forEach((n,r)=>e.set(r,n)):e instanceof Set&&t instanceof Set&&t.forEach(e.add,e);for(const n in t){if(!t.hasOwnProperty(n))continue;const r=t[n],s=e[n];Gs(s)&&Gs(r)&&e.hasOwnProperty(n)&&!Ie(r)&&!zt(r)?e[n]=zs(s,r):e[n]=r}return e}const fd=Symbol();function pd(e){return!Gs(e)||!Object.prototype.hasOwnProperty.call(e,fd)}const{assign:Ht}=Object;function hd(e){return!!(Ie(e)&&e.effect)}function md(e,t,n,r){const{state:s,actions:o,getters:i}=t,a=n.state.value[e];let l;function u(){a||(n.state.value[e]=s?s():{});const c=mu(n.state.value[e]);return Ht(c,o,Object.keys(i||{}).reduce((d,f)=>(d[f]=go(oe(()=>{os(n);const y=n._s.get(e);return i[f].call(y,y)})),d),{}))}return l=Tl(e,u,t,n,r,!0),l}function Tl(e,t,n={},r,s,o){let i;const a=Ht({actions:{}},n),l={deep:!0};let u,c,d=new Set,f=new Set,y;const h=r.state.value[e];!o&&!h&&(r.state.value[e]={}),Z({});let v;function w(X){let N;u=c=!1,typeof X=="function"?(X(r.state.value[e]),N={type:Jn.patchFunction,storeId:e,events:y}):(zs(r.state.value[e],X),N={type:Jn.patchObject,payload:X,storeId:e,events:y});const Q=v=Symbol();Xr().then(()=>{v===Q&&(u=!0)}),c=!0,yn(d,N,r.state.value[e])}const S=o?function(){const{state:N}=n,Q=N?N():{};this.$patch(j=>{Ht(j,Q)})}:Rl;function L(){i.stop(),d.clear(),f.clear(),r._s.delete(e)}const R=(X,N="")=>{if(Ri in X)return X[Es]=N,X;const Q=function(){os(r);const j=Array.from(arguments),_=new Set,K=new Set;function I(ee){_.add(ee)}function ne(ee){K.add(ee)}yn(f,{args:j,name:Q[Es],store:F,after:I,onError:ne});let se;try{se=X.apply(this&&this.$id===e?this:F,j)}catch(ee){throw yn(K,ee),ee}return se instanceof Promise?se.then(ee=>(yn(_,ee),ee)).catch(ee=>(yn(K,ee),Promise.reject(ee))):(yn(_,se),se)};return Q[Ri]=!0,Q[Es]=N,Q},M={_p:r,$id:e,$onAction:Ai.bind(null,f),$patch:w,$reset:S,$subscribe(X,N={}){const Q=Ai(d,X,N.detached,()=>j()),j=i.run(()=>Et(()=>r.state.value[e],_=>{(N.flush==="sync"?c:u)&&X({storeId:e,type:Jn.direct,events:y},_)},Ht({},l,N)));return Q},$dispose:L},F=ar(M);r._s.set(e,F);const q=(r._a&&r._a.runWithContext||dd)(()=>r._e.run(()=>(i=va()).run(()=>t({action:R}))));for(const X in q){const N=q[X];if(Ie(N)&&!hd(N)||zt(N))o||(h&&pd(N)&&(Ie(N)?N.value=h[X]:zs(N,h[X])),r.state.value[e][X]=N);else if(typeof N=="function"){const Q=R(N,X);q[X]=Q,a.actions[X]=N}}return Ht(F,q),Ht(_e(F),q),Object.defineProperty(F,"$state",{get:()=>r.state.value[e],set:X=>{w(N=>{Ht(N,X)})}}),r._p.forEach(X=>{Ht(F,i.run(()=>X({store:F,app:r._a,pinia:r,options:a})))}),h&&o&&n.hydrate&&n.hydrate(F.$state,h),u=!0,c=!0,F}/*! #__NO_SIDE_EFFECTS__ */function Eo(e,t,n){let r;const s=typeof t=="function";r=s?n:t;function o(i,a){const l=Yu();return i=i||(l?ct(Al,null):null),i&&os(i),i=Sl,i._s.has(e)||(s?Tl(e,t,r,i):md(e,r,i)),i._s.get(e)}return o.$id=e,o}/*! + * vue-router v4.6.3 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */const bn=typeof document<"u";function Ol(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function gd(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&Ol(e.default)}const we=Object.assign;function Ss(e,t){const n={};for(const r in t){const s=t[r];n[r]=pt(s)?s.map(e):e(s)}return n}const Yn=()=>{},pt=Array.isArray;function Ti(e,t){const n={};for(const r in e)n[r]=r in t?t[r]:e[r];return n}const Ml=/#/g,yd=/&/g,vd=/\//g,bd=/=/g,Cd=/\?/g,Pl=/\+/g,_d=/%5B/g,xd=/%5D/g,Ll=/%5E/g,wd=/%60/g,Nl=/%7B/g,kd=/%7C/g,Il=/%7D/g,Ed=/%20/g;function So(e){return e==null?"":encodeURI(""+e).replace(kd,"|").replace(_d,"[").replace(xd,"]")}function Sd(e){return So(e).replace(Nl,"{").replace(Il,"}").replace(Ll,"^")}function Js(e){return So(e).replace(Pl,"%2B").replace(Ed,"+").replace(Ml,"%23").replace(yd,"%26").replace(wd,"`").replace(Nl,"{").replace(Il,"}").replace(Ll,"^")}function Ad(e){return Js(e).replace(bd,"%3D")}function Rd(e){return So(e).replace(Ml,"%23").replace(Cd,"%3F")}function Td(e){return Rd(e).replace(vd,"%2F")}function or(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}const Od=/\/$/,Md=e=>e.replace(Od,"");function As(e,t,n="/"){let r,s={},o="",i="";const a=t.indexOf("#");let l=t.indexOf("?");return l=a>=0&&l>a?-1:l,l>=0&&(r=t.slice(0,l),o=t.slice(l,a>0?a:t.length),s=e(o.slice(1))),a>=0&&(r=r||t.slice(0,a),i=t.slice(a,t.length)),r=Id(r??t,n),{fullPath:r+o+i,path:r,query:s,hash:or(i)}}function Pd(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function Oi(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function Ld(e,t,n){const r=t.matched.length-1,s=n.matched.length-1;return r>-1&&r===s&&An(t.matched[r],n.matched[s])&&Dl(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function An(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function Dl(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(const n in e)if(!Nd(e[n],t[n]))return!1;return!0}function Nd(e,t){return pt(e)?Mi(e,t):pt(t)?Mi(t,e):e===t}function Mi(e,t){return pt(t)?e.length===t.length&&e.every((n,r)=>n===t[r]):e.length===1&&e[0]===t}function Id(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),r=e.split("/"),s=r[r.length-1];(s===".."||s===".")&&r.push("");let o=n.length-1,i,a;for(i=0;i1&&o--;else break;return n.slice(0,o).join("/")+"/"+r.slice(i).join("/")}const Bt={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};let Ys=function(e){return e.pop="pop",e.push="push",e}({}),Rs=function(e){return e.back="back",e.forward="forward",e.unknown="",e}({});function Dd(e){if(!e)if(bn){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),Md(e)}const $d=/^[^#]+#/;function Fd(e,t){return e.replace($d,"#")+t}function Vd(e,t){const n=document.documentElement.getBoundingClientRect(),r=e.getBoundingClientRect();return{behavior:t.behavior,left:r.left-n.left-(t.left||0),top:r.top-n.top-(t.top||0)}}const is=()=>({left:window.scrollX,top:window.scrollY});function Bd(e){let t;if("el"in e){const n=e.el,r=typeof n=="string"&&n.startsWith("#"),s=typeof n=="string"?r?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!s)return;t=Vd(s,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function Pi(e,t){return(history.state?history.state.position-t:-1)+e}const Qs=new Map;function Hd(e,t){Qs.set(e,t)}function jd(e){const t=Qs.get(e);return Qs.delete(e),t}function Ud(e){return typeof e=="string"||e&&typeof e=="object"}function $l(e){return typeof e=="string"||typeof e=="symbol"}let Le=function(e){return e[e.MATCHER_NOT_FOUND=1]="MATCHER_NOT_FOUND",e[e.NAVIGATION_GUARD_REDIRECT=2]="NAVIGATION_GUARD_REDIRECT",e[e.NAVIGATION_ABORTED=4]="NAVIGATION_ABORTED",e[e.NAVIGATION_CANCELLED=8]="NAVIGATION_CANCELLED",e[e.NAVIGATION_DUPLICATED=16]="NAVIGATION_DUPLICATED",e}({});const Fl=Symbol("");Le.MATCHER_NOT_FOUND+"",Le.NAVIGATION_GUARD_REDIRECT+"",Le.NAVIGATION_ABORTED+"",Le.NAVIGATION_CANCELLED+"",Le.NAVIGATION_DUPLICATED+"";function Rn(e,t){return we(new Error,{type:e,[Fl]:!0},t)}function Ot(e,t){return e instanceof Error&&Fl in e&&(t==null||!!(e.type&t))}const qd=["params","query","hash"];function Kd(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;const t={};for(const n of qd)n in e&&(t[n]=e[n]);return JSON.stringify(t,null,2)}function Wd(e){const t={};if(e===""||e==="?")return t;const n=(e[0]==="?"?e.slice(1):e).split("&");for(let r=0;rs&&Js(s)):[r&&Js(r)]).forEach(s=>{s!==void 0&&(t+=(t.length?"&":"")+n,s!=null&&(t+="="+s))})}return t}function Zd(e){const t={};for(const n in e){const r=e[n];r!==void 0&&(t[n]=pt(r)?r.map(s=>s==null?null:""+s):r==null?r:""+r)}return t}const Gd=Symbol(""),Ni=Symbol(""),as=Symbol(""),Ao=Symbol(""),Xs=Symbol("");function $n(){let e=[];function t(r){return e.push(r),()=>{const s=e.indexOf(r);s>-1&&e.splice(s,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function Kt(e,t,n,r,s,o=i=>i()){const i=r&&(r.enterCallbacks[s]=r.enterCallbacks[s]||[]);return()=>new Promise((a,l)=>{const u=f=>{f===!1?l(Rn(Le.NAVIGATION_ABORTED,{from:n,to:t})):f instanceof Error?l(f):Ud(f)?l(Rn(Le.NAVIGATION_GUARD_REDIRECT,{from:t,to:f})):(i&&r.enterCallbacks[s]===i&&typeof f=="function"&&i.push(f),a())},c=o(()=>e.call(r&&r.instances[s],t,n,u));let d=Promise.resolve(c);e.length<3&&(d=d.then(u)),d.catch(f=>l(f))})}function Ts(e,t,n,r,s=o=>o()){const o=[];for(const i of e)for(const a in i.components){let l=i.components[a];if(!(t!=="beforeRouteEnter"&&!i.instances[a]))if(Ol(l)){const u=(l.__vccOpts||l)[t];u&&o.push(Kt(u,n,r,i,a,s))}else{let u=l();o.push(()=>u.then(c=>{if(!c)throw new Error(`Couldn't resolve component "${a}" at "${i.path}"`);const d=gd(c)?c.default:c;i.mods[a]=c,i.components[a]=d;const f=(d.__vccOpts||d)[t];return f&&Kt(f,n,r,i,a,s)()}))}}return o}function zd(e,t){const n=[],r=[],s=[],o=Math.max(t.matched.length,e.matched.length);for(let i=0;iAn(u,a))?r.push(a):n.push(a));const l=e.matched[i];l&&(t.matched.find(u=>An(u,l))||s.push(l))}return[n,r,s]}/*! + * vue-router v4.6.3 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */let Jd=()=>location.protocol+"//"+location.host;function Vl(e,t){const{pathname:n,search:r,hash:s}=t,o=e.indexOf("#");if(o>-1){let i=s.includes(e.slice(o))?e.slice(o).length:1,a=s.slice(i);return a[0]!=="/"&&(a="/"+a),Oi(a,"")}return Oi(n,e)+r+s}function Yd(e,t,n,r){let s=[],o=[],i=null;const a=({state:f})=>{const y=Vl(e,location),h=n.value,v=t.value;let w=0;if(f){if(n.value=y,t.value=f,i&&i===h){i=null;return}w=v?f.position-v.position:0}else r(y);s.forEach(S=>{S(n.value,h,{delta:w,type:Ys.pop,direction:w?w>0?Rs.forward:Rs.back:Rs.unknown})})};function l(){i=n.value}function u(f){s.push(f);const y=()=>{const h=s.indexOf(f);h>-1&&s.splice(h,1)};return o.push(y),y}function c(){if(document.visibilityState==="hidden"){const{history:f}=window;if(!f.state)return;f.replaceState(we({},f.state,{scroll:is()}),"")}}function d(){for(const f of o)f();o=[],window.removeEventListener("popstate",a),window.removeEventListener("pagehide",c),document.removeEventListener("visibilitychange",c)}return window.addEventListener("popstate",a),window.addEventListener("pagehide",c),document.addEventListener("visibilitychange",c),{pauseListeners:l,listen:u,destroy:d}}function Ii(e,t,n,r=!1,s=!1){return{back:e,current:t,forward:n,replaced:r,position:window.history.length,scroll:s?is():null}}function Qd(e){const{history:t,location:n}=window,r={value:Vl(e,n)},s={value:t.state};s.value||o(r.value,{back:null,current:r.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function o(l,u,c){const d=e.indexOf("#"),f=d>-1?(n.host&&document.querySelector("base")?e:e.slice(d))+l:Jd()+e+l;try{t[c?"replaceState":"pushState"](u,"",f),s.value=u}catch(y){console.error(y),n[c?"replace":"assign"](f)}}function i(l,u){o(l,we({},t.state,Ii(s.value.back,l,s.value.forward,!0),u,{position:s.value.position}),!0),r.value=l}function a(l,u){const c=we({},s.value,t.state,{forward:l,scroll:is()});o(c.current,c,!0),o(l,we({},Ii(r.value,l,null),{position:c.position+1},u),!1),r.value=l}return{location:r,state:s,push:a,replace:i}}function Xd(e){e=Dd(e);const t=Qd(e),n=Yd(e,t.state,t.location,t.replace);function r(o,i=!0){i||n.pauseListeners(),history.go(o)}const s=we({location:"",base:e,go:r,createHref:Fd.bind(null,e)},t,n);return Object.defineProperty(s,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(s,"state",{enumerable:!0,get:()=>t.state.value}),s}let ln=function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.Group=2]="Group",e}({});var De=function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.ParamRegExp=2]="ParamRegExp",e[e.ParamRegExpEnd=3]="ParamRegExpEnd",e[e.EscapeNext=4]="EscapeNext",e}(De||{});const ef={type:ln.Static,value:""},tf=/[a-zA-Z0-9_]/;function nf(e){if(!e)return[[]];if(e==="/")return[[ef]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(y){throw new Error(`ERR (${n})/"${u}": ${y}`)}let n=De.Static,r=n;const s=[];let o;function i(){o&&s.push(o),o=[]}let a=0,l,u="",c="";function d(){u&&(n===De.Static?o.push({type:ln.Static,value:u}):n===De.Param||n===De.ParamRegExp||n===De.ParamRegExpEnd?(o.length>1&&(l==="*"||l==="+")&&t(`A repeatable param (${u}) must be alone in its segment. eg: '/:ids+.`),o.push({type:ln.Param,value:u,regexp:c,repeatable:l==="*"||l==="+",optional:l==="*"||l==="?"})):t("Invalid state to consume buffer"),u="")}function f(){u+=l}for(;at.length?t.length===1&&t[0]===Ge.Static+Ge.Segment?1:-1:0}function Bl(e,t){let n=0;const r=e.score,s=t.score;for(;n0&&t[t.length-1]<0}const lf={strict:!1,end:!0,sensitive:!1};function cf(e,t,n){const r=of(nf(e.path),n),s=we(r,{record:e,parent:t,children:[],alias:[]});return t&&!s.record.aliasOf==!t.record.aliasOf&&t.children.push(s),s}function uf(e,t){const n=[],r=new Map;t=Ti(lf,t);function s(d){return r.get(d)}function o(d,f,y){const h=!y,v=Vi(d);v.aliasOf=y&&y.record;const w=Ti(t,d),S=[v];if("alias"in d){const M=typeof d.alias=="string"?[d.alias]:d.alias;for(const F of M)S.push(Vi(we({},v,{components:y?y.record.components:v.components,path:F,aliasOf:y?y.record:v})))}let L,R;for(const M of S){const{path:F}=M;if(f&&F[0]!=="/"){const Y=f.record.path,q=Y[Y.length-1]==="/"?"":"/";M.path=f.record.path+(F&&q+F)}if(L=cf(M,f,w),y?y.alias.push(L):(R=R||L,R!==L&&R.alias.push(L),h&&d.name&&!Bi(L)&&i(d.name)),Hl(L)&&l(L),v.children){const Y=v.children;for(let q=0;q{i(R)}:Yn}function i(d){if($l(d)){const f=r.get(d);f&&(r.delete(d),n.splice(n.indexOf(f),1),f.children.forEach(i),f.alias.forEach(i))}else{const f=n.indexOf(d);f>-1&&(n.splice(f,1),d.record.name&&r.delete(d.record.name),d.children.forEach(i),d.alias.forEach(i))}}function a(){return n}function l(d){const f=pf(d,n);n.splice(f,0,d),d.record.name&&!Bi(d)&&r.set(d.record.name,d)}function u(d,f){let y,h={},v,w;if("name"in d&&d.name){if(y=r.get(d.name),!y)throw Rn(Le.MATCHER_NOT_FOUND,{location:d});w=y.record.name,h=we(Fi(f.params,y.keys.filter(R=>!R.optional).concat(y.parent?y.parent.keys.filter(R=>R.optional):[]).map(R=>R.name)),d.params&&Fi(d.params,y.keys.map(R=>R.name))),v=y.stringify(h)}else if(d.path!=null)v=d.path,y=n.find(R=>R.re.test(v)),y&&(h=y.parse(v),w=y.record.name);else{if(y=f.name?r.get(f.name):n.find(R=>R.re.test(f.path)),!y)throw Rn(Le.MATCHER_NOT_FOUND,{location:d,currentLocation:f});w=y.record.name,h=we({},f.params,d.params),v=y.stringify(h)}const S=[];let L=y;for(;L;)S.unshift(L.record),L=L.parent;return{name:w,path:v,params:h,matched:S,meta:ff(S)}}e.forEach(d=>o(d));function c(){n.length=0,r.clear()}return{addRoute:o,resolve:u,removeRoute:i,clearRoutes:c,getRoutes:a,getRecordMatcher:s}}function Fi(e,t){const n={};for(const r of t)r in e&&(n[r]=e[r]);return n}function Vi(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:df(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,"mods",{value:{}}),t}function df(e){const t={},n=e.props||!1;if("component"in e)t.default=n;else for(const r in e.components)t[r]=typeof n=="object"?n[r]:n;return t}function Bi(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function ff(e){return e.reduce((t,n)=>we(t,n.meta),{})}function pf(e,t){let n=0,r=t.length;for(;n!==r;){const o=n+r>>1;Bl(e,t[o])<0?r=o:n=o+1}const s=hf(e);return s&&(r=t.lastIndexOf(s,r-1)),r}function hf(e){let t=e;for(;t=t.parent;)if(Hl(t)&&Bl(e,t)===0)return t}function Hl({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function Hi(e){const t=ct(as),n=ct(Ao),r=oe(()=>{const l=de(e.to);return t.resolve(l)}),s=oe(()=>{const{matched:l}=r.value,{length:u}=l,c=l[u-1],d=n.matched;if(!c||!d.length)return-1;const f=d.findIndex(An.bind(null,c));if(f>-1)return f;const y=ji(l[u-2]);return u>1&&ji(c)===y&&d[d.length-1].path!==y?d.findIndex(An.bind(null,l[u-2])):f}),o=oe(()=>s.value>-1&&bf(n.params,r.value.params)),i=oe(()=>s.value>-1&&s.value===n.matched.length-1&&Dl(n.params,r.value.params));function a(l={}){if(vf(l)){const u=t[de(e.replace)?"replace":"push"](de(e.to)).catch(Yn);return e.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>u),u}return Promise.resolve()}return{route:r,href:oe(()=>r.value.href),isActive:o,isExactActive:i,navigate:a}}function mf(e){return e.length===1?e[0]:e}const gf=ht({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"},viewTransition:Boolean},useLink:Hi,setup(e,{slots:t}){const n=ar(Hi(e)),{options:r}=ct(as),s=oe(()=>({[Ui(e.activeClass,r.linkActiveClass,"router-link-active")]:n.isActive,[Ui(e.exactActiveClass,r.linkExactActiveClass,"router-link-exact-active")]:n.isExactActive}));return()=>{const o=t.default&&mf(t.default(n));return e.custom?o:ko("a",{"aria-current":n.isExactActive?e.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:s.value},o)}}}),yf=gf;function vf(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function bf(e,t){for(const n in t){const r=t[n],s=e[n];if(typeof r=="string"){if(r!==s)return!1}else if(!pt(s)||s.length!==r.length||r.some((o,i)=>o!==s[i]))return!1}return!0}function ji(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const Ui=(e,t,n)=>e??t??n,Cf=ht({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:n}){const r=ct(Xs),s=oe(()=>e.route||r.value),o=ct(Ni,0),i=oe(()=>{let u=de(o);const{matched:c}=s.value;let d;for(;(d=c[u])&&!d.components;)u++;return u}),a=oe(()=>s.value.matched[i.value]);Ar(Ni,oe(()=>i.value+1)),Ar(Gd,a),Ar(Xs,s);const l=Z();return Et(()=>[l.value,a.value,e.name],([u,c,d],[f,y,h])=>{c&&(c.instances[d]=u,y&&y!==c&&u&&u===f&&(c.leaveGuards.size||(c.leaveGuards=y.leaveGuards),c.updateGuards.size||(c.updateGuards=y.updateGuards))),u&&c&&(!y||!An(c,y)||!f)&&(c.enterCallbacks[d]||[]).forEach(v=>v(u))},{flush:"post"}),()=>{const u=s.value,c=e.name,d=a.value,f=d&&d.components[c];if(!f)return qi(n.default,{Component:f,route:u});const y=d.props[c],h=y?y===!0?u.params:typeof y=="function"?y(u):y:null,w=ko(f,we({},h,t,{onVnodeUnmounted:S=>{S.component.isUnmounted&&(d.instances[c]=null)},ref:l}));return qi(n.default,{Component:w,route:u})||w}}});function qi(e,t){if(!e)return null;const n=e(t);return n.length===1?n[0]:n}const _f=Cf;function xf(e){const t=uf(e.routes,e),n=e.parseQuery||Wd,r=e.stringifyQuery||Li,s=e.history,o=$n(),i=$n(),a=$n(),l=fu(Bt);let u=Bt;bn&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const c=Ss.bind(null,E=>""+E),d=Ss.bind(null,Td),f=Ss.bind(null,or);function y(E,k){let C,V;return $l(E)?(C=t.getRecordMatcher(E),V=k):V=E,t.addRoute(V,C)}function h(E){const k=t.getRecordMatcher(E);k&&t.removeRoute(k)}function v(){return t.getRoutes().map(E=>E.record)}function w(E){return!!t.getRecordMatcher(E)}function S(E,k){if(k=we({},k||l.value),typeof E=="string"){const b=As(n,E,k.path),A=t.resolve({path:b.path},k),P=s.createHref(b.fullPath);return we(b,A,{params:f(A.params),hash:or(b.hash),redirectedFrom:void 0,href:P})}let C;if(E.path!=null)C=we({},E,{path:As(n,E.path,k.path).path});else{const b=we({},E.params);for(const A in b)b[A]==null&&delete b[A];C=we({},E,{params:d(b)}),k.params=d(k.params)}const V=t.resolve(C,k),ye=E.hash||"";V.params=c(f(V.params));const m=Pd(r,we({},E,{hash:Sd(ye),path:V.path})),g=s.createHref(m);return we({fullPath:m,hash:ye,query:r===Li?Zd(E.query):E.query||{}},V,{redirectedFrom:void 0,href:g})}function L(E){return typeof E=="string"?As(n,E,l.value.path):we({},E)}function R(E,k){if(u!==E)return Rn(Le.NAVIGATION_CANCELLED,{from:k,to:E})}function M(E){return q(E)}function F(E){return M(we(L(E),{replace:!0}))}function Y(E,k){const C=E.matched[E.matched.length-1];if(C&&C.redirect){const{redirect:V}=C;let ye=typeof V=="function"?V(E,k):V;return typeof ye=="string"&&(ye=ye.includes("?")||ye.includes("#")?ye=L(ye):{path:ye},ye.params={}),we({query:E.query,hash:E.hash,params:ye.path!=null?{}:E.params},ye)}}function q(E,k){const C=u=S(E),V=l.value,ye=E.state,m=E.force,g=E.replace===!0,b=Y(C,V);if(b)return q(we(L(b),{state:typeof b=="object"?we({},ye,b.state):ye,force:m,replace:g}),k||C);const A=C;A.redirectedFrom=k;let P;return!m&&Ld(r,V,C)&&(P=Rn(Le.NAVIGATION_DUPLICATED,{to:A,from:V}),re(V,V,!0,!1)),(P?Promise.resolve(P):Q(A,V)).catch(T=>Ot(T)?Ot(T,Le.NAVIGATION_GUARD_REDIRECT)?T:H(T):te(T,A,V)).then(T=>{if(T){if(Ot(T,Le.NAVIGATION_GUARD_REDIRECT))return q(we({replace:g},L(T.to),{state:typeof T.to=="object"?we({},ye,T.to.state):ye,force:m}),k||A)}else T=_(A,V,!0,g,ye);return j(A,V,T),T})}function X(E,k){const C=R(E,k);return C?Promise.reject(C):Promise.resolve()}function N(E){const k=me.values().next().value;return k&&typeof k.runWithContext=="function"?k.runWithContext(E):E()}function Q(E,k){let C;const[V,ye,m]=zd(E,k);C=Ts(V.reverse(),"beforeRouteLeave",E,k);for(const b of V)b.leaveGuards.forEach(A=>{C.push(Kt(A,E,k))});const g=X.bind(null,E,k);return C.push(g),ge(C).then(()=>{C=[];for(const b of o.list())C.push(Kt(b,E,k));return C.push(g),ge(C)}).then(()=>{C=Ts(ye,"beforeRouteUpdate",E,k);for(const b of ye)b.updateGuards.forEach(A=>{C.push(Kt(A,E,k))});return C.push(g),ge(C)}).then(()=>{C=[];for(const b of m)if(b.beforeEnter)if(pt(b.beforeEnter))for(const A of b.beforeEnter)C.push(Kt(A,E,k));else C.push(Kt(b.beforeEnter,E,k));return C.push(g),ge(C)}).then(()=>(E.matched.forEach(b=>b.enterCallbacks={}),C=Ts(m,"beforeRouteEnter",E,k,N),C.push(g),ge(C))).then(()=>{C=[];for(const b of i.list())C.push(Kt(b,E,k));return C.push(g),ge(C)}).catch(b=>Ot(b,Le.NAVIGATION_CANCELLED)?b:Promise.reject(b))}function j(E,k,C){a.list().forEach(V=>N(()=>V(E,k,C)))}function _(E,k,C,V,ye){const m=R(E,k);if(m)return m;const g=k===Bt,b=bn?history.state:{};C&&(V||g?s.replace(E.fullPath,we({scroll:g&&b&&b.scroll},ye)):s.push(E.fullPath,ye)),l.value=E,re(E,k,C,g),H()}let K;function I(){K||(K=s.listen((E,k,C)=>{if(!G.listening)return;const V=S(E),ye=Y(V,G.currentRoute.value);if(ye){q(we(ye,{replace:!0,force:!0}),V).catch(Yn);return}u=V;const m=l.value;bn&&Hd(Pi(m.fullPath,C.delta),is()),Q(V,m).catch(g=>Ot(g,Le.NAVIGATION_ABORTED|Le.NAVIGATION_CANCELLED)?g:Ot(g,Le.NAVIGATION_GUARD_REDIRECT)?(q(we(L(g.to),{force:!0}),V).then(b=>{Ot(b,Le.NAVIGATION_ABORTED|Le.NAVIGATION_DUPLICATED)&&!C.delta&&C.type===Ys.pop&&s.go(-1,!1)}).catch(Yn),Promise.reject()):(C.delta&&s.go(-C.delta,!1),te(g,V,m))).then(g=>{g=g||_(V,m,!1),g&&(C.delta&&!Ot(g,Le.NAVIGATION_CANCELLED)?s.go(-C.delta,!1):C.type===Ys.pop&&Ot(g,Le.NAVIGATION_ABORTED|Le.NAVIGATION_DUPLICATED)&&s.go(-1,!1)),j(V,m,g)}).catch(Yn)}))}let ne=$n(),se=$n(),ee;function te(E,k,C){H(E);const V=se.list();return V.length?V.forEach(ye=>ye(E,k,C)):console.error(E),Promise.reject(E)}function pe(){return ee&&l.value!==Bt?Promise.resolve():new Promise((E,k)=>{ne.add([E,k])})}function H(E){return ee||(ee=!E,I(),ne.list().forEach(([k,C])=>E?C(E):k()),ne.reset()),E}function re(E,k,C,V){const{scrollBehavior:ye}=e;if(!bn||!ye)return Promise.resolve();const m=!C&&jd(Pi(E.fullPath,0))||(V||!C)&&history.state&&history.state.scroll||null;return Xr().then(()=>ye(E,k,m)).then(g=>g&&Bd(g)).catch(g=>te(g,E,k))}const ve=E=>s.go(E);let Ye;const me=new Set,G={currentRoute:l,listening:!0,addRoute:y,removeRoute:h,clearRoutes:t.clearRoutes,hasRoute:w,getRoutes:v,resolve:S,options:e,push:M,replace:F,go:ve,back:()=>ve(-1),forward:()=>ve(1),beforeEach:o.add,beforeResolve:i.add,afterEach:a.add,onError:se.add,isReady:pe,install(E){E.component("RouterLink",yf),E.component("RouterView",_f),E.config.globalProperties.$router=G,Object.defineProperty(E.config.globalProperties,"$route",{enumerable:!0,get:()=>de(l)}),bn&&!Ye&&l.value===Bt&&(Ye=!0,M(s.location).catch(V=>{}));const k={};for(const V in Bt)Object.defineProperty(k,V,{get:()=>l.value[V],enumerable:!0});E.provide(as,G),E.provide(Ao,Na(k)),E.provide(Xs,l);const C=E.unmount;me.add(E),E.unmount=function(){me.delete(E),me.size<1&&(u=Bt,K&&K(),K=null,l.value=Bt,Ye=!1,ee=!1),C()}}};function ge(E){return E.reduce((k,C)=>k.then(()=>N(C)),Promise.resolve())}return G}function Ro(){return ct(as)}function To(e){return ct(Ao)}const wf="/assets/meshcore-DQNtEl5I.svg";function jl(e,t){return function(){return e.apply(t,arguments)}}const{toString:kf}=Object.prototype,{getPrototypeOf:Oo}=Object,{iterator:ls,toStringTag:Ul}=Symbol,cs=(e=>t=>{const n=kf.call(t);return e[n]||(e[n]=n.slice(8,-1).toLowerCase())})(Object.create(null)),mt=e=>(e=e.toLowerCase(),t=>cs(t)===e),us=e=>t=>typeof t===e,{isArray:Mn}=Array,Tn=us("undefined");function pr(e){return e!==null&&!Tn(e)&&e.constructor!==null&&!Tn(e.constructor)&&et(e.constructor.isBuffer)&&e.constructor.isBuffer(e)}const ql=mt("ArrayBuffer");function Ef(e){let t;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?t=ArrayBuffer.isView(e):t=e&&e.buffer&&ql(e.buffer),t}const Sf=us("string"),et=us("function"),Kl=us("number"),hr=e=>e!==null&&typeof e=="object",Af=e=>e===!0||e===!1,Mr=e=>{if(cs(e)!=="object")return!1;const t=Oo(e);return(t===null||t===Object.prototype||Object.getPrototypeOf(t)===null)&&!(Ul in e)&&!(ls in e)},Rf=e=>{if(!hr(e)||pr(e))return!1;try{return Object.keys(e).length===0&&Object.getPrototypeOf(e)===Object.prototype}catch{return!1}},Tf=mt("Date"),Of=mt("File"),Mf=mt("Blob"),Pf=mt("FileList"),Lf=e=>hr(e)&&et(e.pipe),Nf=e=>{let t;return e&&(typeof FormData=="function"&&e instanceof FormData||et(e.append)&&((t=cs(e))==="formdata"||t==="object"&&et(e.toString)&&e.toString()==="[object FormData]"))},If=mt("URLSearchParams"),[Df,$f,Ff,Vf]=["ReadableStream","Request","Response","Headers"].map(mt),Bf=e=>e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function mr(e,t,{allOwnKeys:n=!1}={}){if(e===null||typeof e>"u")return;let r,s;if(typeof e!="object"&&(e=[e]),Mn(e))for(r=0,s=e.length;r0;)if(s=n[r],t===s.toLowerCase())return s;return null}const cn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,Zl=e=>!Tn(e)&&e!==cn;function eo(){const{caseless:e,skipUndefined:t}=Zl(this)&&this||{},n={},r=(s,o)=>{const i=e&&Wl(n,o)||o;Mr(n[i])&&Mr(s)?n[i]=eo(n[i],s):Mr(s)?n[i]=eo({},s):Mn(s)?n[i]=s.slice():(!t||!Tn(s))&&(n[i]=s)};for(let s=0,o=arguments.length;s(mr(t,(s,o)=>{n&&et(s)?e[o]=jl(s,n):e[o]=s},{allOwnKeys:r}),e),jf=e=>(e.charCodeAt(0)===65279&&(e=e.slice(1)),e),Uf=(e,t,n,r)=>{e.prototype=Object.create(t.prototype,r),e.prototype.constructor=e,Object.defineProperty(e,"super",{value:t.prototype}),n&&Object.assign(e.prototype,n)},qf=(e,t,n,r)=>{let s,o,i;const a={};if(t=t||{},e==null)return t;do{for(s=Object.getOwnPropertyNames(e),o=s.length;o-- >0;)i=s[o],(!r||r(i,e,t))&&!a[i]&&(t[i]=e[i],a[i]=!0);e=n!==!1&&Oo(e)}while(e&&(!n||n(e,t))&&e!==Object.prototype);return t},Kf=(e,t,n)=>{e=String(e),(n===void 0||n>e.length)&&(n=e.length),n-=t.length;const r=e.indexOf(t,n);return r!==-1&&r===n},Wf=e=>{if(!e)return null;if(Mn(e))return e;let t=e.length;if(!Kl(t))return null;const n=new Array(t);for(;t-- >0;)n[t]=e[t];return n},Zf=(e=>t=>e&&t instanceof e)(typeof Uint8Array<"u"&&Oo(Uint8Array)),Gf=(e,t)=>{const r=(e&&e[ls]).call(e);let s;for(;(s=r.next())&&!s.done;){const o=s.value;t.call(e,o[0],o[1])}},zf=(e,t)=>{let n;const r=[];for(;(n=e.exec(t))!==null;)r.push(n);return r},Jf=mt("HTMLFormElement"),Yf=e=>e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(n,r,s){return r.toUpperCase()+s}),Ki=(({hasOwnProperty:e})=>(t,n)=>e.call(t,n))(Object.prototype),Qf=mt("RegExp"),Gl=(e,t)=>{const n=Object.getOwnPropertyDescriptors(e),r={};mr(n,(s,o)=>{let i;(i=t(s,o,e))!==!1&&(r[o]=i||s)}),Object.defineProperties(e,r)},Xf=e=>{Gl(e,(t,n)=>{if(et(e)&&["arguments","caller","callee"].indexOf(n)!==-1)return!1;const r=e[n];if(et(r)){if(t.enumerable=!1,"writable"in t){t.writable=!1;return}t.set||(t.set=()=>{throw Error("Can not rewrite read-only method '"+n+"'")})}})},e0=(e,t)=>{const n={},r=s=>{s.forEach(o=>{n[o]=!0})};return Mn(e)?r(e):r(String(e).split(t)),n},t0=()=>{},n0=(e,t)=>e!=null&&Number.isFinite(e=+e)?e:t;function r0(e){return!!(e&&et(e.append)&&e[Ul]==="FormData"&&e[ls])}const s0=e=>{const t=new Array(10),n=(r,s)=>{if(hr(r)){if(t.indexOf(r)>=0)return;if(pr(r))return r;if(!("toJSON"in r)){t[s]=r;const o=Mn(r)?[]:{};return mr(r,(i,a)=>{const l=n(i,s+1);!Tn(l)&&(o[a]=l)}),t[s]=void 0,o}}return r};return n(e,0)},o0=mt("AsyncFunction"),i0=e=>e&&(hr(e)||et(e))&&et(e.then)&&et(e.catch),zl=((e,t)=>e?setImmediate:t?((n,r)=>(cn.addEventListener("message",({source:s,data:o})=>{s===cn&&o===n&&r.length&&r.shift()()},!1),s=>{r.push(s),cn.postMessage(n,"*")}))(`axios@${Math.random()}`,[]):n=>setTimeout(n))(typeof setImmediate=="function",et(cn.postMessage)),a0=typeof queueMicrotask<"u"?queueMicrotask.bind(cn):typeof process<"u"&&process.nextTick||zl,l0=e=>e!=null&&et(e[ls]),x={isArray:Mn,isArrayBuffer:ql,isBuffer:pr,isFormData:Nf,isArrayBufferView:Ef,isString:Sf,isNumber:Kl,isBoolean:Af,isObject:hr,isPlainObject:Mr,isEmptyObject:Rf,isReadableStream:Df,isRequest:$f,isResponse:Ff,isHeaders:Vf,isUndefined:Tn,isDate:Tf,isFile:Of,isBlob:Mf,isRegExp:Qf,isFunction:et,isStream:Lf,isURLSearchParams:If,isTypedArray:Zf,isFileList:Pf,forEach:mr,merge:eo,extend:Hf,trim:Bf,stripBOM:jf,inherits:Uf,toFlatObject:qf,kindOf:cs,kindOfTest:mt,endsWith:Kf,toArray:Wf,forEachEntry:Gf,matchAll:zf,isHTMLForm:Jf,hasOwnProperty:Ki,hasOwnProp:Ki,reduceDescriptors:Gl,freezeMethods:Xf,toObjectSet:e0,toCamelCase:Yf,noop:t0,toFiniteNumber:n0,findKey:Wl,global:cn,isContextDefined:Zl,isSpecCompliantForm:r0,toJSONObject:s0,isAsyncFn:o0,isThenable:i0,setImmediate:zl,asap:a0,isIterable:l0};function he(e,t,n,r,s){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack,this.message=e,this.name="AxiosError",t&&(this.code=t),n&&(this.config=n),r&&(this.request=r),s&&(this.response=s,this.status=s.status?s.status:null)}x.inherits(he,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:x.toJSONObject(this.config),code:this.code,status:this.status}}});const Jl=he.prototype,Yl={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach(e=>{Yl[e]={value:e}});Object.defineProperties(he,Yl);Object.defineProperty(Jl,"isAxiosError",{value:!0});he.from=(e,t,n,r,s,o)=>{const i=Object.create(Jl);x.toFlatObject(e,i,function(c){return c!==Error.prototype},u=>u!=="isAxiosError");const a=e&&e.message?e.message:"Error",l=t==null&&e?e.code:t;return he.call(i,a,l,n,r,s),e&&i.cause==null&&Object.defineProperty(i,"cause",{value:e,configurable:!0}),i.name=e&&e.name||"Error",o&&Object.assign(i,o),i};const c0=null;function to(e){return x.isPlainObject(e)||x.isArray(e)}function Ql(e){return x.endsWith(e,"[]")?e.slice(0,-2):e}function Wi(e,t,n){return e?e.concat(t).map(function(s,o){return s=Ql(s),!n&&o?"["+s+"]":s}).join(n?".":""):t}function u0(e){return x.isArray(e)&&!e.some(to)}const d0=x.toFlatObject(x,{},null,function(t){return/^is[A-Z]/.test(t)});function ds(e,t,n){if(!x.isObject(e))throw new TypeError("target must be an object");t=t||new FormData,n=x.toFlatObject(n,{metaTokens:!0,dots:!1,indexes:!1},!1,function(v,w){return!x.isUndefined(w[v])});const r=n.metaTokens,s=n.visitor||c,o=n.dots,i=n.indexes,l=(n.Blob||typeof Blob<"u"&&Blob)&&x.isSpecCompliantForm(t);if(!x.isFunction(s))throw new TypeError("visitor must be a function");function u(h){if(h===null)return"";if(x.isDate(h))return h.toISOString();if(x.isBoolean(h))return h.toString();if(!l&&x.isBlob(h))throw new he("Blob is not supported. Use a Buffer instead.");return x.isArrayBuffer(h)||x.isTypedArray(h)?l&&typeof Blob=="function"?new Blob([h]):Buffer.from(h):h}function c(h,v,w){let S=h;if(h&&!w&&typeof h=="object"){if(x.endsWith(v,"{}"))v=r?v:v.slice(0,-2),h=JSON.stringify(h);else if(x.isArray(h)&&u0(h)||(x.isFileList(h)||x.endsWith(v,"[]"))&&(S=x.toArray(h)))return v=Ql(v),S.forEach(function(R,M){!(x.isUndefined(R)||R===null)&&t.append(i===!0?Wi([v],M,o):i===null?v:v+"[]",u(R))}),!1}return to(h)?!0:(t.append(Wi(w,v,o),u(h)),!1)}const d=[],f=Object.assign(d0,{defaultVisitor:c,convertValue:u,isVisitable:to});function y(h,v){if(!x.isUndefined(h)){if(d.indexOf(h)!==-1)throw Error("Circular reference detected in "+v.join("."));d.push(h),x.forEach(h,function(S,L){(!(x.isUndefined(S)||S===null)&&s.call(t,S,x.isString(L)?L.trim():L,v,f))===!0&&y(S,v?v.concat(L):[L])}),d.pop()}}if(!x.isObject(e))throw new TypeError("data must be an object");return y(e),t}function Zi(e){const t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,function(r){return t[r]})}function Mo(e,t){this._pairs=[],e&&ds(e,this,t)}const Xl=Mo.prototype;Xl.append=function(t,n){this._pairs.push([t,n])};Xl.toString=function(t){const n=t?function(r){return t.call(this,r,Zi)}:Zi;return this._pairs.map(function(s){return n(s[0])+"="+n(s[1])},"").join("&")};function f0(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function ec(e,t,n){if(!t)return e;const r=n&&n.encode||f0;x.isFunction(n)&&(n={serialize:n});const s=n&&n.serialize;let o;if(s?o=s(t,n):o=x.isURLSearchParams(t)?t.toString():new Mo(t,n).toString(r),o){const i=e.indexOf("#");i!==-1&&(e=e.slice(0,i)),e+=(e.indexOf("?")===-1?"?":"&")+o}return e}class Gi{constructor(){this.handlers=[]}use(t,n,r){return this.handlers.push({fulfilled:t,rejected:n,synchronous:r?r.synchronous:!1,runWhen:r?r.runWhen:null}),this.handlers.length-1}eject(t){this.handlers[t]&&(this.handlers[t]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(t){x.forEach(this.handlers,function(r){r!==null&&t(r)})}}const tc={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},p0=typeof URLSearchParams<"u"?URLSearchParams:Mo,h0=typeof FormData<"u"?FormData:null,m0=typeof Blob<"u"?Blob:null,g0={isBrowser:!0,classes:{URLSearchParams:p0,FormData:h0,Blob:m0},protocols:["http","https","file","blob","url","data"]},Po=typeof window<"u"&&typeof document<"u",no=typeof navigator=="object"&&navigator||void 0,y0=Po&&(!no||["ReactNative","NativeScript","NS"].indexOf(no.product)<0),v0=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",b0=Po&&window.location.href||"http://localhost",C0=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:Po,hasStandardBrowserEnv:y0,hasStandardBrowserWebWorkerEnv:v0,navigator:no,origin:b0},Symbol.toStringTag,{value:"Module"})),Ue={...C0,...g0};function _0(e,t){return ds(e,new Ue.classes.URLSearchParams,{visitor:function(n,r,s,o){return Ue.isNode&&x.isBuffer(n)?(this.append(r,n.toString("base64")),!1):o.defaultVisitor.apply(this,arguments)},...t})}function x0(e){return x.matchAll(/\w+|\[(\w*)]/g,e).map(t=>t[0]==="[]"?"":t[1]||t[0])}function w0(e){const t={},n=Object.keys(e);let r;const s=n.length;let o;for(r=0;r=n.length;return i=!i&&x.isArray(s)?s.length:i,l?(x.hasOwnProp(s,i)?s[i]=[s[i],r]:s[i]=r,!a):((!s[i]||!x.isObject(s[i]))&&(s[i]=[]),t(n,r,s[i],o)&&x.isArray(s[i])&&(s[i]=w0(s[i])),!a)}if(x.isFormData(e)&&x.isFunction(e.entries)){const n={};return x.forEachEntry(e,(r,s)=>{t(x0(r),s,n,0)}),n}return null}function k0(e,t,n){if(x.isString(e))try{return(t||JSON.parse)(e),x.trim(e)}catch(r){if(r.name!=="SyntaxError")throw r}return(n||JSON.stringify)(e)}const gr={transitional:tc,adapter:["xhr","http","fetch"],transformRequest:[function(t,n){const r=n.getContentType()||"",s=r.indexOf("application/json")>-1,o=x.isObject(t);if(o&&x.isHTMLForm(t)&&(t=new FormData(t)),x.isFormData(t))return s?JSON.stringify(nc(t)):t;if(x.isArrayBuffer(t)||x.isBuffer(t)||x.isStream(t)||x.isFile(t)||x.isBlob(t)||x.isReadableStream(t))return t;if(x.isArrayBufferView(t))return t.buffer;if(x.isURLSearchParams(t))return n.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),t.toString();let a;if(o){if(r.indexOf("application/x-www-form-urlencoded")>-1)return _0(t,this.formSerializer).toString();if((a=x.isFileList(t))||r.indexOf("multipart/form-data")>-1){const l=this.env&&this.env.FormData;return ds(a?{"files[]":t}:t,l&&new l,this.formSerializer)}}return o||s?(n.setContentType("application/json",!1),k0(t)):t}],transformResponse:[function(t){const n=this.transitional||gr.transitional,r=n&&n.forcedJSONParsing,s=this.responseType==="json";if(x.isResponse(t)||x.isReadableStream(t))return t;if(t&&x.isString(t)&&(r&&!this.responseType||s)){const i=!(n&&n.silentJSONParsing)&&s;try{return JSON.parse(t,this.parseReviver)}catch(a){if(i)throw a.name==="SyntaxError"?he.from(a,he.ERR_BAD_RESPONSE,this,null,this.response):a}}return t}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Ue.classes.FormData,Blob:Ue.classes.Blob},validateStatus:function(t){return t>=200&&t<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};x.forEach(["delete","get","head","post","put","patch"],e=>{gr.headers[e]={}});const E0=x.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),S0=e=>{const t={};let n,r,s;return e&&e.split(` +`).forEach(function(i){s=i.indexOf(":"),n=i.substring(0,s).trim().toLowerCase(),r=i.substring(s+1).trim(),!(!n||t[n]&&E0[n])&&(n==="set-cookie"?t[n]?t[n].push(r):t[n]=[r]:t[n]=t[n]?t[n]+", "+r:r)}),t},zi=Symbol("internals");function Fn(e){return e&&String(e).trim().toLowerCase()}function Pr(e){return e===!1||e==null?e:x.isArray(e)?e.map(Pr):String(e)}function A0(e){const t=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let r;for(;r=n.exec(e);)t[r[1]]=r[2];return t}const R0=e=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim());function Os(e,t,n,r,s){if(x.isFunction(r))return r.call(this,t,n);if(s&&(t=n),!!x.isString(t)){if(x.isString(r))return t.indexOf(r)!==-1;if(x.isRegExp(r))return r.test(t)}}function T0(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(t,n,r)=>n.toUpperCase()+r)}function O0(e,t){const n=x.toCamelCase(" "+t);["get","set","has"].forEach(r=>{Object.defineProperty(e,r+n,{value:function(s,o,i){return this[r].call(this,t,s,o,i)},configurable:!0})})}let tt=class{constructor(t){t&&this.set(t)}set(t,n,r){const s=this;function o(a,l,u){const c=Fn(l);if(!c)throw new Error("header name must be a non-empty string");const d=x.findKey(s,c);(!d||s[d]===void 0||u===!0||u===void 0&&s[d]!==!1)&&(s[d||l]=Pr(a))}const i=(a,l)=>x.forEach(a,(u,c)=>o(u,c,l));if(x.isPlainObject(t)||t instanceof this.constructor)i(t,n);else if(x.isString(t)&&(t=t.trim())&&!R0(t))i(S0(t),n);else if(x.isObject(t)&&x.isIterable(t)){let a={},l,u;for(const c of t){if(!x.isArray(c))throw TypeError("Object iterator must return a key-value pair");a[u=c[0]]=(l=a[u])?x.isArray(l)?[...l,c[1]]:[l,c[1]]:c[1]}i(a,n)}else t!=null&&o(n,t,r);return this}get(t,n){if(t=Fn(t),t){const r=x.findKey(this,t);if(r){const s=this[r];if(!n)return s;if(n===!0)return A0(s);if(x.isFunction(n))return n.call(this,s,r);if(x.isRegExp(n))return n.exec(s);throw new TypeError("parser must be boolean|regexp|function")}}}has(t,n){if(t=Fn(t),t){const r=x.findKey(this,t);return!!(r&&this[r]!==void 0&&(!n||Os(this,this[r],r,n)))}return!1}delete(t,n){const r=this;let s=!1;function o(i){if(i=Fn(i),i){const a=x.findKey(r,i);a&&(!n||Os(r,r[a],a,n))&&(delete r[a],s=!0)}}return x.isArray(t)?t.forEach(o):o(t),s}clear(t){const n=Object.keys(this);let r=n.length,s=!1;for(;r--;){const o=n[r];(!t||Os(this,this[o],o,t,!0))&&(delete this[o],s=!0)}return s}normalize(t){const n=this,r={};return x.forEach(this,(s,o)=>{const i=x.findKey(r,o);if(i){n[i]=Pr(s),delete n[o];return}const a=t?T0(o):String(o).trim();a!==o&&delete n[o],n[a]=Pr(s),r[a]=!0}),this}concat(...t){return this.constructor.concat(this,...t)}toJSON(t){const n=Object.create(null);return x.forEach(this,(r,s)=>{r!=null&&r!==!1&&(n[s]=t&&x.isArray(r)?r.join(", "):r)}),n}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([t,n])=>t+": "+n).join(` +`)}getSetCookie(){return this.get("set-cookie")||[]}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(t){return t instanceof this?t:new this(t)}static concat(t,...n){const r=new this(t);return n.forEach(s=>r.set(s)),r}static accessor(t){const r=(this[zi]=this[zi]={accessors:{}}).accessors,s=this.prototype;function o(i){const a=Fn(i);r[a]||(O0(s,i),r[a]=!0)}return x.isArray(t)?t.forEach(o):o(t),this}};tt.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);x.reduceDescriptors(tt.prototype,({value:e},t)=>{let n=t[0].toUpperCase()+t.slice(1);return{get:()=>e,set(r){this[n]=r}}});x.freezeMethods(tt);function Ms(e,t){const n=this||gr,r=t||n,s=tt.from(r.headers);let o=r.data;return x.forEach(e,function(a){o=a.call(n,o,s.normalize(),t?t.status:void 0)}),s.normalize(),o}function rc(e){return!!(e&&e.__CANCEL__)}function Pn(e,t,n){he.call(this,e??"canceled",he.ERR_CANCELED,t,n),this.name="CanceledError"}x.inherits(Pn,he,{__CANCEL__:!0});function sc(e,t,n){const r=n.config.validateStatus;!n.status||!r||r(n.status)?e(n):t(new he("Request failed with status code "+n.status,[he.ERR_BAD_REQUEST,he.ERR_BAD_RESPONSE][Math.floor(n.status/100)-4],n.config,n.request,n))}function M0(e){const t=/^([-+\w]{1,25})(:?\/\/|:)/.exec(e);return t&&t[1]||""}function P0(e,t){e=e||10;const n=new Array(e),r=new Array(e);let s=0,o=0,i;return t=t!==void 0?t:1e3,function(l){const u=Date.now(),c=r[o];i||(i=u),n[s]=l,r[s]=u;let d=o,f=0;for(;d!==s;)f+=n[d++],d=d%e;if(s=(s+1)%e,s===o&&(o=(o+1)%e),u-i{n=c,s=null,o&&(clearTimeout(o),o=null),e(...u)};return[(...u)=>{const c=Date.now(),d=c-n;d>=r?i(u,c):(s=u,o||(o=setTimeout(()=>{o=null,i(s)},r-d)))},()=>s&&i(s)]}const qr=(e,t,n=3)=>{let r=0;const s=P0(50,250);return L0(o=>{const i=o.loaded,a=o.lengthComputable?o.total:void 0,l=i-r,u=s(l),c=i<=a;r=i;const d={loaded:i,total:a,progress:a?i/a:void 0,bytes:l,rate:u||void 0,estimated:u&&a&&c?(a-i)/u:void 0,event:o,lengthComputable:a!=null,[t?"download":"upload"]:!0};e(d)},n)},Ji=(e,t)=>{const n=e!=null;return[r=>t[0]({lengthComputable:n,total:e,loaded:r}),t[1]]},Yi=e=>(...t)=>x.asap(()=>e(...t)),N0=Ue.hasStandardBrowserEnv?((e,t)=>n=>(n=new URL(n,Ue.origin),e.protocol===n.protocol&&e.host===n.host&&(t||e.port===n.port)))(new URL(Ue.origin),Ue.navigator&&/(msie|trident)/i.test(Ue.navigator.userAgent)):()=>!0,I0=Ue.hasStandardBrowserEnv?{write(e,t,n,r,s,o,i){if(typeof document>"u")return;const a=[`${e}=${encodeURIComponent(t)}`];x.isNumber(n)&&a.push(`expires=${new Date(n).toUTCString()}`),x.isString(r)&&a.push(`path=${r}`),x.isString(s)&&a.push(`domain=${s}`),o===!0&&a.push("secure"),x.isString(i)&&a.push(`SameSite=${i}`),document.cookie=a.join("; ")},read(e){if(typeof document>"u")return null;const t=document.cookie.match(new RegExp("(?:^|; )"+e+"=([^;]*)"));return t?decodeURIComponent(t[1]):null},remove(e){this.write(e,"",Date.now()-864e5,"/")}}:{write(){},read(){return null},remove(){}};function D0(e){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(e)}function $0(e,t){return t?e.replace(/\/?\/$/,"")+"/"+t.replace(/^\/+/,""):e}function oc(e,t,n){let r=!D0(t);return e&&(r||n==!1)?$0(e,t):t}const Qi=e=>e instanceof tt?{...e}:e;function pn(e,t){t=t||{};const n={};function r(u,c,d,f){return x.isPlainObject(u)&&x.isPlainObject(c)?x.merge.call({caseless:f},u,c):x.isPlainObject(c)?x.merge({},c):x.isArray(c)?c.slice():c}function s(u,c,d,f){if(x.isUndefined(c)){if(!x.isUndefined(u))return r(void 0,u,d,f)}else return r(u,c,d,f)}function o(u,c){if(!x.isUndefined(c))return r(void 0,c)}function i(u,c){if(x.isUndefined(c)){if(!x.isUndefined(u))return r(void 0,u)}else return r(void 0,c)}function a(u,c,d){if(d in t)return r(u,c);if(d in e)return r(void 0,u)}const l={url:o,method:o,data:o,baseURL:i,transformRequest:i,transformResponse:i,paramsSerializer:i,timeout:i,timeoutMessage:i,withCredentials:i,withXSRFToken:i,adapter:i,responseType:i,xsrfCookieName:i,xsrfHeaderName:i,onUploadProgress:i,onDownloadProgress:i,decompress:i,maxContentLength:i,maxBodyLength:i,beforeRedirect:i,transport:i,httpAgent:i,httpsAgent:i,cancelToken:i,socketPath:i,responseEncoding:i,validateStatus:a,headers:(u,c,d)=>s(Qi(u),Qi(c),d,!0)};return x.forEach(Object.keys({...e,...t}),function(c){const d=l[c]||s,f=d(e[c],t[c],c);x.isUndefined(f)&&d!==a||(n[c]=f)}),n}const ic=e=>{const t=pn({},e);let{data:n,withXSRFToken:r,xsrfHeaderName:s,xsrfCookieName:o,headers:i,auth:a}=t;if(t.headers=i=tt.from(i),t.url=ec(oc(t.baseURL,t.url,t.allowAbsoluteUrls),e.params,e.paramsSerializer),a&&i.set("Authorization","Basic "+btoa((a.username||"")+":"+(a.password?unescape(encodeURIComponent(a.password)):""))),x.isFormData(n)){if(Ue.hasStandardBrowserEnv||Ue.hasStandardBrowserWebWorkerEnv)i.setContentType(void 0);else if(x.isFunction(n.getHeaders)){const l=n.getHeaders(),u=["content-type","content-length"];Object.entries(l).forEach(([c,d])=>{u.includes(c.toLowerCase())&&i.set(c,d)})}}if(Ue.hasStandardBrowserEnv&&(r&&x.isFunction(r)&&(r=r(t)),r||r!==!1&&N0(t.url))){const l=s&&o&&I0.read(o);l&&i.set(s,l)}return t},F0=typeof XMLHttpRequest<"u",V0=F0&&function(e){return new Promise(function(n,r){const s=ic(e);let o=s.data;const i=tt.from(s.headers).normalize();let{responseType:a,onUploadProgress:l,onDownloadProgress:u}=s,c,d,f,y,h;function v(){y&&y(),h&&h(),s.cancelToken&&s.cancelToken.unsubscribe(c),s.signal&&s.signal.removeEventListener("abort",c)}let w=new XMLHttpRequest;w.open(s.method.toUpperCase(),s.url,!0),w.timeout=s.timeout;function S(){if(!w)return;const R=tt.from("getAllResponseHeaders"in w&&w.getAllResponseHeaders()),F={data:!a||a==="text"||a==="json"?w.responseText:w.response,status:w.status,statusText:w.statusText,headers:R,config:e,request:w};sc(function(q){n(q),v()},function(q){r(q),v()},F),w=null}"onloadend"in w?w.onloadend=S:w.onreadystatechange=function(){!w||w.readyState!==4||w.status===0&&!(w.responseURL&&w.responseURL.indexOf("file:")===0)||setTimeout(S)},w.onabort=function(){w&&(r(new he("Request aborted",he.ECONNABORTED,e,w)),w=null)},w.onerror=function(M){const F=M&&M.message?M.message:"Network Error",Y=new he(F,he.ERR_NETWORK,e,w);Y.event=M||null,r(Y),w=null},w.ontimeout=function(){let M=s.timeout?"timeout of "+s.timeout+"ms exceeded":"timeout exceeded";const F=s.transitional||tc;s.timeoutErrorMessage&&(M=s.timeoutErrorMessage),r(new he(M,F.clarifyTimeoutError?he.ETIMEDOUT:he.ECONNABORTED,e,w)),w=null},o===void 0&&i.setContentType(null),"setRequestHeader"in w&&x.forEach(i.toJSON(),function(M,F){w.setRequestHeader(F,M)}),x.isUndefined(s.withCredentials)||(w.withCredentials=!!s.withCredentials),a&&a!=="json"&&(w.responseType=s.responseType),u&&([f,h]=qr(u,!0),w.addEventListener("progress",f)),l&&w.upload&&([d,y]=qr(l),w.upload.addEventListener("progress",d),w.upload.addEventListener("loadend",y)),(s.cancelToken||s.signal)&&(c=R=>{w&&(r(!R||R.type?new Pn(null,e,w):R),w.abort(),w=null)},s.cancelToken&&s.cancelToken.subscribe(c),s.signal&&(s.signal.aborted?c():s.signal.addEventListener("abort",c)));const L=M0(s.url);if(L&&Ue.protocols.indexOf(L)===-1){r(new he("Unsupported protocol "+L+":",he.ERR_BAD_REQUEST,e));return}w.send(o||null)})},B0=(e,t)=>{const{length:n}=e=e?e.filter(Boolean):[];if(t||n){let r=new AbortController,s;const o=function(u){if(!s){s=!0,a();const c=u instanceof Error?u:this.reason;r.abort(c instanceof he?c:new Pn(c instanceof Error?c.message:c))}};let i=t&&setTimeout(()=>{i=null,o(new he(`timeout ${t} of ms exceeded`,he.ETIMEDOUT))},t);const a=()=>{e&&(i&&clearTimeout(i),i=null,e.forEach(u=>{u.unsubscribe?u.unsubscribe(o):u.removeEventListener("abort",o)}),e=null)};e.forEach(u=>u.addEventListener("abort",o));const{signal:l}=r;return l.unsubscribe=()=>x.asap(a),l}},H0=function*(e,t){let n=e.byteLength;if(n{const s=j0(e,t);let o=0,i,a=l=>{i||(i=!0,r&&r(l))};return new ReadableStream({async pull(l){try{const{done:u,value:c}=await s.next();if(u){a(),l.close();return}let d=c.byteLength;if(n){let f=o+=d;n(f)}l.enqueue(new Uint8Array(c))}catch(u){throw a(u),u}},cancel(l){return a(l),s.return()}},{highWaterMark:2})},ea=64*1024,{isFunction:wr}=x,q0=(({Request:e,Response:t})=>({Request:e,Response:t}))(x.global),{ReadableStream:ta,TextEncoder:na}=x.global,ra=(e,...t)=>{try{return!!e(...t)}catch{return!1}},K0=e=>{e=x.merge.call({skipUndefined:!0},q0,e);const{fetch:t,Request:n,Response:r}=e,s=t?wr(t):typeof fetch=="function",o=wr(n),i=wr(r);if(!s)return!1;const a=s&&wr(ta),l=s&&(typeof na=="function"?(h=>v=>h.encode(v))(new na):async h=>new Uint8Array(await new n(h).arrayBuffer())),u=o&&a&&ra(()=>{let h=!1;const v=new n(Ue.origin,{body:new ta,method:"POST",get duplex(){return h=!0,"half"}}).headers.has("Content-Type");return h&&!v}),c=i&&a&&ra(()=>x.isReadableStream(new r("").body)),d={stream:c&&(h=>h.body)};s&&["text","arrayBuffer","blob","formData","stream"].forEach(h=>{!d[h]&&(d[h]=(v,w)=>{let S=v&&v[h];if(S)return S.call(v);throw new he(`Response type '${h}' is not supported`,he.ERR_NOT_SUPPORT,w)})});const f=async h=>{if(h==null)return 0;if(x.isBlob(h))return h.size;if(x.isSpecCompliantForm(h))return(await new n(Ue.origin,{method:"POST",body:h}).arrayBuffer()).byteLength;if(x.isArrayBufferView(h)||x.isArrayBuffer(h))return h.byteLength;if(x.isURLSearchParams(h)&&(h=h+""),x.isString(h))return(await l(h)).byteLength},y=async(h,v)=>{const w=x.toFiniteNumber(h.getContentLength());return w??f(v)};return async h=>{let{url:v,method:w,data:S,signal:L,cancelToken:R,timeout:M,onDownloadProgress:F,onUploadProgress:Y,responseType:q,headers:X,withCredentials:N="same-origin",fetchOptions:Q}=ic(h),j=t||fetch;q=q?(q+"").toLowerCase():"text";let _=B0([L,R&&R.toAbortSignal()],M),K=null;const I=_&&_.unsubscribe&&(()=>{_.unsubscribe()});let ne;try{if(Y&&u&&w!=="get"&&w!=="head"&&(ne=await y(X,S))!==0){let re=new n(v,{method:"POST",body:S,duplex:"half"}),ve;if(x.isFormData(S)&&(ve=re.headers.get("content-type"))&&X.setContentType(ve),re.body){const[Ye,me]=Ji(ne,qr(Yi(Y)));S=Xi(re.body,ea,Ye,me)}}x.isString(N)||(N=N?"include":"omit");const se=o&&"credentials"in n.prototype,ee={...Q,signal:_,method:w.toUpperCase(),headers:X.normalize().toJSON(),body:S,duplex:"half",credentials:se?N:void 0};K=o&&new n(v,ee);let te=await(o?j(K,Q):j(v,ee));const pe=c&&(q==="stream"||q==="response");if(c&&(F||pe&&I)){const re={};["status","statusText","headers"].forEach(G=>{re[G]=te[G]});const ve=x.toFiniteNumber(te.headers.get("content-length")),[Ye,me]=F&&Ji(ve,qr(Yi(F),!0))||[];te=new r(Xi(te.body,ea,Ye,()=>{me&&me(),I&&I()}),re)}q=q||"text";let H=await d[x.findKey(d,q)||"text"](te,h);return!pe&&I&&I(),await new Promise((re,ve)=>{sc(re,ve,{data:H,headers:tt.from(te.headers),status:te.status,statusText:te.statusText,config:h,request:K})})}catch(se){throw I&&I(),se&&se.name==="TypeError"&&/Load failed|fetch/i.test(se.message)?Object.assign(new he("Network Error",he.ERR_NETWORK,h,K),{cause:se.cause||se}):he.from(se,se&&se.code,h,K)}}},W0=new Map,ac=e=>{let t=e&&e.env||{};const{fetch:n,Request:r,Response:s}=t,o=[r,s,n];let i=o.length,a=i,l,u,c=W0;for(;a--;)l=o[a],u=c.get(l),u===void 0&&c.set(l,u=a?new Map:K0(t)),c=u;return u};ac();const Lo={http:c0,xhr:V0,fetch:{get:ac}};x.forEach(Lo,(e,t)=>{if(e){try{Object.defineProperty(e,"name",{value:t})}catch{}Object.defineProperty(e,"adapterName",{value:t})}});const sa=e=>`- ${e}`,Z0=e=>x.isFunction(e)||e===null||e===!1;function G0(e,t){e=x.isArray(e)?e:[e];const{length:n}=e;let r,s;const o={};for(let i=0;i`adapter ${l} `+(u===!1?"is not supported by the environment":"is not available in the build"));let a=n?i.length>1?`since : +`+i.map(sa).join(` +`):" "+sa(i[0]):"as no adapter specified";throw new he("There is no suitable adapter to dispatch the request "+a,"ERR_NOT_SUPPORT")}return s}const lc={getAdapter:G0,adapters:Lo};function Ps(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new Pn(null,e)}function oa(e){return Ps(e),e.headers=tt.from(e.headers),e.data=Ms.call(e,e.transformRequest),["post","put","patch"].indexOf(e.method)!==-1&&e.headers.setContentType("application/x-www-form-urlencoded",!1),lc.getAdapter(e.adapter||gr.adapter,e)(e).then(function(r){return Ps(e),r.data=Ms.call(e,e.transformResponse,r),r.headers=tt.from(r.headers),r},function(r){return rc(r)||(Ps(e),r&&r.response&&(r.response.data=Ms.call(e,e.transformResponse,r.response),r.response.headers=tt.from(r.response.headers))),Promise.reject(r)})}const cc="1.13.2",fs={};["object","boolean","number","function","string","symbol"].forEach((e,t)=>{fs[e]=function(r){return typeof r===e||"a"+(t<1?"n ":" ")+e}});const ia={};fs.transitional=function(t,n,r){function s(o,i){return"[Axios v"+cc+"] Transitional option '"+o+"'"+i+(r?". "+r:"")}return(o,i,a)=>{if(t===!1)throw new he(s(i," has been removed"+(n?" in "+n:"")),he.ERR_DEPRECATED);return n&&!ia[i]&&(ia[i]=!0,console.warn(s(i," has been deprecated since v"+n+" and will be removed in the near future"))),t?t(o,i,a):!0}};fs.spelling=function(t){return(n,r)=>(console.warn(`${r} is likely a misspelling of ${t}`),!0)};function z0(e,t,n){if(typeof e!="object")throw new he("options must be an object",he.ERR_BAD_OPTION_VALUE);const r=Object.keys(e);let s=r.length;for(;s-- >0;){const o=r[s],i=t[o];if(i){const a=e[o],l=a===void 0||i(a,o,e);if(l!==!0)throw new he("option "+o+" must be "+l,he.ERR_BAD_OPTION_VALUE);continue}if(n!==!0)throw new he("Unknown option "+o,he.ERR_BAD_OPTION)}}const Lr={assertOptions:z0,validators:fs},Ct=Lr.validators;let fn=class{constructor(t){this.defaults=t||{},this.interceptors={request:new Gi,response:new Gi}}async request(t,n){try{return await this._request(t,n)}catch(r){if(r instanceof Error){let s={};Error.captureStackTrace?Error.captureStackTrace(s):s=new Error;const o=s.stack?s.stack.replace(/^.+\n/,""):"";try{r.stack?o&&!String(r.stack).endsWith(o.replace(/^.+\n.+\n/,""))&&(r.stack+=` +`+o):r.stack=o}catch{}}throw r}}_request(t,n){typeof t=="string"?(n=n||{},n.url=t):n=t||{},n=pn(this.defaults,n);const{transitional:r,paramsSerializer:s,headers:o}=n;r!==void 0&&Lr.assertOptions(r,{silentJSONParsing:Ct.transitional(Ct.boolean),forcedJSONParsing:Ct.transitional(Ct.boolean),clarifyTimeoutError:Ct.transitional(Ct.boolean)},!1),s!=null&&(x.isFunction(s)?n.paramsSerializer={serialize:s}:Lr.assertOptions(s,{encode:Ct.function,serialize:Ct.function},!0)),n.allowAbsoluteUrls!==void 0||(this.defaults.allowAbsoluteUrls!==void 0?n.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:n.allowAbsoluteUrls=!0),Lr.assertOptions(n,{baseUrl:Ct.spelling("baseURL"),withXsrfToken:Ct.spelling("withXSRFToken")},!0),n.method=(n.method||this.defaults.method||"get").toLowerCase();let i=o&&x.merge(o.common,o[n.method]);o&&x.forEach(["delete","get","head","post","put","patch","common"],h=>{delete o[h]}),n.headers=tt.concat(i,o);const a=[];let l=!0;this.interceptors.request.forEach(function(v){typeof v.runWhen=="function"&&v.runWhen(n)===!1||(l=l&&v.synchronous,a.unshift(v.fulfilled,v.rejected))});const u=[];this.interceptors.response.forEach(function(v){u.push(v.fulfilled,v.rejected)});let c,d=0,f;if(!l){const h=[oa.bind(this),void 0];for(h.unshift(...a),h.push(...u),f=h.length,c=Promise.resolve(n);d{if(!r._listeners)return;let o=r._listeners.length;for(;o-- >0;)r._listeners[o](s);r._listeners=null}),this.promise.then=s=>{let o;const i=new Promise(a=>{r.subscribe(a),o=a}).then(s);return i.cancel=function(){r.unsubscribe(o)},i},t(function(o,i,a){r.reason||(r.reason=new Pn(o,i,a),n(r.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(t){if(this.reason){t(this.reason);return}this._listeners?this._listeners.push(t):this._listeners=[t]}unsubscribe(t){if(!this._listeners)return;const n=this._listeners.indexOf(t);n!==-1&&this._listeners.splice(n,1)}toAbortSignal(){const t=new AbortController,n=r=>{t.abort(r)};return this.subscribe(n),t.signal.unsubscribe=()=>this.unsubscribe(n),t.signal}static source(){let t;return{token:new uc(function(s){t=s}),cancel:t}}};function Y0(e){return function(n){return e.apply(null,n)}}function Q0(e){return x.isObject(e)&&e.isAxiosError===!0}const ro={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511,WebServerIsDown:521,ConnectionTimedOut:522,OriginIsUnreachable:523,TimeoutOccurred:524,SslHandshakeFailed:525,InvalidSslCertificate:526};Object.entries(ro).forEach(([e,t])=>{ro[t]=e});function dc(e){const t=new fn(e),n=jl(fn.prototype.request,t);return x.extend(n,fn.prototype,t,{allOwnKeys:!0}),x.extend(n,t,null,{allOwnKeys:!0}),n.create=function(s){return dc(pn(e,s))},n}const Me=dc(gr);Me.Axios=fn;Me.CanceledError=Pn;Me.CancelToken=J0;Me.isCancel=rc;Me.VERSION=cc;Me.toFormData=ds;Me.AxiosError=he;Me.Cancel=Me.CanceledError;Me.all=function(t){return Promise.all(t)};Me.spread=Y0;Me.isAxiosError=Q0;Me.mergeConfig=pn;Me.AxiosHeaders=tt;Me.formToJSON=e=>nc(x.isHTMLForm(e)?new FormData(e):e);Me.getAdapter=lc.getAdapter;Me.HttpStatusCode=ro;Me.default=Me;const{Axios:kh,AxiosError:Eh,CanceledError:Sh,isCancel:Ah,CancelToken:Rh,VERSION:Th,all:Oh,Cancel:Mh,isAxiosError:Ph,spread:Lh,toFormData:Nh,AxiosHeaders:Ih,HttpStatusCode:Dh,formToJSON:$h,getAdapter:Fh,mergeConfig:Vh}=Me,No="pymc_jwt_token",aa="pymc_client_id";function fc(){let e=localStorage.getItem(aa);return e||(e=`${Date.now()}-${Math.random().toString(36).substring(2,15)}`,localStorage.setItem(aa,e)),e}function Xt(){return localStorage.getItem(No)}function X0(e){localStorage.setItem(No,e)}function mn(){localStorage.removeItem(No)}function pc(){return Xt()!==null}function Io(e){try{const n=e.split(".")[1].replace(/-/g,"+").replace(/_/g,"/"),r=decodeURIComponent(atob(n).split("").map(s=>"%"+("00"+s.charCodeAt(0).toString(16)).slice(-2)).join(""));return JSON.parse(r)}catch{return null}}function hc(){const e=Xt();if(!e)return!0;const t=Io(e);return!t||!t.exp?!0:Date.now()>=t.exp*1e3-3e4}function mc(){const e=Xt();if(!e)return!1;const t=Io(e);if(!t||!t.exp)return!1;const n=t.exp*1e3-Date.now();return n>0&&n<3e5}function e2(){const e=Xt();if(!e)return null;const t=Io(e);return!t||!t.sub?null:t.sub}const t2="modulepreload",n2=function(e){return"/"+e},la={},We=function(t,n,r){let s=Promise.resolve();if(n&&n.length>0){let l=function(u){return Promise.all(u.map(c=>Promise.resolve(c).then(d=>({status:"fulfilled",value:d}),d=>({status:"rejected",reason:d}))))};document.getElementsByTagName("link");const i=document.querySelector("meta[property=csp-nonce]"),a=i?.nonce||i?.getAttribute("nonce");s=l(n.map(u=>{if(u=n2(u),u in la)return;la[u]=!0;const c=u.endsWith(".css"),d=c?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${u}"]${d}`))return;const f=document.createElement("link");if(f.rel=c?"stylesheet":t2,c||(f.as="script"),f.crossOrigin="",f.href=u,a&&f.setAttribute("nonce",a),document.head.appendChild(f),c)return new Promise((y,h)=>{f.addEventListener("load",y),f.addEventListener("error",()=>h(new Error(`Unable to preload CSS for ${u}`)))})}))}function o(i){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=i,window.dispatchEvent(a),!a.defaultPrevented)throw i}return s.then(i=>{for(const a of i||[])a.status==="rejected"&&o(a.reason);return t().catch(o)})},$t=xf({history:Xd("/"),routes:[{path:"/setup",name:"setup",component:()=>We(()=>import("./Setup-CLJIlSKT.js"),__vite__mapDeps([0,1])),meta:{requiresAuth:!1,requiresSetup:!1}},{path:"/login",name:"login",component:()=>We(()=>import("./Login-BwBtx78C.js"),__vite__mapDeps([2,3])),meta:{requiresAuth:!1}},{path:"/",name:"dashboard",component:()=>We(()=>import("./Dashboard-BqBlpKE8.js"),__vite__mapDeps([4,5,6,7,8])),meta:{requiresAuth:!0}},{path:"/neighbors",name:"neighbors",component:()=>We(()=>import("./Neighbors-CXfm_tfh.js"),__vite__mapDeps([9,6,10,11,7,12,13])),meta:{requiresAuth:!0}},{path:"/statistics",name:"statistics",component:()=>We(()=>import("./Statistics-JA9qMWm0.js"),__vite__mapDeps([14,15,5,16,7,17,11,18])),meta:{requiresAuth:!0}},{path:"/system-stats",name:"system-stats",component:()=>We(()=>import("./SystemStats-DiLdS6K6.js"),__vite__mapDeps([19,15,5,16,20])),meta:{requiresAuth:!0}},{path:"/configuration",name:"configuration",component:()=>We(()=>import("./Configuration-CkZchTBn.js"),__vite__mapDeps([21,22,7,23,13])),meta:{requiresAuth:!0}},{path:"/cad-calibration",name:"cad-calibration",component:()=>We(()=>import("./CADCalibration-Dc9AglAf.js"),__vite__mapDeps([24,17,11,25])),meta:{requiresAuth:!0}},{path:"/sessions",name:"sessions",component:()=>We(()=>import("./Sessions-C6MSx2tq.js"),[]),meta:{requiresAuth:!0}},{path:"/room-servers",name:"room-servers",component:()=>We(()=>import("./RoomServers-V3porqGE.js"),__vite__mapDeps([26,7,22,27])),meta:{requiresAuth:!0}},{path:"/companions",name:"companions",component:()=>We(()=>import("./Companions-X5GdqESj.js"),__vite__mapDeps([28,22,27])),meta:{requiresAuth:!0}},{path:"/logs",name:"logs",component:()=>We(()=>import("./Logs-DXaq6_-G.js"),[]),meta:{requiresAuth:!0}},{path:"/terminal",name:"terminal",component:()=>We(()=>import("./Terminal-BAoTtMQy.js"),__vite__mapDeps([29,30])),meta:{requiresAuth:!0}},{path:"/help",name:"help",component:()=>We(()=>import("./Help-DWQEtjHZ.js"),[]),meta:{requiresAuth:!0}}]});async function ca(){try{const e=await fetch("/api/needs_setup",{headers:{Accept:"application/json"}});if(!e.ok)return console.error("Setup check failed:",e.status),!1;const t=await e.json();return console.log("Setup status check:",t),t.needs_setup===!0}catch(e){return console.error("Error checking setup status:",e),!1}}$t.beforeEach(async(e,t,n)=>{const r=e.meta.requiresAuth!==!1,s=pc();if(e.path!=="/setup"&&await ca()){n("/setup");return}if(e.path==="/setup"&&!await ca()){n("/login");return}r&&!s?n("/login"):e.path==="/login"&&s?n("/"):n()});const r2="/api",Kr="";let Ls=!1,Vn=null;async function gc(){return Ls&&Vn||(Ls=!0,Vn=(async()=>{try{const e=Xt();if(!e)throw new Error("No token to refresh");const t=fc(),n=await Me.post(`${Kr}/auth/refresh`,{client_id:t},{headers:{Authorization:`Bearer ${e}`,"Content-Type":"application/json"}});if(n.data.success&&n.data.token){const r=n.data.token;return X0(r),console.log("Token refreshed successfully"),r}else throw new Error("Token refresh failed")}catch(e){throw console.error("Token refresh error:",e),mn(),$t.push("/login"),e}finally{Ls=!1,Vn=null}})()),Vn}const an=Me.create({baseURL:r2,timeout:5e3,headers:{"Content-Type":"application/json"}}),yc=Me.create({baseURL:Kr,timeout:5e3,headers:{"Content-Type":"application/json"}});yc.interceptors.request.use(async e=>{if(e.url?.includes("/auth/login")||e.url?.includes("/auth/refresh"))return e;const t=Xt();if(t){if(mc())try{const n=await gc();return e.headers.Authorization=`Bearer ${n}`,e}catch(n){return Promise.reject(n)}if(hc())return mn(),$t.push("/login"),Promise.reject(new Error("Token expired"));e.headers.Authorization=`Bearer ${t}`}return e},e=>(console.error("Auth API Request Error:",e),Promise.reject(e)));yc.interceptors.response.use(e=>e,e=>(e.response?.status===401&&(mn(),$t.currentRoute.value.path!=="/login"&&$t.push("/login")),console.error("Auth API Response Error:",e.response?.data||e.message),Promise.reject(e)));an.interceptors.request.use(async e=>{if(e.url?.includes("/auth/login"))return e;const t=Xt();if(t){if(mc())try{const n=await gc();return e.headers.Authorization=`Bearer ${n}`,e}catch(n){return Promise.reject(n)}if(hc())return mn(),$t.push("/login"),Promise.reject(new Error("Token expired"));e.headers.Authorization=`Bearer ${t}`}return e},e=>(console.error("API Request Error:",e),Promise.reject(e)));an.interceptors.response.use(e=>e,e=>(e.response?.status===401&&(mn(),$t.currentRoute.value.path!=="/login"&&$t.push("/login")),console.error("API Response Error:",e.response?.data||e.message),Promise.reject(e)));class Fe{static async get(t,n){try{return(await an.get(t,{params:n})).data}catch(r){throw this.handleError(r)}}static async post(t,n,r){try{return(await an.post(t,n,r)).data}catch(s){throw this.handleError(s)}}static async put(t,n,r){try{return(await an.put(t,n,r)).data}catch(s){throw this.handleError(s)}}static async delete(t,n){try{return(await an.delete(t,n)).data}catch(r){throw this.handleError(r)}}static async getTransportKeys(){return this.get("transport_keys")}static async sendAdvert(){return this.post("send_advert",{},{headers:{"Content-Type":"application/json"}})}static async createTransportKey(t,n,r,s,o){const i={name:t,flood_policy:n,parent_id:s,last_used:o};return r!==void 0&&(i.transport_key=r),this.post("transport_keys",i)}static async getTransportKey(t){return this.get(`transport_key/${t}`)}static async updateTransportKey(t,n,r,s,o,i){return this.put(`transport_key/${t}`,{name:n,flood_policy:r,transport_key:s,parent_id:o,last_used:i})}static async deleteTransportKey(t){return this.delete(`transport_key/${t}`)}static async updateGlobalFloodPolicy(t){return this.post("global_flood_policy",{global_flood_allow:t})}static async getLogs(){try{return(await an.get("logs")).data}catch(t){throw this.handleError(t)}}static async deleteAdvert(t){return this.delete(`advert/${t}`)}static async pingNeighbor(t,n=10){return this.post("ping_neighbor",{target_id:t,timeout:n})}static async getIdentities(){return this.get("identities")}static async getIdentity(t){return this.get("identity",{name:t})}static async createIdentity(t){return this.post("create_identity",t)}static async updateIdentity(t){return this.put("update_identity",t)}static async deleteIdentity(t,n="room_server"){const r=new URLSearchParams({name:t});return n==="companion"&&r.set("type","companion"),this.delete(`delete_identity?${r.toString()}`)}static async sendRoomServerAdvert(t){return this.post("send_room_server_advert",{name:t})}static async getACLInfo(){return this.get("acl_info")}static async getACLClients(t){return this.get("acl_clients",t)}static async removeACLClient(t){return this.post("acl_remove_client",t)}static async getACLStats(){return this.get("acl_stats")}static async getRoomMessages(t){return this.get("room_messages",t)}static async postRoomMessage(t){return this.post("room_post_message",t)}static async deleteRoomMessage(t){return this.delete(`room_message?room_name=${encodeURIComponent(t.room_name)}&message_id=${t.message_id}`)}static async clearRoomMessages(t){return this.delete(`room_messages?room_name=${encodeURIComponent(t)}`)}static async getRoomStats(t){return this.get("room_stats",t?{room_name:t}:void 0)}static async getRoomClients(t){return this.get("room_clients",{room_name:t})}static handleError(t){if(Me.isAxiosError(t)){if(t.response){const n=t.response.data?.error||t.response.data?.message||`HTTP ${t.response.status}`;return new Error(n)}else if(t.request)return new Error("Network error - no response received")}return new Error(t instanceof Error?t.message:"Unknown error occurred")}}const yr=Eo("system",()=>{const e=Z(null),t=Z(!1),n=Z(null),r=Z(null),s=Z("forward"),o=Z(!0),i=Z(0),a=Z(10),l=Z(!1),u=oe(()=>e.value?.config?.node_name??"Unknown"),c=oe(()=>{const I=e.value?.public_key;return!I||I==="Unknown"?"Unknown":I.length>=16?`${I.slice(0,8)} ... ${I.slice(-8)}`:`${I}`}),d=oe(()=>e.value!==null),f=oe(()=>e.value?.version??"Unknown"),y=oe(()=>e.value?.core_version??"Unknown"),h=oe(()=>e.value?.noise_floor_dbm??null),v=oe(()=>a.value>0?Math.min(i.value/a.value*100,100):0),w=oe(()=>s.value==="monitor"?{text:"Monitor Mode",title:"Monitoring only - not forwarding packets"}:o.value?{text:"Active",title:"Forwarding with duty cycle enforcement"}:{text:"No Limits",title:"Forwarding without duty cycle enforcement"}),S=oe(()=>s.value==="monitor"?{active:!1,warning:!0}:{active:!0,warning:!1}),L=oe(()=>o.value?{active:!0,warning:!1}:{active:!1,warning:!0}),R=I=>{l.value=I};async function M(){try{t.value=!0,n.value=null;const I=await Fe.get("/stats");if(I.success&&I.data)return e.value=I.data,r.value=new Date,F(I.data),I.data;if(I&&"version"in I){const ne=I;return e.value=ne,r.value=new Date,F(ne),ne}else throw new Error(I.error||"Failed to fetch stats")}catch(I){throw n.value=I instanceof Error?I.message:"Unknown error occurred",console.error("Error fetching stats:",I),I}finally{t.value=!1}}function F(I){if(I.config){const se=I.config.repeater?.mode;(se==="forward"||se==="monitor")&&(s.value=se);const ee=I.config.duty_cycle;if(ee){o.value=ee.enforcement_enabled!==!1;const te=ee.max_airtime_percent;typeof te=="number"?a.value=te:te&&typeof te=="object"&&"parsedValue"in te&&(a.value=te.parsedValue||10)}}const ne=I.utilization_percent;typeof ne=="number"?i.value=ne:ne&&typeof ne=="object"&&"parsedValue"in ne&&(i.value=ne.parsedValue||0)}async function Y(I){try{const ne=await Fe.post("/set_mode",{mode:I});if(ne.success)return s.value=I,!0;throw new Error(ne.error||"Failed to set mode")}catch(ne){throw n.value=ne instanceof Error?ne.message:"Unknown error occurred",console.error("Error setting mode:",ne),ne}}async function q(I){try{const ne=await Fe.post("/set_duty_cycle",{enabled:I});if(ne.success)return o.value=I,!0;throw new Error(ne.error||"Failed to set duty cycle")}catch(ne){throw n.value=ne instanceof Error?ne.message:"Unknown error occurred",console.error("Error setting duty cycle:",ne),ne}}async function X(){try{const I=await Fe.post("/send_advert",{},{timeout:1e4});if(I.success)return console.log("Advertisement sent successfully:",I.data),!0;throw new Error(I.error||"Failed to send advert")}catch(I){throw n.value=I instanceof Error?I.message:"Unknown error occurred",console.error("Error sending advert:",I),I}}async function N(){const I=s.value==="forward"?"monitor":"forward";return await Y(I)}async function Q(){return await q(!o.value)}function j(I){e.value?(I.uptime_seconds!==void 0&&(e.value.uptime_seconds=I.uptime_seconds),I.noise_floor_dbm!==void 0&&(e.value.noise_floor_dbm=I.noise_floor_dbm)):e.value=I,r.value=new Date,F(I)}async function _(I=5e3,ne=!1){ne||await M();let se=null;return ne||(se=setInterval(async()=>{try{await M()}catch(ee){console.error("Auto-refresh error:",ee)}},I)),()=>{se&&clearInterval(se)}}function K(){e.value=null,n.value=null,r.value=null,t.value=!1,s.value="forward",o.value=!0,i.value=0,a.value=10}return{stats:e,isLoading:t,error:n,lastUpdated:r,currentMode:s,dutyCycleEnabled:o,dutyCycleUtilization:i,dutyCycleMax:a,cadCalibrationRunning:l,nodeName:u,pubKey:c,hasStats:d,version:f,coreVersion:y,noiseFloorDbm:h,dutyCyclePercentage:v,statusBadge:w,modeButtonState:S,dutyCycleButtonState:L,fetchStats:M,setMode:Y,setDutyCycle:q,sendAdvert:X,toggleMode:N,toggleDutyCycle:Q,startAutoRefresh:_,updateRealtimeStats:j,reset:K,setCadCalibrationRunning:R}}),vc=Eo("packets",()=>{const e=Z(null),t=Z(null),n=Z([]),r=Z([]),s=Z(null),o=Z(!1),i=Z(null),a=Z(null),l=Z([]),u=Z([]),c=Z(null),d=Z(0),f=Z([]),y=Z({rx:0,tx:0,drop:0}),h=Z({rx:0,tx:0,drop:0}),v=oe(()=>e.value!==null),w=oe(()=>t.value!==null),S=oe(()=>n.value.length>0),L=oe(()=>r.value.length>0),R=oe(()=>s.value?.avg_noise_floor??0),M=oe(()=>e.value?.total_packets??0),F=oe(()=>e.value?.avg_rssi??0),Y=oe(()=>e.value?.avg_snr??0),q=oe(()=>t.value?.uptime_seconds??0),X=oe(()=>{if(!e.value?.packet_types)return[];const k=e.value.packet_types,C=k.reduce((V,ye)=>V+ye.count,0);return k.map(V=>({type:V.type.toString(),count:V.count,percentage:C>0?V.count/C*100:0}))}),N=oe(()=>{const k={};return n.value.forEach(C=>{k[C.type]||(k[C.type]=[]),k[C.type].push(C)}),k});async function Q(){try{const k=await Fe.get("/stats");if(k.success&&k.data){t.value=k.data;const C=new Date;return u.value.push({timestamp:C,stats:k.data}),u.value.length>50&&(u.value=u.value.slice(-50)),k.data}else if(k&&"version"in k){const C=k;t.value=C;const V=new Date;return u.value.push({timestamp:V,stats:C}),u.value.length>50&&(u.value=u.value.slice(-50)),C}else throw new Error(k.error||"Failed to fetch system stats")}catch(k){throw i.value=k instanceof Error?k.message:"Unknown error occurred",console.error("Error fetching system stats:",k),k}}async function j(k={hours:24}){try{const C=await Fe.get("/noise_floor_history",k);if(C.success&&C.data&&C.data.history)return r.value=C.data.history,a.value=new Date,C.data.history;throw new Error(C.error||"Failed to fetch noise floor history")}catch(C){throw i.value=C instanceof Error?C.message:"Unknown error occurred",console.error("Error fetching noise floor history:",C),C}}async function _(k={hours:24}){try{const C=await Fe.get("/noise_floor_stats",k);if(C.success&&C.data&&C.data.stats)return s.value=C.data.stats,a.value=new Date,C.data.stats;throw new Error(C.error||"Failed to fetch noise floor stats")}catch(C){throw i.value=C instanceof Error?C.message:"Unknown error occurred",console.error("Error fetching noise floor stats:",C),C}}const K=oe(()=>!r.value||!Array.isArray(r.value)?[]:r.value.slice(-50).map(k=>k.noise_floor_dbm));async function I(k={hours:24}){try{o.value=!0,i.value=null;const C=await Fe.get("/packet_stats",k);if(C.success&&C.data){e.value=C.data;const V=new Date;l.value.push({timestamp:V,stats:C.data}),l.value.length>50&&(l.value=l.value.slice(-50)),a.value=V}else throw new Error(C.error||"Failed to fetch packet stats")}catch(C){i.value=C instanceof Error?C.message:"Unknown error occurred",console.error("Error fetching packet stats:",C)}finally{o.value=!1}}async function ne(k={limit:100}){try{o.value=!0,i.value=null;const C=await Fe.get("/recent_packets",k);if(C.success&&C.data)n.value=C.data,a.value=new Date;else throw new Error(C.error||"Failed to fetch recent packets")}catch(C){i.value=C instanceof Error?C.message:"Unknown error occurred",console.error("Error fetching recent packets:",C)}finally{o.value=!1}}async function se(k){try{o.value=!0,i.value=null;const C=await Fe.get("/filtered_packets",k);if(C.success&&C.data)return n.value=C.data,a.value=new Date,C.data;throw new Error(C.error||"Failed to fetch filtered packets")}catch(C){throw i.value=C instanceof Error?C.message:"Unknown error occurred",console.error("Error fetching filtered packets:",C),C}finally{o.value=!1}}async function ee(k){try{o.value=!0,i.value=null;const C=await Fe.get("/packet_by_hash",{packet_hash:k});if(C.success&&C.data)return C.data;throw new Error(C.error||"Packet not found")}catch(C){throw i.value=C instanceof Error?C.message:"Unknown error occurred",console.error("Error fetching packet by hash:",C),C}finally{o.value=!1}}const te=oe(()=>{if(!c.value?.series)return{totalPackets:[],transmittedPackets:[],droppedPackets:[],crcErrors:f.value.map(g=>g.count),currentRates:y.value};const k=c.value.series.find(g=>g.type==="rx_count"),C=c.value.series.find(g=>g.type==="tx_count"),V=k?.data||[],ye=C?.data||[],m=V.map((g,b)=>{const A=ye[b];return A?Math.max(0,g[1]-A[1]):g[1]});return{totalPackets:V.map(g=>g[1]),transmittedPackets:ye.map(g=>g[1]),droppedPackets:m,crcErrors:f.value.map(g=>g.count),currentRates:y.value}}),pe=oe(()=>{const k=l.value,C=u.value;return{totalPackets:k.map(V=>V.stats.total_packets),transmittedPackets:k.map(V=>V.stats.transmitted_packets),droppedPackets:k.map(V=>V.stats.dropped_packets),avgRssi:k.map(V=>V.stats.avg_rssi),uptimeHours:C.map(V=>Math.floor((V.stats.uptime_seconds||0)/3600))}});async function H(k=3e4){await Promise.all([Q(),I(),ne(),j({hours:1}),_({hours:1})]);const C=setInterval(async()=>{try{await Promise.all([Q(),I(),ne(),j({hours:1}),_({hours:1})])}catch(V){console.error("Auto-refresh error:",V)}},k);return()=>clearInterval(C)}async function re(k=24){try{const[C,V]=await Promise.all([Fe.get("/crc_error_count",{hours:k}),Fe.get("/crc_error_history",{hours:k})]);C?.success&&C.data&&(d.value=C.data.crc_error_count??0),V?.success&&V.data&&(f.value=V.data.history??[])}catch(C){console.error("Failed to fetch CRC error data:",C)}}async function ve(){try{const[k]=await Promise.all([Fe.get("/metrics_graph_data",{hours:24,resolution:"average",metrics:"rx_count,tx_count"}),re(24)]);k?.success&&k.data&&(c.value=k.data)}catch(k){console.error("Failed to fetch sparkline data:",k)}}async function Ye(){await ve()}function me(){ve()}function G(){e.value=null,t.value=null,n.value=[],r.value=[],s.value=null,l.value=[],u.value=[],c.value=null,d.value=0,f.value=[],y.value={rx:0,tx:0,drop:0},h.value={rx:0,tx:0,drop:0},i.value=null,a.value=null,o.value=!1}function ge(k){n.value.unshift(k),n.value.length>1e3&&(n.value=n.value.slice(0,1e3))}function E(k){if(k.packet_stats){e.value=k.packet_stats;const C=new Date;l.value.push({timestamp:C,stats:k.packet_stats}),l.value.length>50&&(l.value=l.value.slice(-50))}if(k.system_stats){t.value=k.system_stats;const C=new Date;u.value.push({timestamp:C,stats:k.system_stats}),u.value.length>50&&(u.value=u.value.slice(-50))}a.value=new Date}return{packetStats:e,systemStats:t,recentPackets:n,noiseFloorHistory:r,noiseFloorStats:s,packetStatsHistory:l,systemStatsHistory:u,isLoading:o,error:i,lastUpdated:a,hasPacketStats:v,hasSystemStats:w,hasRecentPackets:S,hasNoiseFloorData:L,currentNoiseFloor:R,totalPackets:M,averageRSSI:F,averageSNR:Y,uptime:q,packetTypeBreakdown:X,recentPacketsByType:N,sparklineData:te,legacySparklineData:pe,noiseFloorSparklineData:K,crcErrorCount:d,interpolatedRates:y,fetchSystemStats:Q,fetchPacketStats:I,fetchCrcErrors:re,fetchRecentPackets:ne,fetchFilteredPackets:se,getPacketByHash:ee,fetchNoiseFloorHistory:j,fetchNoiseFloorStats:_,startAutoRefresh:H,initializeSparklineHistory:Ye,interpolateRates:me,reset:G,addRealtimePacket:ge,updateRealtimeStats:E}}),bc=Eo("websocket",()=>{const e=Z(null),t=Z(!1),n=Z(0),r=Z(null),s=Z(Date.now()),o=vc(),i=yr();function a(){if(e.value){if(e.value.readyState===WebSocket.OPEN){console.log("[WebSocket] Already connected, skipping connect()");return}else if(e.value.readyState===WebSocket.CONNECTING){console.log("[WebSocket] Already connecting, skipping connect()");return}}let u;const c=Xt(),d=fc(),f=new URLSearchParams;c&&f.set("token",c),d&&f.set("client_id",d);{const y=window.location.protocol==="https:"?"wss:":"ws:",h=Kr?.trim()?new URL(Kr).host:window.location.host;u=`${y}//${h}/ws/packets?${f.toString()}`}console.log("[WebSocket] Creating new connection..."),e.value=new WebSocket(u),e.value.onopen=()=>{console.log("[WebSocket] Connected"),t.value=!0,n.value=0,s.value=Date.now(),r.value&&clearInterval(r.value),r.value=window.setInterval(()=>{e.value?.readyState===WebSocket.OPEN&&(e.value.send(JSON.stringify({type:"ping"})),Date.now()-s.value>6e4&&(console.warn("[WebSocket] No pong received, reconnecting..."),l(),a()))},3e4)},e.value.onmessage=y=>{try{const h=JSON.parse(y.data);h.type==="packet"?o.addRealtimePacket(h.data):h.type==="stats"?(h.data?.packet_stats&&o.updateRealtimeStats({packet_stats:h.data.packet_stats}),h.data?.system_stats&&i.updateRealtimeStats(h.data.system_stats)):h.type==="packet_stats"?o.updateRealtimeStats(h.data):h.type==="system_stats"?i.updateRealtimeStats(h.data):(h.type==="pong"||h.type==="ping")&&(s.value=Date.now(),h.type==="ping"&&e.value?.readyState===WebSocket.OPEN&&e.value.send(JSON.stringify({type:"pong"})))}catch(h){console.error("[WebSocket] Parse error:",h)}},e.value.onerror=()=>{if(console.log("[WebSocket] Error"),t.value=!1,e.value=null,r.value&&(clearInterval(r.value),r.value=null),n.value<5){const y=Math.min(1e3*Math.pow(2,Math.min(n.value,5)),3e4);console.log(`[WebSocket] Reconnecting in ${y}ms (attempt ${n.value+1})`),n.value++,setTimeout(a,y)}else console.error("[WebSocket] Max reconnection attempts reached - stopping")},e.value.onclose=()=>{console.log("[WebSocket] Disconnected"),t.value=!1,e.value=null,r.value&&(clearInterval(r.value),r.value=null),n.value<5?(n.value=0,setTimeout(a,3e3)):console.log("[WebSocket] Not reconnecting - max attempts reached")}}function l(){console.log("[WebSocket] Disconnecting..."),r.value&&(clearInterval(r.value),r.value=null),e.value&&(e.value.onclose=null,e.value.onerror=null,e.value.close(),e.value=null),t.value=!1,n.value=0}return{isConnected:t,connect:a,disconnect:l}}),qe=(e,t)=>{const n=e.__vccOpts||e;for(const[r,s]of t)n[r]=s;return n},s2={},o2={width:"23",height:"25",viewBox:"0 0 23 25",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function i2(e,t){return O(),D("svg",o2,t[0]||(t[0]=[p("path",{d:"M2.84279 2.25795C2.90709 1.12053 3.17879 0.625914 3.95795 0.228723C4.79631 -0.198778 6.11858 0.000168182 7.67449 0.788054C8.34465 1.12757 8.41289 1.13448 9.58736 0.983905C11.1485 0.783681 13.1582 0.784388 14.5991 0.985738C15.6887 1.13801 15.7603 1.1304 16.4321 0.790174C18.6406 -0.328212 20.3842 -0.255036 21.0156 0.982491C21.3308 1.6002 21.3893 3.20304 21.1449 4.52503C21.0094 5.25793 21.0238 5.34943 21.3502 5.83037C23.6466 9.21443 21.9919 14.6998 18.0569 16.7469C17.7558 16.9036 17.502 17.0005 17.2952 17.0795C16.6602 17.3219 16.4674 17.3956 16.7008 18.5117C16.8132 19.0486 16.9486 20.3833 17.0018 21.478C17.098 23.4567 17.0966 23.4705 16.7495 23.8742C16.2772 24.4233 15.5963 24.4326 15.135 23.8962C14.8341 23.5464 14.8047 23.3812 14.8047 22.0315C14.8047 20.037 14.5861 18.7113 14.0695 17.5753C13.4553 16.2235 13.9106 15.7194 15.3154 15.4173C17.268 14.9973 18.793 13.7923 19.643 11.9978C20.4511 10.2921 20.5729 7.93485 19.1119 6.50124C18.6964 6.00746 18.6674 5.56022 18.9641 4.21159C19.075 3.70754 19.168 3.05725 19.1707 2.76637C19.1749 2.30701 19.1331 2.23764 18.8509 2.23764C18.6724 2.23764 17.9902 2.49736 17.3352 2.81474L16.2897 3.32145C16.1947 3.36751 16.0883 3.38522 15.9834 3.37318C13.3251 3.06805 10.7991 3.06334 8.12774 3.37438C8.02244 3.38663 7.91563 3.36892 7.82025 3.32263L6.77535 2.81559C6.12027 2.49764 5.43813 2.23764 5.25963 2.23764C4.84693 2.23764 4.84072 2.54233 5.2169 4.35258C5.44669 5.45816 5.60133 5.70451 4.93703 6.58851C3.94131 7.91359 3.69258 9.55902 4.22654 11.2878C4.89952 13.4664 6.54749 14.9382 8.86436 15.4292C10.261 15.7253 10.6261 16.1115 10.0928 17.713C9.67293 18.9734 9.40748 19.2982 8.79738 19.2982C7.97649 19.2982 7.46228 18.5871 7.74527 17.843C7.86991 17.5151 7.83283 17.4801 7.06383 17.1996C4.71637 16.3437 2.9209 14.4254 2.10002 11.8959C1.46553 9.94098 1.74471 7.39642 2.76257 5.85843C3.10914 5.33477 3.1145 5.29036 2.95277 4.28787C2.86126 3.72037 2.81177 2.80699 2.84279 2.25795Z",fill:"currentColor"},null,-1),p("path",{d:"M2.02306 16.5589C1.68479 16.0516 0.999227 15.9144 0.491814 16.2527C-0.0155884 16.591 -0.152708 17.2765 0.185564 17.7839C0.435301 18.1586 0.734065 18.4663 0.987777 18.72C1.03455 18.7668 1.08 18.8119 1.12438 18.856C1.3369 19.0671 1.52455 19.2535 1.71302 19.4748C2.12986 19.964 2.54572 20.623 2.78206 21.8047C2.88733 22.3311 3.26569 22.6147 3.47533 22.7386C3.70269 22.8728 3.9511 22.952 4.15552 23.0036C4.57369 23.109 5.08133 23.1638 5.56309 23.1957C6.09196 23.2308 6.665 23.2422 7.17743 23.2453C7.1778 23.8547 7.67202 24.3487 8.28162 24.3487C8.89146 24.3487 9.38582 23.8543 9.38582 23.2445V22.1403C9.38582 21.5305 8.89146 21.0361 8.28162 21.0361C8.17753 21.0361 8.06491 21.0364 7.94562 21.0369C7.29761 21.0389 6.45295 21.0414 5.70905 20.9922C5.35033 20.9684 5.05544 20.9347 4.8392 20.8936C4.50619 19.5863 3.96821 18.7165 3.39415 18.0426C3.14038 17.7448 2.87761 17.4842 2.66387 17.2722C2.62385 17.2326 2.58556 17.1946 2.54935 17.1584C2.30273 16.9118 2.1414 16.7365 2.02306 16.5589Z",fill:"currentColor"},null,-1)]))}const a2=qe(s2,[["render",i2]]),l2={},c2={width:"17",height:"24",viewBox:"0 0 17 24",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function u2(e,t){return O(),D("svg",c2,t[0]||(t[0]=[At('',12)]))}const d2=qe(l2,[["render",u2]]),f2={class:"glass-card p-5 relative overflow-hidden"},p2={key:0,class:"absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg"},h2={class:"flex items-baseline gap-2 mb-4"},m2={class:"text-content-primary dark:text-content-primary text-2xl font-medium"},g2=["viewBox"],y2=["y1","y2"],v2=["cx","cy"],Ns=200,Bn=50,kr=4,b2=ht({__name:"RFNoiseFloor",props:{limit:{default:void 0}},setup(e){const t=e,n=vc(),r=yr(),s=Z(null),o=(f,y)=>{const h=y/100*(f.length-1),v=Math.floor(h),w=Math.ceil(h);return v===w?f[v]:f[v]+(f[w]-f[v])*(h-v)},i=oe(()=>{const f=d.value;if(f.length===0)return[];const y=[...f].sort((F,Y)=>F-Y),h=o(y,2.5),v=o(y,97.5),w=v-h,S=Math.max(w*.05,.5),L=h-S,R=v+S,M=R-L||1;return f.map((F,Y)=>{const q=kr+Y/Math.max(f.length-1,1)*(Ns-kr*2),N=(Math.max(L,Math.min(R,F))-L)/M,Q=Bn-kr-N*(Bn-kr*2);return{x:q,y:Q}})}),a=async()=>{try{const f={hours:1};t.limit&&(f.limit=t.limit),await Promise.all([n.fetchNoiseFloorHistory(f),n.fetchNoiseFloorStats({hours:1})])}catch(f){console.error("Error fetching noise floor data:",f)}},l=()=>{s.value||(s.value=window.setInterval(a,5e3))},u=()=>{s.value&&(clearInterval(s.value),s.value=null)};hn(()=>{a(),l()}),ts(()=>{u()});const c=oe(()=>{const f=n.noiseFloorSparklineData;return f&&f.length>0?f[f.length-1]:n.noiseFloorStats?.avg_noise_floor??-116}),d=oe(()=>n.noiseFloorSparklineData);return(f,y)=>(O(),D("div",f2,[de(r).cadCalibrationRunning?(O(),D("div",p2,y[0]||(y[0]=[At('
CAD Calibration

In Progress

',1)]))):be("",!0),y[2]||(y[2]=p("p",{class:"text-content-secondary dark:text-content-muted text-xs uppercase mb-2"},"RF NOISE FLOOR",-1)),p("div",h2,[p("span",m2,W(c.value),1),y[1]||(y[1]=p("span",{class:"text-content-secondary dark:text-content-muted text-xs uppercase"},"dBm",-1))]),(O(),D("svg",{class:"w-full h-[50px]",viewBox:`0 0 ${Ns} ${Bn}`,fill:"none",xmlns:"http://www.w3.org/2000/svg"},[(O(),D(Oe,null,at(3,h=>p("line",{key:"grid-"+h,x1:0,y1:h*Bn/4,x2:Ns,y2:h*Bn/4,stroke:"rgba(255, 255, 255, 0.1)","stroke-width":"1"},null,8,y2)),64)),(O(!0),D(Oe,null,at(i.value,(h,v)=>(O(),D("circle",{key:"point-"+v,cx:h.x,cy:h.y,r:"2.5",fill:"rgba(245, 158, 11, 0.8)",class:"transition-all duration-300"},null,8,v2))),128))],8,g2))]))}}),Cc=qe(b2,[["__scopeId","data-v-03250605"]]),C2=Object.freeze(Object.defineProperty({__proto__:null,default:Cc},Symbol.toStringTag,{value:"Module"})),_2={},x2={width:"800px",height:"800px",viewBox:"0 -1.5 20 20",version:"1.1",xmlns:"http://www.w3.org/2000/svg","xmlns:xlink":"http://www.w3.org/1999/xlink",class:"w-full h-full"};function w2(e,t){return O(),D("svg",x2,t[0]||(t[0]=[p("g",{id:"Page-1",stroke:"none","stroke-width":"1",fill:"none","fill-rule":"evenodd"},[p("g",{transform:"translate(-420.000000, -3641.000000)",fill:"currentColor"},[p("g",{id:"icons",transform:"translate(56.000000, 160.000000)"},[p("path",{d:"M378.195439,3483.828 L376.781439,3485.242 C378.195439,3486.656 378.294439,3489.588 376.880439,3491.002 L378.294439,3492.417 C380.415439,3490.295 380.316439,3485.949 378.195439,3483.828 M381.023439,3481 L379.609439,3482.414 C382.438439,3485.242 382.537439,3491.002 379.708439,3493.831 L381.122439,3495.245 C385.365439,3491.002 384.559439,3484.535 381.023439,3481 M375.432439,3486.737 C375.409439,3486.711 375.392439,3486.682 375.367439,3486.656 L375.363439,3486.66 C374.582439,3485.879 373.243439,3485.952 372.536439,3486.659 C371.829439,3487.366 371.831439,3488.778 372.538439,3489.485 C372.547439,3489.494 372.558439,3489.499 372.567439,3489.508 C372.590439,3489.534 372.607439,3489.563 372.632439,3489.588 L372.636439,3489.585 C373.201439,3490.15 373.000439,3488.284 373.000439,3498 L375.000439,3498 C375.000439,3488.058 374.753439,3490.296 375.463439,3489.586 C376.170439,3488.879 376.168439,3487.467 375.461439,3486.76 C375.452439,3486.751 375.441439,3486.746 375.432439,3486.737 M371.119439,3485.242 L369.705439,3483.828 C367.584439,3485.949 367.683439,3490.295 369.804439,3492.417 L371.218439,3491.002 C369.804439,3489.588 369.705439,3486.656 371.119439,3485.242 M368.390439,3493.831 L366.976439,3495.245 C363.440439,3491.709 362.634439,3485.242 366.877439,3481 L368.291439,3482.414 C365.462439,3485.242 365.561439,3491.002 368.390439,3493.831",id:"radio_tower-[#1019]"})])])],-1)]))}const k2=qe(_2,[["render",w2]]),E2={class:"text-center"},S2={class:"relative flex items-center justify-center mb-8"},A2={class:"relative w-32 h-32"},R2={class:"absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2"},T2={key:0,class:"absolute inset-0 flex items-center justify-center"},O2={key:1,class:"absolute inset-0 flex items-center justify-center"},M2={key:2,class:"absolute inset-0"},P2={class:"mb-6"},L2={key:0,class:"text-content-primary dark:text-content-primary text-lg"},N2={key:1,class:"text-accent-green text-lg font-medium"},I2={key:2,class:"text-secondary text-lg"},D2={key:3,class:"text-accent-red text-lg"},$2={key:4,class:"text-content-secondary dark:text-content-muted"},F2={key:5,class:"mt-3"},V2={key:0,class:"text-secondary text-sm"},B2={key:1,class:"text-accent-red text-sm"},H2={key:0,class:"flex gap-3"},j2={key:1,class:"text-content-muted text-sm"},U2=ht({name:"AdvertModal",__name:"AdvertModal",props:{isOpen:{type:Boolean},isLoading:{type:Boolean},isSuccess:{type:Boolean},error:{default:null}},emits:["close","send"],setup(e,{emit:t}){const n=e,r=t,s=Z(!1),o=Z(!1),i=Z(!1);Et(()=>n.isOpen,c=>{c?(s.value=!0,setTimeout(()=>{o.value=!0},50)):(o.value=!1,i.value=!1,setTimeout(()=>{s.value=!1},300))},{immediate:!0}),Et(()=>n.isLoading,c=>{c||setTimeout(()=>{i.value=!1},1e3)});const a=()=>{n.isLoading||r("close")},l=()=>{n.isLoading||(i.value=!0,r("send"))},u=c=>c?.includes("Network error - no response received")||c?.includes("timeout");return(c,d)=>(O(),rt(Au,{to:"body"},[s.value?(O(),D("div",{key:0,class:"fixed inset-0 z-50 flex items-center justify-center p-4",onClick:Zs(a,["self"])},[p("div",{class:ce(["absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300",o.value?"opacity-100":"opacity-0"])},null,2),p("div",{class:ce(["relative bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-8 max-w-md w-full transform transition-all duration-300 border border-stroke-subtle dark:border-white/10",o.value?"scale-100 opacity-100":"scale-95 opacity-0"])},[c.isLoading?be("",!0):(O(),D("button",{key:0,onClick:a,class:"absolute top-4 right-4 text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors p-2"},d[0]||(d[0]=[p("svg",{class:"w-5 h-5",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[p("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))),p("div",E2,[d[6]||(d[6]=p("h2",{class:"text-content-primary dark:text-content-primary text-xl font-semibold mb-6"},"Send Advertisement",-1)),p("div",S2,[p("div",A2,[p("div",R2,[xe(k2,{class:ce(["w-16 h-16 transition-all duration-500",[c.isLoading?"animate-pulse":"",c.isSuccess?"text-accent-green":c.error&&!u(c.error)?"text-accent-red":"text-primary"]]),style:On({filter:c.isLoading?"drop-shadow(0 0 8px currentColor)":c.isSuccess?"drop-shadow(0 0 8px #A5E5B6)":c.error&&!u(c.error)?"drop-shadow(0 0 8px #FB787B)":"drop-shadow(0 0 4px #AAE8E8)"})},null,8,["class","style"])]),c.isLoading||c.isSuccess?(O(),D("div",T2,[p("div",{class:ce(["absolute w-16 h-16 rounded-full border-2 animate-ping",[c.isSuccess?"border-accent-green/60":"border-primary/60"]]),style:{"animation-duration":"1.5s"}},null,2),p("div",{class:ce(["absolute w-24 h-24 rounded-full border-2 animate-ping",[c.isSuccess?"border-accent-green/40":"border-primary/40"]]),style:{"animation-duration":"2s","animation-delay":"0.3s"}},null,2),p("div",{class:ce(["absolute w-32 h-32 rounded-full border-2 animate-ping",[c.isSuccess?"border-accent-green/20":"border-primary/20"]]),style:{"animation-duration":"2.5s","animation-delay":"0.6s"}},null,2)])):be("",!0),i.value?(O(),D("div",O2,d[1]||(d[1]=[p("div",{class:"absolute w-8 h-8 rounded-full border-4 border-secondary animate-ping-fast"},null,-1),p("div",{class:"absolute w-16 h-16 rounded-full border-3 border-secondary/70 animate-ping-fast",style:{"animation-delay":"0.1s"}},null,-1),p("div",{class:"absolute w-24 h-24 rounded-full border-2 border-secondary/50 animate-ping-fast",style:{"animation-delay":"0.2s"}},null,-1),p("div",{class:"absolute w-32 h-32 rounded-full border-2 border-secondary/30 animate-ping-fast",style:{"animation-delay":"0.3s"}},null,-1)]))):be("",!0),c.isLoading||c.isSuccess?(O(),D("div",M2,[p("div",{class:ce(["absolute top-2 right-2 w-4 h-4 rounded-full transition-all duration-500 animate-pulse",[c.isSuccess?"bg-accent-green shadow-lg shadow-accent-green/50":"bg-primary/70 shadow-lg shadow-primary/30"]]),style:{"animation-delay":"0.5s"}},d[2]||(d[2]=[p("div",{class:"w-2 h-2 bg-white rounded-full mx-auto mt-1"},null,-1)]),2),p("div",{class:ce(["absolute bottom-2 left-2 w-4 h-4 rounded-full transition-all duration-500 animate-pulse",[c.isSuccess?"bg-accent-green shadow-lg shadow-accent-green/50":"bg-primary/70 shadow-lg shadow-primary/30"]]),style:{"animation-delay":"1s"}},d[3]||(d[3]=[p("div",{class:"w-2 h-2 bg-white rounded-full mx-auto mt-1"},null,-1)]),2),p("div",{class:ce(["absolute top-1/2 right-1 w-4 h-4 rounded-full transition-all duration-500 animate-pulse",[c.isSuccess?"bg-accent-green shadow-lg shadow-accent-green/50":"bg-primary/70 shadow-lg shadow-primary/30"]]),style:{"animation-delay":"1.5s",transform:"translateY(-50%)"}},d[4]||(d[4]=[p("div",{class:"w-2 h-2 bg-white rounded-full mx-auto mt-1"},null,-1)]),2),p("div",{class:ce(["absolute top-3 left-3 w-4 h-4 rounded-full transition-all duration-500 animate-pulse",[c.isSuccess?"bg-accent-green shadow-lg shadow-accent-green/50":"bg-primary/70 shadow-lg shadow-primary/30"]]),style:{"animation-delay":"2s"}},d[5]||(d[5]=[p("div",{class:"w-2 h-2 bg-white rounded-full mx-auto mt-1"},null,-1)]),2)])):be("",!0)])]),p("div",P2,[c.isLoading?(O(),D("p",L2," Broadcasting advertisement... ")):c.isSuccess?(O(),D("p",N2," Advertisement sent successfully! ")):c.error&&u(c.error)?(O(),D("p",I2," Advertisement likely sent ")):c.error?(O(),D("p",D2," Failed to send advertisement ")):(O(),D("p",$2," This will broadcast your node's presence to nearby nodes. ")),c.error?(O(),D("div",F2,[u(c.error)?(O(),D("p",V2," Network timeout occurred, but the advertisement may have been successfully transmitted to nearby nodes. ")):(O(),D("p",B2,W(c.error),1))])):be("",!0)]),!c.isLoading&&!c.isSuccess?(O(),D("div",H2,[p("button",{onClick:a,class:"flex-1 bg-background-mute dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 hover:border-primary rounded-[10px] px-6 py-3 text-content-primary dark:text-content-primary hover:bg-stroke-subtle dark:hover:bg-white/10 transition-all duration-200"}," Cancel "),p("button",{onClick:l,class:ce(["flex-1 rounded-[10px] px-6 py-3 font-medium transition-all duration-200 shadow-lg",[c.error&&u(c.error)?"bg-secondary hover:bg-secondary/90 text-background hover:shadow-secondary/20":"bg-primary hover:bg-primary/90 text-background hover:shadow-primary/20"]])},W(c.error&&u(c.error)?"Try Again":"Send Advertisement"),3)])):be("",!0),c.isSuccess?(O(),D("div",j2," Closing automatically... ")):be("",!0)])],2)])):be("",!0)]))}}),_c=qe(U2,[["__scopeId","data-v-2eb89c71"]]),q2={},K2={width:"14",height:"14",viewBox:"0 0 14 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function W2(e,t){return O(),D("svg",K2,t[0]||(t[0]=[At('',2)]))}const wn=qe(q2,[["render",W2]]),Z2={},G2={width:"14",height:"14",viewBox:"0 0 14 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function z2(e,t){return O(),D("svg",G2,t[0]||(t[0]=[At('',9)]))}const xc=qe(Z2,[["render",z2]]),J2={},Y2={width:"14",height:"14",viewBox:"0 0 14 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function Q2(e,t){return O(),D("svg",Y2,t[0]||(t[0]=[At('',2)]))}const wc=qe(J2,[["render",Q2]]),X2={},e3={width:"11",height:"14",viewBox:"0 0 11 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function t3(e,t){return O(),D("svg",e3,t[0]||(t[0]=[p("path",{d:"M9.81633 1.99133L8.5085 0.683492C8.29229 0.466088 8.03511 0.293723 7.75185 0.176372C7.46859 0.059021 7.16486 -0.000985579 6.85825 -0.000175002H1.75C1.28587 -0.000175002 0.840752 0.184199 0.512563 0.512388C0.184375 0.840577 0 1.2857 0 1.74983V13.9998H10.5V3.64099C10.4985 3.02248 10.2528 2.4296 9.81633 1.99133ZM8.9915 2.81616C9.02083 2.84799 9.04829 2.88149 9.07375 2.91649H7.58333V1.42608C7.61834 1.45153 7.65184 1.479 7.68367 1.50833L8.9915 2.81616ZM1.16667 12.8332V1.74983C1.16667 1.59512 1.22812 1.44674 1.33752 1.33735C1.44692 1.22795 1.59529 1.16649 1.75 1.16649H6.41667V4.08316H9.33333V12.8332H1.16667ZM2.33333 9.33316H8.16667V5.83316H2.33333V9.33316ZM3.5 6.99983H7V8.16649H3.5V6.99983ZM2.33333 10.4998H8.16667V11.6665H2.33333V10.4998Z",fill:"currentColor"},null,-1)]))}const kc=qe(X2,[["render",t3]]),n3={},r3={width:"14",height:"14",viewBox:"0 0 14 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function s3(e,t){return O(),D("svg",r3,t[0]||(t[0]=[p("path",{d:"M12.25 0H1.75C1.28587 0 0.840752 0.184375 0.512563 0.512563C0.184375 0.840752 0 1.28587 0 1.75V12.25C0 12.7141 0.184375 13.1592 0.512563 13.4874C0.840752 13.8156 1.28587 14 1.75 14H12.25C12.7141 14 13.1592 13.8156 13.4874 13.4874C13.8156 13.1592 14 12.7141 14 12.25V1.75C14 1.28587 13.8156 0.840752 13.4874 0.512563C13.1592 0.184375 12.7141 0 12.25 0ZM12.8333 12.25C12.8333 12.4047 12.7719 12.5531 12.6625 12.6625C12.5531 12.7719 12.4047 12.8333 12.25 12.8333H1.75C1.59529 12.8333 1.44692 12.7719 1.33752 12.6625C1.22812 12.5531 1.16667 12.4047 1.16667 12.25V1.75C1.16667 1.59529 1.22812 1.44692 1.33752 1.33752C1.44692 1.22812 1.59529 1.16667 1.75 1.16667H12.25C12.4047 1.16667 12.5531 1.22812 12.6625 1.33752C12.7719 1.44692 12.8333 1.59529 12.8333 1.75V12.25ZM3.23583 7.41317L5.23583 9.41317C5.29134 9.46685 5.35738 9.50892 5.43004 9.53689C5.5027 9.56485 5.58055 9.57812 5.65892 9.57579C5.73729 9.57347 5.81418 9.5556 5.88513 9.52325C5.95608 9.4909 6.01963 9.44476 6.07175 9.38792C6.12387 9.33108 6.16351 9.26467 6.18833 9.19237C6.21315 9.12007 6.22263 9.04335 6.21618 8.96725C6.20973 8.89115 6.18746 8.81722 6.15078 8.74965C6.11411 8.68207 6.06376 8.62223 6.00292 8.57383L4.66708 7.23617L6.00292 5.90033C6.10827 5.78972 6.16669 5.64161 6.16522 5.48792C6.16375 5.33423 6.10251 5.1873 5.99491 5.07882C5.88731 4.97034 5.74082 4.90791 5.58716 4.90522C5.4335 4.90254 5.28489 4.95982 5.17367 5.06417L3.17367 7.06417C3.06317 7.17386 3.00063 7.32313 3.00063 7.47867C3.00063 7.63421 3.06317 7.78348 3.17367 7.89317L3.23583 7.41317ZM8.75 10.5H7.58333C7.4286 10.5 7.28025 10.5615 7.17085 10.6709C7.06146 10.7803 7 10.9286 7 11.0833C7 11.2381 7.06146 11.3864 7.17085 11.4958C7.28025 11.6052 7.4286 11.6667 7.58333 11.6667H8.75C8.90473 11.6667 9.05308 11.6052 9.16248 11.4958C9.27188 11.3864 9.33333 11.2381 9.33333 11.0833C9.33333 10.9286 9.27188 10.7803 9.16248 10.6709C9.05308 10.5615 8.90473 10.5 8.75 10.5Z",fill:"currentColor"},null,-1)]))}const Ec=qe(n3,[["render",s3]]),o3={},i3={width:"14",height:"14",viewBox:"0 0 14 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function a3(e,t){return O(),D("svg",i3,t[0]||(t[0]=[At('',2)]))}const Sc=qe(o3,[["render",a3]]),l3={name:"SystemIcon"},c3={width:"14",height:"14",viewBox:"0 0 14 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function u3(e,t,n,r,s,o){return O(),D("svg",c3,t[0]||(t[0]=[At('',5)]))}const Wr=qe(l3,[["render",u3]]),d3={},f3={width:"11",height:"14",viewBox:"0 0 11 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function p3(e,t){return O(),D("svg",f3,t[0]||(t[0]=[p("path",{d:"M10.5 14.0004H9.33333V11.0586C9.33287 10.6013 9.15099 10.1628 8.82761 9.83942C8.50422 9.51603 8.06575 9.33415 7.60842 9.33369H2.89158C2.43425 9.33415 1.99578 9.51603 1.67239 9.83942C1.34901 10.1628 1.16713 10.6013 1.16667 11.0586V14.0004H0V11.0586C0.000926233 10.292 0.305872 9.55705 0.847948 9.01497C1.39002 8.47289 2.12497 8.16795 2.89158 8.16702H7.60842C8.37503 8.16795 9.10998 8.47289 9.65205 9.01497C10.1941 9.55705 10.4991 10.292 10.5 11.0586V14.0004Z",fill:"currentColor"},null,-1),p("path",{d:"M5.25 6.99997C4.55777 6.99997 3.88108 6.7947 3.30551 6.41011C2.72993 6.02553 2.28133 5.4789 2.01642 4.83936C1.75152 4.19982 1.6822 3.49609 1.81725 2.81716C1.9523 2.13822 2.28564 1.51458 2.77513 1.0251C3.26461 0.535614 3.88825 0.202271 4.56719 0.0672226C5.24612 -0.0678257 5.94985 0.00148598 6.58939 0.266393C7.22894 0.531299 7.77556 0.979903 8.16015 1.55548C8.54473 2.13105 8.75 2.80774 8.75 3.49997C8.74908 4.42794 8.38003 5.31765 7.72385 5.97382C7.06768 6.63 6.17798 6.99904 5.25 6.99997ZM5.25 1.16664C4.78851 1.16664 4.33739 1.30349 3.95367 1.55988C3.56996 1.81627 3.27089 2.18068 3.09428 2.60704C2.91768 3.0334 2.87147 3.50256 2.9615 3.95518C3.05153 4.4078 3.27376 4.82357 3.60009 5.14989C3.92641 5.47621 4.34217 5.69844 4.79479 5.78847C5.24741 5.8785 5.71657 5.83229 6.14293 5.65569C6.56929 5.47909 6.93371 5.18002 7.1901 4.7963C7.44649 4.41259 7.58334 3.96146 7.58334 3.49997C7.58334 2.88113 7.3375 2.28764 6.89992 1.85006C6.46233 1.41247 5.86884 1.16664 5.25 1.16664Z",fill:"currentColor"},null,-1)]))}const Ac=qe(d3,[["render",p3]]),h3={},m3={width:"11",height:"13",viewBox:"0 0 11 13",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function g3(e,t){return O(),D("svg",m3,t[0]||(t[0]=[p("path",{d:"M6.77889 9.16667H10.1122V12.5M4.11222 3.83333H0.77889V0.5M10.3906 4.50227C10.0168 3.57711 9.39097 2.77536 8.58423 2.18815C7.77749 1.60094 6.82233 1.25168 5.82707 1.18034C4.8318 1.109 3.83627 1.31827 2.95402 1.78441C2.07177 2.25055 1.3381 2.95503 0.836182 3.81742M0.500244 8.49805C0.874034 9.42321 1.49986 10.225 2.30661 10.8122C3.11335 11.3994 4.06948 11.7482 5.06474 11.8195C6.06001 11.8909 7.05473 11.6816 7.93697 11.2155C8.81922 10.7494 9.55239 10.045 10.0543 9.18262",stroke:"currentColor","stroke-linecap":"round","stroke-linejoin":"round"},null,-1)]))}const Rc=qe(h3,[["render",g3]]),y3={},v3={width:"14",height:"14",viewBox:"0 0 14 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"};function b3(e,t){return O(),D("svg",v3,t[0]||(t[0]=[At('',2)]))}const Tc=qe(y3,[["render",b3]]),C3={class:"w-[285px] flex-shrink-0 p-[15px] hidden lg:block"},_3={class:"glass-card h-full p-6"},x3={class:"mb-12"},w3={class:"text-content-secondary dark:text-content-muted text-sm"},k3=["title"],E3={class:"text-content-secondary dark:text-content-muted text-sm mt-1"},S3={class:"mt-3 p-2 rounded-[10px] border border-stroke-subtle dark:border-white/10 bg-white dark:bg-white/5"},A3={class:"flex items-center justify-between"},R3={class:"flex items-center gap-3 mt-1.5 text-[10px] text-content-muted dark:text-content-muted"},T3={class:"text-green-600 dark:text-green-400"},O3={class:"text-red-600 dark:text-red-400"},M3={key:0,class:"text-orange-600 dark:text-orange-400"},P3={class:"mb-8"},L3={class:"mb-8"},N3={class:"space-y-2"},I3=["onClick"],D3={class:"mb-8"},$3={class:"space-y-2"},F3=["onClick"],V3={class:"mb-8"},B3={class:"space-y-2"},H3=["onClick"],j3={class:"mb-8"},U3={class:"space-y-2"},q3=["onClick"],K3=["disabled"],W3={class:"flex items-center gap-3"},Z3=["disabled"],G3={class:"flex items-center gap-3"},z3={class:"mb-4"},J3={key:0,class:"mb-2 glass-card px-3 py-2 rounded-lg border border-blue-500/30 dark:border-blue-400/50 bg-blue-500/10 dark:bg-blue-400/20"},Y3={class:"flex items-center gap-2"},Q3={key:0,class:"mt-2 glass-card px-3 py-2 rounded-lg border border-stroke-subtle dark:border-stroke/30 space-y-2 text-xs animate-fade-in"},X3={class:"space-y-1"},ep={class:"flex items-center justify-between"},tp={class:"text-content-primary dark:text-content-primary font-mono"},np={key:0,class:"pl-2 space-y-0.5 text-[10px] text-content-secondary dark:text-content-muted"},rp={key:0,class:"flex items-center gap-1"},sp={class:"bg-white/5 dark:bg-black/20 px-1 py-0.5 rounded"},op={class:"space-y-1"},ip={class:"flex items-center justify-between"},ap={class:"text-content-primary dark:text-content-primary font-mono"},lp={key:0,class:"pl-2 space-y-0.5 text-[10px] text-content-secondary dark:text-content-muted"},cp={key:0,class:"flex items-center gap-1"},up={class:"bg-white/5 dark:bg-black/20 px-1 py-0.5 rounded"},dp={key:0,class:"mb-4"},fp={class:"text-content-secondary dark:text-content-muted text-xs mb-2"},pp={class:"text-content-primary dark:text-content-primary"},hp={class:"w-full h-1 bg-white/10 rounded-full overflow-hidden"},mp={class:"flex items-center gap-2 text-content-secondary dark:text-content-muted text-xs mb-3"},gp={class:"flex items-center justify-center gap-3"},yp={href:"https://github.com/rightup",target:"_blank",class:"inline-flex items-center justify-center w-9 h-9 rounded-xl bg-content-primary dark:bg-white/10 border border-stroke-subtle dark:border-stroke/20 hover:bg-primary/20 dark:hover:bg-primary/30 hover:border-primary/50 transition-all duration-300 hover:scale-110 group backdrop-blur-sm",title:"GitHub"},vp={href:"https://buymeacoffee.com/rightup",target:"_blank",class:"inline-flex items-center justify-center w-9 h-9 rounded-xl bg-content-primary dark:bg-white/10 border border-stroke-subtle dark:border-stroke/20 hover:bg-yellow-50 dark:hover:bg-yellow-500/20 hover:border-yellow-500/50 transition-all duration-300 hover:scale-110 group backdrop-blur-sm",title:"Buy Me a Coffee"},bp=ht({name:"SidebarNav",__name:"Sidebar",setup(e){const t=Ro(),n=To(),r=yr(),s=bc(),o=Z(!1),i=Z(!1),a=Z(!1),l=Z(!1),u=Z(!1),c=Z(null);let d=null,f=null;const y=Z("unknown"),h=Z(0),v=Z(0),w=Z(0),S=async pe=>{d&&(d(),d=null),pe||(d=await r.startAutoRefresh(5e3,pe))};hn(async()=>{await S(s.isConnected),await L(),f=window.setInterval(()=>{L()},3e4),Et(()=>s.isConnected,pe=>{S(pe)})}),dr(()=>{d&&d(),f&&clearInterval(f)});const L=async()=>{try{const H=(await Fe.get("/advert_rate_limit_stats"))?.data;y.value=typeof H?.adaptive?.current_tier=="string"?H.adaptive.current_tier:"unknown",h.value=H?.stats?.adverts_allowed||0,v.value=H?.stats?.adverts_dropped||0,w.value=Object.keys(H?.active_penalties||{}).length}catch{y.value="unknown",h.value=0,v.value=0,w.value=0}},R=oe(()=>{switch(y.value){case"quiet":return"bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 border-green-500/50";case"normal":return"bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 border-blue-500/50";case"busy":return"bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 border-yellow-500/50";case"congested":return"bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 border-red-500/50";default:return"bg-gray-100 dark:bg-gray-500/20 text-gray-700 dark:text-gray-400 border-gray-500/50"}}),M={dashboard:xc,neighbors:Ac,statistics:Sc,"system-stats":Wr,sessions:Wr,configuration:wn,"room-servers":wn,companions:wn,logs:kc,terminal:Ec,help:wc},F=[{name:"Dashboard",icon:"dashboard",route:"/"},{name:"Neighbors",icon:"neighbors",route:"/neighbors"},{name:"Statistics",icon:"statistics",route:"/statistics"},{name:"System Stats",icon:"system-stats",route:"/system-stats"},{name:"Sessions",icon:"sessions",route:"/sessions"},{name:"Configuration",icon:"configuration",route:"/configuration"},{name:"Terminal",icon:"terminal",route:"/terminal"},{name:"Room Servers",icon:"room-servers",route:"/room-servers"},{name:"Companions",icon:"companions",route:"/companions"},{name:"Logs",icon:"logs",route:"/logs"},{name:"Help",icon:"help",route:"/help"}],Y=oe(()=>pe=>n.path===pe),q=pe=>{t.push(pe)},X=async()=>{o.value=!0,c.value=null;try{await r.sendAdvert(),u.value=!0,setTimeout(()=>{N()},2e3)}catch(pe){c.value=pe instanceof Error?pe.message:"Unknown error occurred",console.error("Failed to send advert:",pe)}finally{o.value=!1}},N=()=>{l.value=!1,u.value=!1,c.value=null,o.value=!1},Q=async()=>{if(!i.value){i.value=!0;try{await r.toggleMode()}catch(pe){console.error("Failed to toggle mode:",pe)}finally{i.value=!1}}},j=async()=>{if(!a.value){a.value=!0;try{await r.toggleDutyCycle()}catch(pe){console.error("Failed to toggle duty cycle:",pe)}finally{a.value=!1}}},_=Z(new Date().toLocaleTimeString());setInterval(()=>{_.value=new Date().toLocaleTimeString()},1e3);const K=oe(()=>{const pe=r.dutyCyclePercentage;let H="#A5E5B6";return pe>90?H="#FB787B":pe>70&&(H="#FFC246"),{width:pe===0?"2px":`${Math.max(pe,2)}%`,backgroundColor:H}}),I=Z(!1),ne=oe(()=>r.version.includes("dev")||r.coreVersion.includes("dev")),se=pe=>{const H=pe.match(/^([\d.]+)(\.dev(\d+))?((\+g)([a-f0-9]+))?$/);return H?{base:H[1],isDev:!!H[2],devNumber:H[3]||null,commit:H[6]||null}:{base:pe,isDev:!1,devNumber:null,commit:null}},ee=oe(()=>se(r.version)),te=oe(()=>se(r.coreVersion));return(pe,H)=>(O(),D(Oe,null,[p("aside",C3,[p("div",_3,[p("div",x3,[H[3]||(H[3]=p("div",{class:"mb-2 flex justify-center"},[p("img",{src:wf,alt:"MeshCore",class:"h-4 opacity-80 dark:invert-0 invert"})],-1)),H[4]||(H[4]=p("h1",{class:"text-content-primary dark:text-content-primary text-[22px] font-extrabold tracking-tight mb-3 text-center",style:{"font-family":"system-ui, -apple-system, sans-serif"}},"pyMC Repeater",-1)),p("p",w3,[Ne(W(de(r).nodeName)+" ",1),p("span",{class:ce(["inline-block w-2 h-2 rounded-full ml-2",de(r).statusBadge.text==="Active"?"bg-accent-green":de(r).statusBadge.text==="Monitor Mode"?"bg-secondary":"bg-accent-red"]),title:de(r).statusBadge.title},null,10,k3)]),p("p",E3,"<"+W(de(r).pubKey)+">",1),p("div",S3,[p("div",A3,[H[2]||(H[2]=p("span",{class:"text-content-muted dark:text-content-muted text-[10px] uppercase tracking-wide"},"Adaptive",-1)),p("div",{class:ce(["inline-flex items-center px-2 py-0.5 rounded-full border text-[10px] font-semibold",R.value])},W(y.value.toUpperCase()),3)]),p("div",R3,[p("span",T3,"OK: "+W(h.value),1),p("span",O3,"Drop: "+W(v.value),1),w.value>0?(O(),D("span",M3,"Pen: "+W(w.value),1)):be("",!0)])])]),H[22]||(H[22]=p("div",{class:"border-t border-stroke-subtle dark:border-stroke mb-6"},null,-1)),p("div",P3,[H[6]||(H[6]=p("p",{class:"text-content-muted dark:text-content-muted text-xs uppercase mb-4"},"Actions",-1)),p("button",{onClick:H[0]||(H[0]=re=>l.value=!0),class:"w-full bg-white dark:bg-white/10 rounded-[10px] py-3 px-4 flex items-center gap-2 text-sm font-medium text-[#212122] dark:text-white border border-stroke-subtle dark:border-white/10 hover:bg-gray-100 dark:hover:bg-white/20 transition-colors"},H[5]||(H[5]=[p("svg",{class:"w-3.5 h-3.5",viewBox:"0 0 14 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[p("path",{d:"M7 0C5.61553 0 4.26216 0.410543 3.11101 1.17971C1.95987 1.94888 1.06266 3.04213 0.532846 4.32122C0.003033 5.6003 -0.13559 7.00777 0.134506 8.36563C0.404603 9.7235 1.07129 10.9708 2.05026 11.9497C3.02922 12.9287 4.2765 13.5954 5.63437 13.8655C6.99224 14.1356 8.3997 13.997 9.67879 13.4672C10.9579 12.9373 12.0511 12.0401 12.8203 10.889C13.5895 9.73785 14 8.38447 14 7C13.998 5.1441 13.2599 3.36479 11.9475 2.05247C10.6352 0.74015 8.8559 0.0020073 7 0V0ZM7 12.8333C5.84628 12.8333 4.71846 12.4912 3.75918 11.8502C2.79989 11.2093 2.05222 10.2982 1.61071 9.23232C1.16919 8.16642 1.05368 6.99353 1.27876 5.86197C1.50384 4.73042 2.05941 3.69102 2.87521 2.87521C3.69102 2.0594 4.73042 1.50383 5.86198 1.27875C6.99353 1.05367 8.16642 1.16919 9.23232 1.6107C10.2982 2.05221 11.2093 2.79989 11.8502 3.75917C12.4912 4.71846 12.8333 5.84628 12.8333 7C12.8316 8.54658 12.2165 10.0293 11.1229 11.1229C10.0293 12.2165 8.54658 12.8316 7 12.8333ZM8.16667 7C8.1676 7.20501 8.11448 7.40665 8.01268 7.58461C7.91087 7.76256 7.76397 7.91054 7.58677 8.01365C7.40957 8.11676 7.20833 8.17136 7.00332 8.17194C6.7983 8.17252 6.59675 8.11906 6.41897 8.01696C6.24119 7.91485 6.09346 7.7677 5.99065 7.59033C5.88784 7.41295 5.83358 7.21162 5.83335 7.0066C5.83312 6.80159 5.88691 6.60013 5.98932 6.42252C6.09172 6.24491 6.23912 6.09743 6.41667 5.99492V3.5H7.58334V5.99492C7.76016 6.09659 7.90713 6.24298 8.00952 6.41939C8.1119 6.5958 8.1661 6.79603 8.16667 7Z",fill:"currentColor"})],-1),Ne(" Send Advert ",-1)]))]),p("div",L3,[H[7]||(H[7]=p("p",{class:"text-content-muted dark:text-content-muted text-xs uppercase mb-4"},"Monitoring",-1)),p("div",N3,[(O(!0),D(Oe,null,at(F.slice(0,3),re=>(O(),D("button",{key:re.name,onClick:ve=>q(re.route),class:ce([Y.value(re.route)?"bg-gradient-to-r from-cyan-400/90 to-cyan-500/90 dark:bg-primary/30 border-cyan-500 dark:border-primary/40 shadow-[0_4px_16px_rgba(6,182,212,0.4)] dark:shadow-[0_4px_12px_rgba(170,232,232,0.25)] text-white dark:text-primary font-semibold":"text-content-primary dark:text-content-primary hover:bg-gradient-to-r hover:from-cyan-400/20 hover:to-cyan-500/20 dark:hover:bg-primary/5 hover:border-cyan-400/30 dark:hover:border-primary/20 hover:shadow-[0_2px_12px_rgba(6,182,212,0.2)] dark:hover:shadow-[0_2px_8px_rgba(170,232,232,0.15)] border border-stroke-subtle dark:border-transparent","w-full rounded-[10px] py-3 px-4 flex items-center gap-3 text-sm font-medium transition-all duration-200"])},[(O(),rt(Zt(M[re.icon]),{class:ce(Y.value(re.route)?"w-3.5 h-3.5 text-white dark:text-primary [&_path]:fill-current":"w-3.5 h-3.5 text-content-primary dark:text-content-primary [&_path]:fill-current")},null,8,["class"])),Ne(" "+W(re.name),1)],10,I3))),128))])]),p("div",D3,[H[8]||(H[8]=p("p",{class:"text-content-muted dark:text-content-muted text-xs uppercase mb-4"},"System",-1)),p("div",$3,[(O(!0),D(Oe,null,at(F.slice(3,7),re=>(O(),D("button",{key:re.name,onClick:ve=>q(re.route),class:ce([Y.value(re.route)?"bg-gradient-to-r from-cyan-400/90 to-cyan-500/90 dark:bg-primary/30 border-cyan-500 dark:border-primary/40 shadow-[0_4px_16px_rgba(6,182,212,0.4)] dark:shadow-[0_4px_12px_rgba(170,232,232,0.25)] text-white dark:text-primary font-semibold":"text-content-primary dark:text-content-primary hover:bg-gradient-to-r hover:from-cyan-400/20 hover:to-cyan-500/20 dark:hover:bg-primary/5 hover:border-cyan-400/30 dark:hover:border-primary/20 hover:shadow-[0_2px_12px_rgba(6,182,212,0.2)] dark:hover:shadow-[0_2px_8px_rgba(170,232,232,0.15)] border border-stroke-subtle dark:border-transparent","w-full rounded-[10px] py-3 px-4 flex items-center gap-3 text-sm font-medium transition-all duration-200"])},[(O(),rt(Zt(M[re.icon]),{class:ce(Y.value(re.route)?"w-3.5 h-3.5 text-white dark:text-primary [&_path]:fill-current":"w-3.5 h-3.5 text-content-primary dark:text-content-primary [&_path]:fill-current")},null,8,["class"])),Ne(" "+W(re.name),1)],10,F3))),128))])]),p("div",V3,[H[9]||(H[9]=p("p",{class:"text-content-muted dark:text-content-muted text-xs uppercase mb-4"},"Room Servers & Companions",-1)),p("div",B3,[(O(!0),D(Oe,null,at(F.slice(7,9),re=>(O(),D("button",{key:re.name,onClick:ve=>q(re.route),class:ce([Y.value(re.route)?"bg-gradient-to-r from-cyan-400/90 to-cyan-500/90 dark:bg-primary/30 border-cyan-500 dark:border-primary/40 shadow-[0_4px_16px_rgba(6,182,212,0.4)] dark:shadow-[0_4px_12px_rgba(170,232,232,0.25)] text-white dark:text-primary font-semibold":"text-content-primary dark:text-content-primary hover:bg-gradient-to-r hover:from-cyan-400/20 hover:to-cyan-500/20 dark:hover:bg-primary/5 hover:border-cyan-400/30 dark:hover:border-primary/20 hover:shadow-[0_2px_12px_rgba(6,182,212,0.2)] dark:hover:shadow-[0_2px_8px_rgba(170,232,212,0.15)] border border-stroke-subtle dark:border-transparent","w-full rounded-[10px] py-3 px-4 flex items-center gap-3 text-sm font-medium transition-all duration-200"])},[(O(),rt(Zt(M[re.icon]),{class:ce(Y.value(re.route)?"w-3.5 h-3.5 text-white dark:text-primary [&_path]:fill-current":"w-3.5 h-3.5 text-content-primary dark:text-content-primary [&_path]:fill-current")},null,8,["class"])),Ne(" "+W(re.name),1)],10,H3))),128))])]),p("div",j3,[H[10]||(H[10]=p("p",{class:"text-content-muted dark:text-content-muted text-xs uppercase mb-4"},"Other",-1)),p("div",U3,[(O(!0),D(Oe,null,at(F.slice(9),re=>(O(),D("button",{key:re.name,onClick:ve=>q(re.route),class:ce([Y.value(re.route)?"bg-gradient-to-r from-cyan-400/90 to-cyan-500/90 dark:bg-primary/30 border-cyan-500 dark:border-primary/40 shadow-[0_4px_16px_rgba(6,182,212,0.4)] dark:shadow-[0_4px_12px_rgba(170,232,232,0.25)] text-white dark:text-primary font-semibold":"text-content-primary dark:text-content-primary hover:bg-gradient-to-r hover:from-cyan-400/20 hover:to-cyan-500/20 dark:hover:bg-primary/5 hover:border-cyan-400/30 dark:hover:border-primary/20 hover:shadow-[0_2px_12px_rgba(6,182,212,0.2)] dark:hover:shadow-[0_2px_8px_rgba(170,232,232,0.15)] border border-stroke-subtle dark:border-transparent","w-full rounded-[10px] py-3 px-4 flex items-center gap-3 text-sm font-medium transition-all duration-200"])},[(O(),rt(Zt(M[re.icon]),{class:ce(Y.value(re.route)?"w-3.5 h-3.5 text-white dark:text-primary [&_path]:fill-current":"w-3.5 h-3.5 text-content-primary dark:text-content-primary [&_path]:fill-current")},null,8,["class"])),Ne(" "+W(re.name),1)],10,q3))),128))])]),xe(Cc,{"current-value":de(r).noiseFloorDbm||-116,"update-interval":3e3,class:"mb-6"},null,8,["current-value"]),p("button",{onClick:Q,disabled:i.value,class:ce(["p-4 flex items-center justify-between mb-4 w-full transition-all duration-200 cursor-pointer group",de(r).modeButtonState.warning?"glass-card-orange hover:bg-accent-red/10":"glass-card-green hover:bg-accent-green/10"])},[p("div",W3,[xe(Rc,{class:"w-4 h-4 text-content-primary dark:text-content-primary group-hover:text-primary transition-colors"}),H[11]||(H[11]=p("span",{class:"text-content-primary dark:text-content-primary text-sm group-hover:text-primary transition-colors"},"Mode",-1))]),p("span",{class:ce(["text-xs font-medium group-hover:text-white transition-colors",de(r).modeButtonState.warning?"text-accent-red":"text-accent-green"])},W(i.value?"Changing...":de(r).currentMode.charAt(0).toUpperCase()+de(r).currentMode.slice(1)),3)],10,K3),p("button",{onClick:j,disabled:a.value,class:ce(["p-4 flex items-center justify-between mb-4 w-full transition-all duration-200 cursor-pointer group",de(r).dutyCycleButtonState.warning?"glass-card-orange hover:bg-accent-red/10":"glass-card-green hover:bg-accent-green/10"])},[p("div",G3,[xe(Tc,{class:"w-3.5 h-3.5 text-content-primary dark:text-content-primary group-hover:text-primary transition-colors"}),H[12]||(H[12]=p("span",{class:"text-content-primary dark:text-content-primary text-sm group-hover:text-primary transition-colors"},"Duty Cycle",-1))]),p("span",{class:ce(["text-xs font-medium group-hover:text-white transition-colors",de(r).dutyCycleButtonState.warning?"text-accent-red":"text-primary"])},W(a.value?"Changing...":de(r).dutyCycleEnabled?"Enabled":"Disabled"),3)],10,Z3),p("div",z3,[ne.value?(O(),D("div",J3,H[13]||(H[13]=[p("div",{class:"flex items-center justify-center gap-2"},[p("svg",{class:"w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0",viewBox:"0 0 20 20",fill:"currentColor"},[p("path",{"fill-rule":"evenodd",d:"M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z","clip-rule":"evenodd"})]),p("span",{class:"text-blue-500 dark:text-blue-400 text-xs font-semibold"},"Development Build")],-1)]))):be("",!0),p("div",{onClick:H[1]||(H[1]=re=>I.value=!I.value),class:"cursor-pointer transition-all duration-200 hover:scale-[1.02]"},[p("div",Y3,[p("span",{class:ce(["glass-card px-2 py-1 text-xs font-medium rounded border transition-colors",ee.value.isDev?"text-yellow-600 dark:text-yellow-400 border-yellow-500/30 dark:border-yellow-500/30":"text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke"])}," R:v"+W(ee.value.base)+W(ee.value.isDev?"-dev"+ee.value.devNumber:""),3),p("span",{class:ce(["glass-card px-2 py-1 text-xs font-medium rounded border transition-colors",te.value.isDev?"text-yellow-600 dark:text-yellow-400 border-yellow-500/30 dark:border-yellow-500/30":"text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke"])}," Core:v"+W(te.value.base)+W(te.value.isDev?"-dev"+te.value.devNumber:""),3),(O(),D("svg",{class:ce(["w-3 h-3 text-content-muted transition-transform duration-200",I.value?"rotate-180":""]),fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},H[14]||(H[14]=[p("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M19 9l-7 7-7-7"},null,-1)]),2))]),I.value?(O(),D("div",Q3,[p("div",X3,[p("div",ep,[H[15]||(H[15]=p("span",{class:"text-content-muted font-medium"},"Repeater:",-1)),p("span",tp,"v"+W(ee.value.base),1)]),ee.value.isDev?(O(),D("div",np,[p("div",null,"Dev Build: "+W(ee.value.devNumber),1),ee.value.commit?(O(),D("div",rp,[H[16]||(H[16]=p("span",null,"Commit:",-1)),p("code",sp,W(ee.value.commit),1)])):be("",!0)])):be("",!0)]),H[19]||(H[19]=p("div",{class:"border-t border-stroke-subtle dark:border-stroke/20"},null,-1)),p("div",op,[p("div",ip,[H[17]||(H[17]=p("span",{class:"text-content-muted font-medium"},"Core:",-1)),p("span",ap,"v"+W(te.value.base),1)]),te.value.isDev?(O(),D("div",lp,[p("div",null,"Dev Build: "+W(te.value.devNumber),1),te.value.commit?(O(),D("div",cp,[H[18]||(H[18]=p("span",null,"Commit:",-1)),p("code",up,W(te.value.commit),1)])):be("",!0)])):be("",!0)])])):be("",!0)])]),H[23]||(H[23]=p("div",{class:"border-t border-accent-green mb-4"},null,-1)),de(r).dutyCycleEnabled?(O(),D("div",dp,[p("p",fp,[H[20]||(H[20]=Ne(" Duty Cycle: ",-1)),p("span",pp,W(de(r).dutyCycleUtilization.toFixed(1))+"% / "+W(de(r).dutyCycleMax.toFixed(1))+"%",1)]),p("div",hp,[p("div",{class:"h-full rounded-full transition-all duration-300",style:On(K.value)},null,4)])])):be("",!0),p("div",mp,[H[21]||(H[21]=p("svg",{class:"w-3 h-3",viewBox:"0 0 13 13",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[p("path",{d:"M6.5 13C5.59722 13 4.75174 12.8286 3.96355 12.4858C3.17537 12.143 2.48926 11.6795 1.90522 11.0955C1.32119 10.5115 0.85776 9.82535 0.514945 9.03717C0.172131 8.24898 0.000482491 7.40326 1.0101e-06 6.5C-0.000480471 5.59674 0.171168 4.75126 0.514945 3.96356C0.858723 3.17585 1.32191 2.48974 1.9045 1.90522C2.48709 1.3207 3.1732 0.857278 3.96283 0.514944C4.75246 0.172611 5.59818 0.000962963 6.5 0C7.48703 0 8.42303 0.210648 9.30799 0.631944C10.193 1.05324 10.9421 1.64907 11.5555 2.41944V1.44444C11.5555 1.23981 11.6249 1.06841 11.7635 0.930222C11.9022 0.792037 12.0736 0.722704 12.2778 0.722222C12.4819 0.721741 12.6536 0.791074 12.7927 0.930222C12.9319 1.06937 13.001 1.24078 13 1.44444V4.33333C13 4.53796 12.9307 4.70961 12.792 4.84828C12.6533 4.98694 12.4819 5.05604 12.2778 5.05556H9.38888C9.18425 5.05556 9.01285 4.98622 8.87466 4.84756C8.73647 4.70889 8.66714 4.53748 8.66666 4.33333C8.66618 4.12919 8.73551 3.95778 8.87466 3.81911C9.01381 3.68044 9.18521 3.61111 9.38888 3.61111H10.6528C10.1593 2.93704 9.55138 2.40741 8.82916 2.02222C8.10694 1.63704 7.33055 1.44444 6.5 1.44444C5.09166 1.44444 3.89711 1.93507 2.91633 2.91633C1.93555 3.89759 1.44493 5.09215 1.44444 6.5C1.44396 7.90785 1.93459 9.10265 2.91633 10.0844C3.89807 11.0661 5.09263 11.5565 6.5 11.5556C7.64351 11.5556 8.66666 11.2125 9.56944 10.5264C10.4722 9.84028 11.068 8.95555 11.3569 7.87222C11.4171 7.67963 11.5255 7.53519 11.6819 7.43889C11.8384 7.34259 12.013 7.30648 12.2055 7.33055C12.4102 7.35463 12.5727 7.44178 12.693 7.592C12.8134 7.74222 12.8495 7.90785 12.8014 8.08889C12.4523 9.5213 11.694 10.698 10.5264 11.6191C9.35879 12.5402 8.01666 13.0005 6.5 13ZM7.22222 6.21111L9.02777 8.01667C9.16018 8.14907 9.22638 8.31759 9.22638 8.52222C9.22638 8.72685 9.16018 8.89537 9.02777 9.02778C8.89536 9.16018 8.72685 9.22639 8.52222 9.22639C8.31759 9.22639 8.14907 9.16018 8.01666 9.02778L5.99444 7.00556C5.92222 6.93333 5.86805 6.8522 5.83194 6.76217C5.79583 6.67213 5.77777 6.57872 5.77777 6.48194V3.61111C5.77777 3.40648 5.84711 3.23507 5.98577 3.09689C6.12444 2.9587 6.29585 2.88937 6.5 2.88889C6.70414 2.88841 6.87579 2.95774 7.01494 3.09689C7.15409 3.23604 7.22318 3.40744 7.22222 3.61111V6.21111Z",fill:"currentColor"})],-1)),Ne(" Last Updated: "+W(_.value),1)]),p("div",gp,[p("a",yp,[xe(a2,{class:"w-5 h-5 text-white group-hover:text-primary transition-colors"})]),p("a",vp,[xe(d2,{class:"w-5 h-5 text-white group-hover:text-yellow-500 transition-colors"})])])])]),xe(_c,{isOpen:l.value,isLoading:o.value,isSuccess:u.value,error:c.value,onClose:N,onSend:X},null,8,["isOpen","isLoading","isSuccess","error"])],64))}}),Cp={class:"bg-white/95 dark:bg-black/20 backdrop-blur-xl border border-stroke dark:border-white/10 rounded-2xl h-full p-6 overflow-auto shadow-2xl"},_p={class:"mb-6 flex items-center justify-between"},xp={class:"text-content-secondary dark:text-[#C3C3C3] text-sm"},wp=["title"],kp={class:"text-content-secondary dark:text-[#C3C3C3] text-sm mt-1"},Ep={class:"mt-3 p-2 rounded-[10px] border border-stroke-subtle dark:border-white/10 bg-white dark:bg-white/5"},Sp={class:"flex items-center justify-between"},Ap={class:"flex items-center gap-3 mt-1.5 text-[10px] text-content-muted"},Rp={class:"text-green-600 dark:text-green-400"},Tp={class:"text-red-600 dark:text-red-400"},Op={key:0,class:"text-orange-600 dark:text-orange-400"},Mp={class:"mb-4"},Pp={class:"mb-4"},Lp={class:"space-y-2 mb-3"},Np=["onClick"],Ip={class:"mb-4"},Dp={class:"space-y-2 mb-3"},$p=["onClick"],Fp={class:"mb-4"},Vp={class:"space-y-2 mb-3"},Bp=["onClick"],Hp={class:"mb-4"},jp={class:"space-y-2 mb-3"},Up=["onClick"],qp=["disabled"],Kp={class:"flex items-center gap-3"},Wp=["disabled"],Zp={class:"flex items-center gap-3"},Gp={class:"mb-4"},zp={key:0,class:"mt-2 glass-card px-3 py-2 rounded-lg border border-stroke-subtle dark:border-stroke/30 space-y-2 text-xs animate-fade-in"},Jp={class:"space-y-1"},Yp={class:"flex items-center justify-between"},Qp={class:"text-content-primary dark:text-content-primary font-mono"},Xp={key:0,class:"pl-2 space-y-0.5 text-[10px] text-content-secondary dark:text-content-muted"},e5={key:0,class:"flex items-center gap-1"},t5={class:"bg-white/5 dark:bg-black/20 px-1 py-0.5 rounded"},n5={class:"space-y-1"},r5={class:"flex items-center justify-between"},s5={class:"text-content-primary dark:text-content-primary font-mono"},o5={key:0,class:"pl-2 space-y-0.5 text-[10px] text-content-secondary dark:text-content-muted"},i5={key:0,class:"flex items-center gap-1"},a5={class:"bg-white/5 dark:bg-black/20 px-1 py-0.5 rounded"},l5={key:1,class:"mb-4"},c5={class:"text-content-muted text-xs mb-2"},u5={class:"text-content-primary dark:text-white"},d5={class:"w-full h-1 bg-stroke-subtle dark:bg-white/10 rounded-full overflow-hidden"},f5={class:"text-content-muted text-xs"},p5=ht({name:"MobileSidebar",__name:"MobileSidebar",props:{showMobileSidebar:{type:Boolean}},emits:["update:showMobileSidebar","close"],setup(e,{emit:t}){const n=Pu(()=>We(()=>Promise.resolve().then(()=>C2),void 0)),r=Z(!1),s=e,o=t,i=Ro(),a=To(),l=yr();Et(()=>s.showMobileSidebar,me=>{me&&!r.value?setTimeout(()=>{r.value=!0},100):me||(r.value=!1)});const u=Z(!1),c=Z(!1),d=Z(!1),f=Z(!1),y=Z(!1),h=Z(null);let v=null,w=null;const S=Z("unknown"),L=Z(0),R=Z(0),M=Z(0);hn(()=>{v=window.setInterval(()=>{ee.value=new Date().toLocaleTimeString()},1e3),F(),w=window.setInterval(()=>{F()},3e4)}),dr(()=>{v&&clearInterval(v),w&&clearInterval(w)});const F=async()=>{try{const G=(await Fe.get("/advert_rate_limit_stats"))?.data;S.value=typeof G?.adaptive?.current_tier=="string"?G.adaptive.current_tier:"unknown",L.value=G?.stats?.adverts_allowed||0,R.value=G?.stats?.adverts_dropped||0,M.value=Object.keys(G?.active_penalties||{}).length}catch{S.value="unknown",L.value=0,R.value=0,M.value=0}},Y=oe(()=>{switch(S.value){case"quiet":return"bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-400 border-green-500/50";case"normal":return"bg-blue-100 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 border-blue-500/50";case"busy":return"bg-yellow-100 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 border-yellow-500/50";case"congested":return"bg-red-100 dark:bg-red-500/20 text-red-700 dark:text-red-400 border-red-500/50";default:return"bg-gray-100 dark:bg-gray-500/20 text-gray-700 dark:text-gray-400 border-gray-500/50"}}),q={dashboard:xc,neighbors:Ac,statistics:Sc,"system-stats":Wr,sessions:Wr,configuration:wn,"room-servers":wn,companions:wn,logs:kc,terminal:Ec,help:wc},X=[{name:"Dashboard",icon:"dashboard",route:"/"},{name:"Neighbors",icon:"neighbors",route:"/neighbors"},{name:"Statistics",icon:"statistics",route:"/statistics"},{name:"System Stats",icon:"system-stats",route:"/system-stats"},{name:"Sessions",icon:"sessions",route:"/sessions"},{name:"Configuration",icon:"configuration",route:"/configuration"},{name:"Terminal",icon:"terminal",route:"/terminal"},{name:"Room Servers",icon:"room-servers",route:"/room-servers"},{name:"Companions",icon:"companions",route:"/companions"},{name:"Logs",icon:"logs",route:"/logs"},{name:"Help",icon:"help",route:"/help"}],N=oe(()=>me=>a.path===me),Q=me=>{i.push(me),j()},j=()=>{o("update:showMobileSidebar",!1)},_=()=>{mn(),i.push("/login"),j()},K=async()=>{u.value=!0,h.value=null;try{await l.sendAdvert(),y.value=!0,setTimeout(()=>{I()},2e3)}catch(me){h.value=me instanceof Error?me.message:"Unknown error occurred",console.error("Failed to send advert:",me)}finally{u.value=!1}},I=()=>{f.value=!1,y.value=!1,h.value=null,u.value=!1},ne=async()=>{if(!c.value){c.value=!0;try{await l.toggleMode()}catch(me){console.error("Failed to toggle mode:",me)}finally{c.value=!1}}},se=async()=>{if(!d.value){d.value=!0;try{await l.toggleDutyCycle()}catch(me){console.error("Failed to toggle duty cycle:",me)}finally{d.value=!1}}},ee=Z(new Date().toLocaleTimeString()),te=Z(!1),pe=oe(()=>l.version.includes("dev")||l.coreVersion.includes("dev")),H=me=>{const G=me.match(/^([\d.]+)(\.dev(\d+))?((\+g)([a-f0-9]+))?$/);return G?{base:G[1],isDev:!!G[2],devNumber:G[3]||null,commit:G[6]||null}:{base:me,isDev:!1,devNumber:null,commit:null}},re=oe(()=>H(l.version)),ve=oe(()=>H(l.coreVersion)),Ye=oe(()=>{const me=l.dutyCyclePercentage;let G="#A5E5B6";return me>90?G="#FB787B":me>70&&(G="#FFC246"),{width:me===0?".125rem":`${Math.max(me,2)}%`,backgroundColor:G}});return(me,G)=>(O(),D(Oe,null,[p("div",{class:ce(["fixed inset-0 z-[1010] lg:hidden transition-opacity duration-300",me.showMobileSidebar?"opacity-100 pointer-events-auto":"opacity-0 pointer-events-none"])},[p("div",{class:"absolute inset-0 bg-black/30 backdrop-blur-sm dark:bg-black/30",onClick:j}),p("div",{class:ce(["absolute left-0 top-0 bottom-0 w-72 p-4 transition-transform duration-300",me.showMobileSidebar?"translate-x-0":"-translate-x-full"])},[p("div",Cp,[p("div",_p,[p("div",null,[G[3]||(G[3]=p("h1",{class:"text-content-heading dark:text-white text-[1.25rem] font-bold"},"pyMC Repeater",-1)),p("p",xp,[Ne(W(de(l).nodeName)+" ",1),p("span",{class:ce(["inline-block w-2 h-2 rounded-full ml-2",de(l).statusBadge.text==="Active"?"bg-accent-green":de(l).statusBadge.text==="Monitor Mode"?"bg-secondary":"bg-accent-red"]),title:de(l).statusBadge.title},null,10,wp)]),p("p",kp,"<"+W(de(l).pubKey)+">",1),p("div",Ep,[p("div",Sp,[G[2]||(G[2]=p("span",{class:"text-content-muted text-[10px] uppercase tracking-wide"},"Adaptive",-1)),p("div",{class:ce(["inline-flex items-center px-2 py-0.5 rounded-full border text-[10px] font-semibold",Y.value])},W(S.value.toUpperCase()),3)]),p("div",Ap,[p("span",Rp,"OK: "+W(L.value),1),p("span",Tp,"Drop: "+W(R.value),1),M.value>0?(O(),D("span",Op,"Pen: "+W(M.value),1)):be("",!0)])])]),p("button",{onClick:j,class:"text-content-primary dark:text-content-muted hover:text-content-heading dark:hover:text-white"},"✕")]),G[20]||(G[20]=p("div",{class:"border-t border-stroke dark:border-dark-border mb-4"},null,-1)),p("div",Mp,[G[5]||(G[5]=p("p",{class:"text-content-muted text-xs uppercase mb-2"},"Actions",-1)),p("button",{onClick:G[0]||(G[0]=ge=>{f.value=!0,j()}),class:"w-full bg-white dark:bg-white/10 rounded-[.625rem] py-3 px-4 flex items-center gap-2 text-sm font-medium text-[#212122] dark:text-white border border-stroke-subtle dark:border-white/10 hover:bg-gray-100 dark:hover:bg-white/20 transition-colors mb-2"},G[4]||(G[4]=[p("svg",{class:"w-3.5 h-3.5",viewBox:"0 0 14 14",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[p("path",{d:"M7 0C5.61553 0 4.26216 0.410543 3.11101 1.17971C1.95987 1.94888 1.06266 3.04213 0.532846 4.32122C0.003033 5.6003 -0.13559 7.00777 0.134506 8.36563C0.404603 9.7235 1.07129 10.9708 2.05026 11.9497C3.02922 12.9287 4.2765 13.5954 5.63437 13.8655C6.99224 14.1356 8.3997 13.997 9.67879 13.4672C10.9579 12.9373 12.0511 12.0401 12.8203 10.889C13.5895 9.73785 14 8.38447 14 7C13.998 5.1441 13.2599 3.36479 11.9475 2.05247C10.6352 0.74015 8.8559 0.0020073 7 0V0ZM7 12.8333C5.84628 12.8333 4.71846 12.4912 3.75918 11.8502C2.79989 11.2093 2.05222 10.2982 1.61071 9.23232C1.16919 8.16642 1.05368 6.99353 1.27876 5.86197C1.50384 4.73042 2.05941 3.69102 2.87521 2.87521C3.69102 2.0594 4.73042 1.50383 5.86198 1.27875C6.99353 1.05367 8.16642 1.16919 9.23232 1.6107C10.2982 2.05221 11.2093 2.79989 11.8502 3.75917C12.4912 4.71846 12.8333 5.84628 12.8333 7C12.8316 8.54658 12.2165 10.0293 11.1229 11.1229C10.0293 12.2165 8.54658 12.8316 7 12.8333ZM8.16667 7C8.1676 7.20501 8.11448 7.40665 8.01268 7.58461C7.91087 7.76256 7.76397 7.91054 7.58677 8.01365C7.40957 8.11676 7.20833 8.17136 7.00332 8.17194C6.7983 8.17252 6.59675 8.11906 6.41897 8.01696C6.24119 7.91485 6.09346 7.7677 5.99065 7.59033C5.88784 7.41295 5.83358 7.21162 5.83335 7.0066C5.83312 6.80159 5.88691 6.60013 5.98932 6.42252C6.09172 6.24491 6.23912 6.09743 6.41667 5.99492V3.5H7.58334V5.99492C7.76016 6.09659 7.90713 6.24298 8.00952 6.41939C8.1119 6.5958 8.1661 6.79603 8.16667 7Z",fill:"currentColor"})],-1),Ne(" Send Advert ",-1)]))]),p("div",Pp,[G[6]||(G[6]=p("p",{class:"text-content-muted text-xs uppercase mb-2"},"Monitoring",-1)),p("div",Lp,[(O(!0),D(Oe,null,at(X.slice(0,3),ge=>(O(),D("button",{key:ge.name,onClick:E=>Q(ge.route),class:ce([N.value(ge.route)?"bg-primary/20 shadow-[0_0_.375rem_0_rgba(170,232,232,0.20)] text-primary":"text-content-primary dark:text-white hover:bg-content-primary/10 dark:hover:bg-white/5","w-full rounded-[.625rem] py-3 px-4 flex items-center gap-3 text-sm transition-all"])},[(O(),rt(Zt(q[ge.icon]),{class:"w-3.5 h-3.5"})),Ne(" "+W(ge.name),1)],10,Np))),128))])]),p("div",Ip,[G[7]||(G[7]=p("p",{class:"text-content-muted text-xs uppercase mb-2"},"System",-1)),p("div",Dp,[(O(!0),D(Oe,null,at(X.slice(3,7),ge=>(O(),D("button",{key:ge.name,onClick:E=>Q(ge.route),class:ce([N.value(ge.route)?"bg-primary/20 shadow-[0_0_.375rem_0_rgba(170,232,232,0.20)] text-primary":"text-content-primary dark:text-white hover:bg-content-primary/10 dark:hover:bg-white/5","w-full rounded-[.625rem] py-3 px-4 flex items-center gap-3 text-sm transition-all"])},[(O(),rt(Zt(q[ge.icon]),{class:"w-3.5 h-3.5"})),Ne(" "+W(ge.name),1)],10,$p))),128))])]),p("div",Fp,[G[8]||(G[8]=p("p",{class:"text-content-muted text-xs uppercase mb-2"},"Room Servers & Companions",-1)),p("div",Vp,[(O(!0),D(Oe,null,at(X.slice(7,9),ge=>(O(),D("button",{key:ge.name,onClick:E=>Q(ge.route),class:ce([N.value(ge.route)?"bg-primary/20 shadow-[0_0_.375rem_0_rgba(170,232,232,0.20)] text-primary":"text-content-primary dark:text-white hover:bg-content-primary/10 dark:hover:bg-white/5","w-full rounded-[.625rem] py-3 px-4 flex items-center gap-3 text-sm transition-all"])},[(O(),rt(Zt(q[ge.icon]),{class:"w-3.5 h-3.5"})),Ne(" "+W(ge.name),1)],10,Bp))),128))])]),p("div",Hp,[G[9]||(G[9]=p("p",{class:"text-content-muted text-xs uppercase mb-2"},"Other",-1)),p("div",jp,[(O(!0),D(Oe,null,at(X.slice(9),ge=>(O(),D("button",{key:ge.name,onClick:E=>Q(ge.route),class:ce([N.value(ge.route)?"bg-primary/20 shadow-[0_0_.375rem_0_rgba(170,232,232,0.20)] text-primary":"text-content-primary dark:text-white hover:bg-content-primary/10 dark:hover:bg-white/5","w-full rounded-[.625rem] py-3 px-4 flex items-center gap-3 text-sm transition-all"])},[(O(),rt(Zt(q[ge.icon]),{class:"w-3.5 h-3.5"})),Ne(" "+W(ge.name),1)],10,Up))),128))])]),r.value?(O(),rt(de(n),{key:0,"current-value":de(l).noiseFloorDbm||-116,"update-interval":3e3,limit:50,class:"mb-4"},null,8,["current-value"])):be("",!0),p("button",{onClick:ne,disabled:c.value,class:ce(["p-4 flex items-center justify-between mb-3 w-full transition-all duration-200 cursor-pointer group",de(l).modeButtonState.warning?"glass-card-orange hover:bg-accent-red/10":"glass-card-green hover:bg-accent-green/10"])},[p("div",Kp,[xe(Rc,{class:"w-4 h-4 text-content-primary dark:text-white group-hover:text-primary transition-colors"}),G[10]||(G[10]=p("span",{class:"text-content-primary dark:text-white text-sm group-hover:text-primary transition-colors"},"Mode",-1))]),p("span",{class:ce(["text-xs font-medium group-hover:text-primary dark:group-hover:text-white transition-colors",de(l).modeButtonState.warning?"text-accent-red":"text-accent-green"])},W(c.value?"Changing...":de(l).currentMode.charAt(0).toUpperCase()+de(l).currentMode.slice(1)),3)],10,qp),p("button",{onClick:se,disabled:d.value,class:ce(["p-4 flex items-center justify-between mb-3 w-full transition-all duration-200 cursor-pointer group",de(l).dutyCycleButtonState.warning?"glass-card-orange hover:bg-accent-red/10":"glass-card-green hover:bg-accent-green/10"])},[p("div",Zp,[xe(Tc,{class:"w-3.5 h-3.5 text-content-primary dark:text-white group-hover:text-primary transition-colors"}),G[11]||(G[11]=p("span",{class:"text-content-primary dark:text-white text-sm group-hover:text-primary transition-colors"},"Duty Cycle",-1))]),p("span",{class:ce(["text-xs font-medium group-hover:text-primary dark:group-hover:text-white transition-colors",de(l).dutyCycleButtonState.warning?"text-accent-red":"text-primary"])},W(d.value?"Changing...":de(l).dutyCycleEnabled?"Enabled":"Disabled"),3)],10,Wp),p("button",{onClick:_,class:"w-full glass-card-orange hover:bg-accent-red/10 rounded-[.625rem] py-3 px-4 flex items-center justify-center gap-2 text-sm font-medium text-content-primary dark:text-white transition-all mb-4"},G[12]||(G[12]=[p("svg",{class:"w-4 h-4",viewBox:"0 0 20 20",fill:"none",stroke:"currentColor","stroke-width":"1.5",xmlns:"http://www.w3.org/2000/svg"},[p("path",{d:"M13 3H15C16.1046 3 17 3.89543 17 5V15C17 16.1046 16.1046 17 15 17H13M8 7L4 10.5M4 10.5L8 14M4 10.5H13","stroke-linecap":"round","stroke-linejoin":"round"})],-1),Ne(" Logout ",-1)])),p("div",Gp,[p("div",{onClick:G[1]||(G[1]=ge=>te.value=!te.value),class:"flex items-center gap-2 cursor-pointer group"},[p("span",{class:ce(["glass-card px-2 py-1 text-xs font-medium rounded border transition-all duration-200","border-stroke dark:border-dark-border",re.value.isDev?"text-secondary bg-secondary-bg/20 dark:bg-secondary-bg/10 border-secondary/40":"text-content-muted"])}," R:v"+W(re.value.base)+W(re.value.isDev?`.dev${re.value.devNumber}`:""),3),p("span",{class:ce(["glass-card px-2 py-1 text-xs font-medium rounded border transition-all duration-200","border-stroke dark:border-dark-border",ve.value.isDev?"text-secondary bg-secondary-bg/20 dark:bg-secondary-bg/10 border-secondary/40":"text-content-muted"])}," C:v"+W(ve.value.base)+W(ve.value.isDev?`.dev${ve.value.devNumber}`:""),3),pe.value?(O(),D("svg",{key:0,class:ce(["w-3 h-3 text-content-muted transition-transform duration-200",te.value?"rotate-180":""]),fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},G[13]||(G[13]=[p("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M19 9l-7 7-7-7"},null,-1)]),2)):be("",!0)]),te.value?(O(),D("div",zp,[p("div",Jp,[p("div",Yp,[G[14]||(G[14]=p("span",{class:"text-content-muted font-medium"},"Repeater:",-1)),p("span",Qp,"v"+W(re.value.base),1)]),re.value.isDev?(O(),D("div",Xp,[p("div",null,"Dev Build: "+W(re.value.devNumber),1),re.value.commit?(O(),D("div",e5,[G[15]||(G[15]=p("span",null,"Commit:",-1)),p("code",t5,W(re.value.commit),1)])):be("",!0)])):be("",!0)]),G[18]||(G[18]=p("div",{class:"border-t border-stroke-subtle dark:border-stroke/20"},null,-1)),p("div",n5,[p("div",r5,[G[16]||(G[16]=p("span",{class:"text-content-muted font-medium"},"Core:",-1)),p("span",s5,"v"+W(ve.value.base),1)]),ve.value.isDev?(O(),D("div",o5,[p("div",null,"Dev Build: "+W(ve.value.devNumber),1),ve.value.commit?(O(),D("div",i5,[G[17]||(G[17]=p("span",null,"Commit:",-1)),p("code",a5,W(ve.value.commit),1)])):be("",!0)])):be("",!0)])])):be("",!0)]),G[21]||(G[21]=p("div",{class:"border-t border-accent-green mb-4"},null,-1)),de(l).dutyCycleEnabled?(O(),D("div",l5,[p("p",c5,[G[19]||(G[19]=Ne(" Duty Cycle: ",-1)),p("span",u5,W(de(l).dutyCycleUtilization.toFixed(1))+"% / "+W(de(l).dutyCycleMax.toFixed(1))+"%",1)]),p("div",d5,[p("div",{class:"h-full rounded-full transition-all duration-300",style:On(Ye.value)},null,4)])])):be("",!0),p("p",f5,"Last Updated: "+W(ee.value),1)])],2)],2),xe(_c,{isOpen:f.value,isLoading:u.value,isSuccess:y.value,error:h.value,onClose:I,onSend:K},null,8,["isOpen","isLoading","isSuccess","error"])],64))}}),Oc="theme-preference",wt=Z("dark"),ua=Z(!1);function Mc(e){const t=document.documentElement;e==="dark"?t.classList.add("dark"):t.classList.remove("dark")}function h5(){if(ua.value)return;const e=localStorage.getItem(Oc);e&&(e==="light"||e==="dark")?wt.value=e:window.matchMedia("(prefers-color-scheme: light)").matches?wt.value="light":wt.value="dark",Mc(wt.value),ua.value=!0}typeof window<"u"&&h5();Et(wt,e=>{localStorage.setItem(Oc,e),Mc(e)});function m5(){return{theme:wt,toggleTheme:()=>{wt.value=wt.value==="dark"?"light":"dark"},setTheme:r=>{wt.value=r},isDark:()=>wt.value==="dark"}}const g5=["aria-label","title"],y5={key:0,xmlns:"http://www.w3.org/2000/svg",class:"w-5 h-5 text-yellow-600 dark:text-yellow-400",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor","stroke-width":"2"},v5={key:1,xmlns:"http://www.w3.org/2000/svg",class:"w-5 h-5 text-content-secondary dark:text-content",fill:"none",viewBox:"0 0 24 24",stroke:"currentColor","stroke-width":"2"},b5=ht({__name:"ThemeToggle",setup(e){const{theme:t,toggleTheme:n}=m5();return(r,s)=>(O(),D("button",{onClick:s[0]||(s[0]=(...o)=>de(n)&&de(n)(...o)),class:"w-[35px] h-[35px] rounded bg-background-mute dark:bg-surface-elevated flex items-center justify-center hover:bg-stroke-subtle dark:hover:bg-stroke/30 transition-colors","aria-label":de(t)==="dark"?"Switch to light mode":"Switch to dark mode",title:de(t)==="dark"?"Switch to light mode":"Switch to dark mode"},[de(t)==="dark"?(O(),D("svg",y5,s[1]||(s[1]=[p("path",{"stroke-linecap":"round","stroke-linejoin":"round",d:"M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"},null,-1)]))):(O(),D("svg",v5,s[2]||(s[2]=[p("path",{"stroke-linecap":"round","stroke-linejoin":"round",d:"M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"},null,-1)])))],8,g5))}}),C5={class:"glass-card p-3 sm:p-6 mb-5 rounded-[20px] relative z-10"},_5={class:"flex justify-between items-center"},x5={class:"flex items-center gap-3"},w5={class:"hidden sm:block"},k5={class:"text-content-primary dark:text-content-primary text-2xl lg:text-[35px] font-bold mb-1 sm:mb-2"},E5={class:"flex items-center gap-3 sm:gap-4"},S5={class:"text-right",style:{"min-width":"180px"}},A5={key:0,class:"flex items-center gap-2 justify-end"},R5={key:1,class:"space-y-1"},T5={class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},O5={class:"text-primary font-medium"},M5={key:0,class:"text-xs text-content-muted dark:text-content-muted/80",style:{"min-height":"16px"}},P5={key:0},L5={key:2},N5={key:0,class:"text-xs text-content-muted dark:text-content-muted/60 hidden sm:block",style:{"min-height":"16px"}},I5={class:"flex items-center justify-between mb-3"},D5={class:"flex items-center gap-2"},$5=["disabled"],F5=["disabled"],V5={class:"space-y-3 text-sm"},B5={key:0,class:"bg-red-50 dark:bg-background-mute p-3 rounded-lg border border-accent-red/30 border-l-2 border-l-accent-red"},H5={class:"flex items-center justify-between"},j5={class:"text-accent-red font-bold"},U5={class:"text-xs text-content-muted dark:text-content-muted mt-1"},q5={key:1,class:"bg-green-50 dark:bg-background-mute p-3 rounded-lg border border-stroke-subtle dark:border-stroke/10 border-l-2 border-l-accent-green"},K5={class:"flex items-center justify-between"},W5={class:"text-accent-green font-bold"},Z5={key:0,class:"text-xs text-content-muted dark:text-content-muted mt-1"},G5={key:2,class:"bg-background-mute dark:bg-background-mute p-3 rounded-lg border border-stroke-subtle dark:border-stroke/10"},z5={key:3,class:"bg-red-50 dark:bg-background-mute p-3 rounded-lg border border-accent-red/30 border-l-2 border-l-accent-red"},J5={class:"text-xs text-content-secondary dark:text-content-muted"},Y5={class:"bg-background-mute dark:bg-background-mute p-3 rounded-lg border border-stroke-subtle dark:border-stroke/10 border-l-2 border-l-primary"},Q5={class:"flex items-center justify-between"},X5={class:"text-primary font-bold"},eh={key:0,class:"text-xs text-content-muted dark:text-content-muted mt-1"},th={class:"flex items-center justify-between"},nh={class:"text-content-primary dark:text-content-primary font-medium"},rh={key:0,class:"mt-2"},sh={class:"text-xs text-content-muted dark:text-content-muted"},oh={class:"text-content-secondary dark:text-content-secondary"},ih={key:4,class:"bg-background-mute dark:bg-background-mute p-4 rounded-lg border border-stroke-subtle dark:border-stroke/10 text-center"},ah={key:5,class:"bg-background-mute dark:bg-background-mute p-3 rounded-lg border border-stroke-subtle dark:border-stroke/10 text-center"},lh=ht({name:"TopBar",__name:"TopBar",emits:["toggleMobileSidebar"],setup(e,{emit:t}){const n=t,r=Ro(),s=yr(),o=Z(!1),i=Z(null),a=Z({hasUpdate:!1,currentVersion:"",latestVersion:"",isChecking:!1,lastChecked:null,error:null}),l=Z({}),u=Z(!0),c=Z(null),d=Z(e2()||"User"),f=["Chat Node","Repeater","Room Server"];function y(j){const _=j.target;i.value&&!i.value.contains(_)&&(o.value=!1)}const h=async()=>{try{u.value=!0;const j={};for(const _ of f)try{const K=await Fe.get(`/adverts_by_contact_type?contact_type=${encodeURIComponent(_)}&hours=168`);K.success&&Array.isArray(K.data)?j[_]=K.data:j[_]=[]}catch(K){console.error(`Error fetching ${_} nodes:`,K),j[_]=[]}l.value=j,c.value=new Date}catch(j){console.error("Error updating tracked nodes:",j)}finally{u.value=!1}},v=async()=>{if(!a.value.isChecking)try{a.value.isChecking=!0,a.value.error=null,await s.fetchStats();const j=s.version;if(!j||j==="Unknown"){a.value.error="Unable to determine current version";return}const K=await fetch("https://raw.githubusercontent.com/rightup/pyMC_Repeater/main/repeater/__init__.py");if(!K.ok)throw new Error(`GitHub request failed: ${K.status}`);const ne=(await K.text()).match(/__version__\s*=\s*["']([^"']+)["']/);if(!ne)throw new Error("Could not parse version from GitHub file");const se=ne[1];a.value.currentVersion=j,a.value.latestVersion=se,a.value.lastChecked=new Date,a.value.hasUpdate=j!==se}catch(j){console.error("Error checking for updates:",j),a.value.error=j instanceof Error?j.message:"Failed to check for updates"}finally{a.value.isChecking=!1}},w=()=>{mn(),r.push("/login")},S=oe(()=>Object.values(l.value).reduce((_,K)=>_+K.length,0)),L=oe(()=>f.map(_=>({type:_,count:l.value[_]?.length||0})).filter(_=>_.count>0)),R=oe(()=>a.value.hasUpdate||S.value>0),M=j=>({"Chat Node":"text-blue-600 dark:text-blue-400",Repeater:"text-accent-green","Room Server":"text-accent-purple"})[j]||"text-gray-400",F=j=>{const _=l.value[j]||[];return _.length===0?"None":_.reduce((I,ne)=>ne.last_seen>I.last_seen?ne:I,_[0]).node_name||"Unknown Node"};let Y=null,q=null;const X=()=>{Y&&clearInterval(Y),Y=setInterval(()=>{h()},3e4),q&&clearInterval(q),q=setInterval(()=>{v()},6e5)},N=()=>{Y&&(clearInterval(Y),Y=null),q&&(clearInterval(q),q=null)};hn(()=>{document.addEventListener("click",y),h(),v(),X()}),ts(()=>{document.removeEventListener("click",y),N()});const Q=()=>{n("toggleMobileSidebar")};return(j,_)=>(O(),D("div",C5,[p("div",_5,[p("div",x5,[p("button",{onClick:Q,class:"lg:hidden w-10 h-10 rounded bg-background-mute dark:bg-surface-elevated flex items-center justify-center hover:bg-stroke-subtle dark:hover:bg-stroke/30 transition-colors"},_[2]||(_[2]=[p("svg",{class:"w-5 h-5 text-content-secondary dark:text-content-primary",viewBox:"0 0 20 20",fill:"none",xmlns:"http://www.w3.org/2000/svg"},[p("path",{d:"M3 6h14M3 10h14M3 14h14",stroke:"currentColor","stroke-width":"1.5","stroke-linecap":"round","stroke-linejoin":"round"})],-1)])),p("div",w5,[p("h1",k5,"Hi "+W(d.value)+"👋",1)])]),p("div",E5,[p("div",S5,[u.value?(O(),D("div",A5,_[3]||(_[3]=[p("div",{class:"animate-spin rounded-full h-3 w-3 border-b-2 border-primary"},null,-1),p("p",{class:"text-content-secondary dark:text-content-muted text-xs sm:text-sm"},"Loading...",-1)]))):S.value>0?(O(),D("div",R5,[p("p",T5,[_[4]||(_[4]=Ne(" Tracking: ",-1)),p("span",O5,W(S.value)+" node"+W(S.value===1?"":"s"),1)]),L.value.length>0?(O(),D("div",M5,[(O(!0),D(Oe,null,at(L.value,(K,I)=>(O(),D("span",{key:K.type,class:"inline"},[Ne(W(K.count)+" "+W(K.type)+W(K.count===1?"":"s"),1),I