diff --git a/.gitignore b/.gitignore index a01bc69..4cacd56 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ __pycache__/ *.so .Python build/ +.pybuild/ +repeater/_version.py develop-eggs/ dist/ downloads/ @@ -23,9 +25,16 @@ share/python-wheels/ *.egg-info/ .installed.cfg *.egg +DEBIAN/ +debian/files +debian/.debhelper/ +debian/pymc-repeater/ +debian/pymc-repeater.debhelper.log +debian/pymc-repeater.substvars # Virtual environments .venv/ +.venv_new/ env/ ENV/ @@ -43,8 +52,16 @@ htmlcov/ # Config config.yaml +config.yaml.backup identity.json +# Data +data/ + # Logs *.log .DS_Store +syncpi.sh + +# Docker +/data diff --git a/README.md b/README.md index d020fe2..fc174cd 100644 --- a/README.md +++ b/README.md @@ -30,18 +30,31 @@ The repeater daemon runs continuously as a background process, forwarding LoRa p ## Supported Hardware (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. Set `radio_type: kiss` in config and configure `kiss.port` and `kiss.baud_rate`. + +> [!CAUTION] +> ## Compatibility +> +> ### Supported Radio Interfaces +> +> | Interface | Supported | +> |------------|------------| +> | Native SPI radio SX1262 | ✅ Yes | +> | USB–SPI bridge (CH341F) | ✅ Yes | +> | UART-based HATs | ❌ No | +> | SX1302 concentrator boards | ❌ No | +> | SX1303 concentrator boards | ❌ No | +> +> This project supports **single-radio SPI transceivers only**, either: +> - Connected directly via SPI +> - Connected via a CH341F USB–SPI adapter +> - Connected using hardware that supports Meshcore Kiss Modem firmware + The following hardware is currently supported out-of-the-box: -Waveshare LoRaWAN/GNSS HAT (SPI Version Only) - - Hardware: Waveshare SX1262 LoRa HAT (SPI interface - UART version not supported) - Platform: Raspberry Pi (or compatible single-board computer) - Frequency: 868MHz (EU) or 915MHz (US) - TX Power: Up to 22dBm - SPI Bus: SPI0 - GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16 - Note: Only the SPI version is supported. The UART version will not work. - HackerGadgets uConsole Hardware: uConsole RTL-SDR/LoRa/GPS/RTC/USB Hub @@ -79,6 +92,27 @@ HT-RA62 module SPI Bus: SPI0 GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16, use_dio3_tcxo=True, use_dio2_rf=True +Zindello Industries UltraPeater + + Hardware: EBYTE E22/P 1W Module + Platform: Luckfox Pico Ultra/W (NOT A PI DEVICE) + Frequency: 868MHz (EU) or 915Mhz (US/AU) + Tx Power: Up to 30dBm + SPI Bus: SPI0 + GPIO Pins: CS=16, Reset=22, Busy=11, IRQ=10, TXEN=20 , RXEN=21 (E22 Only), EN=21 (E22P Only), TXLED=9, RXLED=1, use_dio2_rf=False, use_dio3_tcxo=True, use_gpiod_backend=True, gpio_chip=1 + +Waveshare LoRaWAN/GNSS HAT (SPI Version Only) + + NO LONGER RECOMMENDED + Note: May experience issues on "Narrow" (62.5KHz) settings due to a lack of TCXO + Hardware: Waveshare SX1262 LoRa HAT (SPI interface - UART version not supported) + Platform: Raspberry Pi (or compatible single-board computer) + Frequency: 868MHz (EU) or 915MHz (US) + TX Power: Up to 22dBm + SPI Bus: SPI0 + GPIO Pins: CS=21, Reset=18, Busy=20, IRQ=16 + Note: Only the SPI version is supported. The UART version will not work. + ... ## Screenshots @@ -166,6 +200,18 @@ The configuration file is created and configured during installation at: /etc/pymc_repeater/config.yaml ``` +### Optional pyMC_Glass integration +The repeater now supports an additive `glass` config section for central control-plane integration. +When enabled, it sends periodic `/inform` payloads to pyMC_Glass, receives queued commands, and reports command results on the next inform cycle. + +Minimal example: +```yaml +glass: + enabled: true + base_url: "http://localhost:8080" + inform_interval_seconds: 30 +``` + To reconfigure radio and hardware settings after installation, run: ```bash sudo bash setup-radio-config.sh /etc/pymc_repeater @@ -194,6 +240,91 @@ The upgrade script will: - Restart the service automatically - Preserve your existing configuration +--- + +## Installing on Proxmox (LXC Container) + +pyMC Repeater can run inside a Proxmox LXC container using a **CH341 USB-to-SPI adapter** for radio communication. This is ideal for headless, always-on deployments without dedicating a full Raspberry Pi. + +### Requirements + +- **Proxmox VE 7.x or 8.x** host +- **CH341 USB-to-SPI adapter** (VID `1a86`, PID `5512`) connected to the Proxmox host +- **SX1262-based LoRa module** (e.g. Ebyte E22-900M30S) wired to the CH341 adapter +- Internet connectivity for the container + +### One-Line Install + +Run this on the **Proxmox host** (not inside a container): + +```bash +bash -c "$(curl -fsSL https://raw.githubusercontent.com/rightup/pyMC_Repeater/feat/newRadios/scripts/proxmox-install.sh)" +``` + +> **Tip:** Replace `feat/newRadios` in the URL with whichever branch you want to install. + +The installer will interactively prompt you for container settings (hostname, RAM, disk, bridge, etc.) and then: + +1. Download a Debian 12 LXC template +2. Create a **privileged** container with USB passthrough +3. Install a host-side udev rule for the CH341 device +4. Clone the repository and pre-seed the config with CH341 GPIO pin mappings +5. Run `manage.sh install` inside the container +6. Display the dashboard URL when finished + +### Default Container Settings + +| Setting | Default | +|-----------|-----------------| +| Hostname | `pymc-repeater` | +| RAM | 1024 MB | +| Disk | 4 GB | +| CPU cores | 2 | +| Bridge | `vmbr0` | +| Storage | `local-lvm` | +| Password | `pymc` | + +### After Installation + +```bash +# Enter the container +pct enter + +# View service logs +journalctl -u pymc-repeater -f + +# Access web dashboard +http://:8000 + +# Manage the repeater +cd /opt/pymc_repeater && bash manage.sh +``` + +### CH341 GPIO Pin Mapping + +The installer pre-configures the CH341 GPIO pins for an E22 module. These differ from the Raspberry Pi BCM pin numbers: + +| Function | CH341 GPIO | Pi BCM (default) | +|----------|-----------|-------------------| +| CS | 0 | 21 | +| RXEN | 1 | -1 | +| Reset | 2 | 18 | +| Busy | 4 | 20 | +| IRQ | 6 | 16 | + +The installer also enables `use_dio3_tcxo` and `use_dio2_rf` for E22 modules. + +### Troubleshooting (Proxmox) + +- **USB device not found**: Make sure the CH341 is plugged into the Proxmox host and shows up with `lsusb -d 1a86:5512` +- **Permission denied on USB**: The installer creates a host udev rule (`/etc/udev/rules.d/99-ch341.rules`). Run `udevadm trigger` on the host if needed +- **Container can't see USB**: Verify USB passthrough lines exist in `/etc/pve/lxc/.conf`: + ``` + lxc.cgroup2.devices.allow: c 189:* rwm + lxc.mount.entry: /dev/bus/usb dev/bus/usb none bind,optional,create=dir 0 0 + ``` +- **NoBackendError (libusb)**: The installer installs `libusb-1.0-0` automatically. If you see this error, run `apt-get install libusb-1.0-0` inside the container + @@ -212,6 +343,34 @@ This script will: The script will prompt you for each optional removal step. +## Docker Compose + +You can now run pyMC Repeater from within a [Docker Container](https://www.docker.com/). Checkout the example [Docker Compose](./docker-compose.yml) file before you get started. It will need some configuration changes based on what hardware you're using (USB vs SPI). Look at the commented out lines to see which hardware requires what lines and only enable what you need. + +Here is what you'll need to do in order to get the container running: + +1. Copy the `config.yaml.example` to `config.yaml` + +```bash +cp ./config.yaml.example ./config.yaml +``` + +2. Run the configuration script and follow the prompts. + +```bash +sudo bash ./setup-radio-config.sh +``` + +3. Modify the `config.yaml` file with a unique web UI password. This allows you to bypass the `/setup` page when logging for the first time. You can find the value under `repeater.security.admin_password`. Change to _anything_ besides the default of `admin123`. + +4. Configure the [docker compose](./docker-compose.yml) to your specific hardware and file paths. Be sure to comment-out or delete lines that aren't required for your hardware. Please note that your hardware devices might be at a different path than those listed in the docker compose file. + +5. Build and start the container. + +```bash +docker compose up -d --force-recreate --build +``` + ## Roadmap / Planned Features - [ ] **Public Map Integration** - Submit repeater location and details to public map for discovery @@ -259,8 +418,6 @@ Pre-commit hooks will automatically: - Lint with flake8 - Fix trailing whitespace and other file issues - - ## Support - [Core Lib Documentation](https://rightup.github.io/pyMC_core/) @@ -286,7 +443,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/buildroot-manage.sh b/buildroot-manage.sh new file mode 100644 index 0000000..6a0aa68 --- /dev/null +++ b/buildroot-manage.sh @@ -0,0 +1,1286 @@ +#!/bin/bash +# Buildroot/Luckfox management entrypoint for pyMC Repeater + +set -euo pipefail + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +INSTALL_DIR="/opt/pymc_repeater" +VENV_DIR="$INSTALL_DIR/venv" +VENV_PIP="$VENV_DIR/bin/pip" +VENV_PYTHON="$VENV_DIR/bin/python" +CONFIG_DIR="/etc/pymc_repeater" +LOG_DIR="/var/log/pymc_repeater" +DATA_DIR="/var/lib/pymc_repeater" +SERVICE_USER="root" +INIT_SCRIPT="/etc/init.d/S80pymc-repeater" +PIDFILE="/var/run/pymc-repeater.pid" +LOGFILE="$LOG_DIR/repeater.log" +SERVICE_NAME="pymc-repeater" +SILENT_MODE="${PYMC_SILENT:-${SILENT:-}}" + +R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps" +PIWHEELS_INDEX_URL="https://www.piwheels.org/simple" +R2_ENABLED=1 +PYMC_CORE_REPO="${PYMC_CORE_REPO:-https://github.com/rightup/pyMC_core.git}" +PYMC_CORE_REF="${PYMC_CORE_REF:-}" +RADIO_SETTINGS_JSON="$SCRIPT_DIR/radio-settings.json" +RADIO_PRESETS_JSON="$SCRIPT_DIR/radio-presets.json" +BUILDROOT_RADIO_SETTINGS_JSON="$SCRIPT_DIR/radio-settings-buildroot.json" +set_wheel_dependencies() { + set -- \ + "cherrypy>=18.0.0" \ + "cherrypy-cors==1.7.0" \ + "paho-mqtt>=1.6.0" \ + "pyjwt>=2.8.0" \ + "ws4py>=0.6.0" \ + "autocommand" \ + "backports.tarfile" \ + "jaraco.collections" \ + "jaraco.text" \ + "jaraco.context" \ + "tempora" \ + "zc.lockfile" \ + "httpagentparser>=1.5" + printf '%s\n' "$@" +} + +stage() { + printf '\n==> %s\n' "$1" +} + +info() { + printf ' - %s\n' "$1" +} + +warn() { + printf ' - %s\n' "$1" >&2 +} + +fail() { + printf '%s\n' "$1" >&2 + exit 1 +} + +detect_local_git_ref() { + local branch tag + + if ! git -C "$SCRIPT_DIR" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + return 1 + fi + + branch=$(git -C "$SCRIPT_DIR" symbolic-ref --quiet --short HEAD 2>/dev/null || true) + if [ -n "$branch" ]; then + printf '%s\n' "$branch" + return 0 + fi + + tag=$(git -C "$SCRIPT_DIR" describe --tags --exact-match 2>/dev/null || true) + if [ -n "$tag" ]; then + printf '%s\n' "$tag" + return 0 + fi + + return 1 +} + +remote_ref_exists() { + local repo="$1" + local ref="$2" + + [ -n "$ref" ] || return 1 + git ls-remote --exit-code --heads --tags "$repo" "$ref" >/dev/null 2>&1 +} + +resolve_core_ref() { + local candidate fallback + + if [ -n "$PYMC_CORE_REF" ]; then + printf '%s\n' "$PYMC_CORE_REF" + return 0 + fi + + candidate=$(detect_local_git_ref 2>/dev/null || true) + if [ -n "$candidate" ] && remote_ref_exists "$PYMC_CORE_REPO" "$candidate"; then + printf '%s\n' "$candidate" + return 0 + fi + + fallback="dev" + printf '%s\n' "$fallback" +} + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1" +} + +is_buildroot() { + [ -f /etc/pymc-image-build-id ] && return 0 + [ -f /etc/os-release ] && grep -q '^ID=buildroot$' /etc/os-release 2>/dev/null && return 0 + return 1 +} + +prompt_value() { + local prompt="$1" + local default_value="${2:-}" + local reply="" + + if [ ! -t 0 ]; then + printf '%s\n' "$default_value" + return 0 + fi + + if [ -n "$default_value" ]; then + printf '%s [%s]: ' "$prompt" "$default_value" >&2 + else + printf '%s: ' "$prompt" >&2 + fi + IFS= read -r reply || true + reply=${reply:-$default_value} + printf '%s\n' "$reply" +} + +prompt_secret() { + local prompt="$1" + + python3 - "$prompt" <<'PY' +import os +import sys +import termios +import tty + +prompt = sys.argv[1] +try: + tty_fd = os.open("/dev/tty", os.O_RDWR) +except OSError: + print("Interactive admin password prompt requires a TTY. Set PYMC_ADMIN_PASSWORD instead.", file=sys.stderr) + raise SystemExit(1) + +def read_secret(label: str) -> str: + os.write(tty_fd, f"{label}: ".encode()) + original = termios.tcgetattr(tty_fd) + chars = [] + try: + tty.setraw(tty_fd) + while True: + ch = os.read(tty_fd, 1) + if ch in (b"\r", b"\n"): + os.write(tty_fd, b"\r\n") + return "".join(chars) + if ch == b"\x03": + raise KeyboardInterrupt + if ch in (b"\x7f", b"\x08"): + if chars: + chars.pop() + os.write(tty_fd, b"\b \b") + continue + if not ch or ch[0] < 32: + continue + chars.append(ch.decode(errors="ignore")) + os.write(tty_fd, b"*") + finally: + termios.tcsetattr(tty_fd, termios.TCSADRAIN, original) + +while True: + first = read_secret(prompt) + second = read_secret(f"Confirm {prompt.lower()}") + + if not first: + print(" - Password cannot be empty.", file=sys.stderr) + continue + if len(first) < 6: + print(" - Password must be at least 6 characters.", file=sys.stderr) + continue + if first == "admin123": + print(" - Password cannot be the default admin123.", file=sys.stderr) + continue + if first != second: + print(" - Passwords do not match.", file=sys.stderr) + continue + + print(first) + break +os.close(tty_fd) +PY +} + +validate_node_name() { + local node_name="$1" + + python3 - "$node_name" <<'PY' +import sys + +node_name = sys.argv[1].strip() +if not node_name: + print("Repeater name cannot be empty.", file=sys.stderr) + raise SystemExit(1) +if node_name == "mesh-repeater-01": + print("Repeater name cannot be the default mesh-repeater-01.", file=sys.stderr) + raise SystemExit(1) +if len(node_name.encode("utf-8")) > 31: + print("Repeater name is too long (max 31 UTF-8 bytes).", file=sys.stderr) + raise SystemExit(1) +PY +} + +validate_admin_password() { + local admin_password="$1" + + python3 - "$admin_password" <<'PY' +import sys + +password = sys.argv[1] +if not password: + print("Admin password cannot be empty.", file=sys.stderr) + raise SystemExit(1) +if len(password) < 6: + print("Admin password must be at least 6 characters.", file=sys.stderr) + raise SystemExit(1) +if password == "admin123": + print("Admin password cannot be the default admin123.", file=sys.stderr) + raise SystemExit(1) +PY +} + +ensure_root() { + [ "$(id -u 2>/dev/null || echo 1)" -eq 0 ] || fail "This command must be run as root." +} + +group_exists() { + grep -q "^$1:" /etc/group 2>/dev/null +} + +ensure_group_line() { + local group_name="$1" + local gid="$2" + group_exists "$group_name" && return 0 + printf '%s:x:%s:\n' "$group_name" "$gid" >> /etc/group +} + +ensure_service_user() { + if [ "$SERVICE_USER" = "root" ]; then + return 0 + fi + + if id "$SERVICE_USER" >/dev/null 2>&1; then + return 0 + fi + + if command -v useradd >/dev/null 2>&1; then + useradd --system --home "$DATA_DIR" --shell /sbin/nologin "$SERVICE_USER" + return 0 + fi + + ensure_group_line "$SERVICE_USER" 990 + printf '%s:x:990:990::%s:/sbin/nologin\n' "$SERVICE_USER" "$DATA_DIR" >> /etc/passwd + if [ -f /etc/shadow ]; then + printf '%s:!:19000:0:99999:7:::\n' "$SERVICE_USER" >> /etc/shadow + fi +} + +add_user_to_group() { + local user_name="$1" + local group_name="$2" + local current_line current_members gid escaped_line new_members + + if [ "$user_name" = "root" ]; then + return 0 + fi + + group_exists "$group_name" || return 0 + current_line=$(grep "^${group_name}:" /etc/group 2>/dev/null || true) + [ -n "$current_line" ] || return 0 + current_members=$(printf '%s' "$current_line" | cut -d: -f4) + case ",${current_members}," in + *,"${user_name}",*) return 0 ;; + esac + + if [ -n "$current_members" ]; then + new_members="${current_members},${user_name}" + else + new_members="${user_name}" + fi + gid=$(printf '%s' "$current_line" | cut -d: -f3) + escaped_line=$(printf '%s\n' "$current_line" | sed 's/[].[^$\\*]/\\&/g') + sed -i "s/^${escaped_line}\$/${group_name}:x:${gid}:${new_members}/" /etc/group +} + +install_system_packages() { + if is_buildroot; then + info "Buildroot image detected; using preinstalled packages." + return 0 + fi + + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y \ + libffi-dev libusb-1.0-0 sudo jq pip python3-venv python3-rrdtool wget swig build-essential python3-dev +} + +ensure_venv() { + local recreate=0 + + if [ -f "$VENV_DIR/pyvenv.cfg" ] && grep -Eq '^include-system-site-packages *= *true$' "$VENV_DIR/pyvenv.cfg"; then + stage "Rebuilding virtual environment" + info "Existing venv uses system site-packages and is not supported on Buildroot." + recreate=1 + fi + + if [ "$recreate" -eq 1 ]; then + rm -rf "$VENV_DIR" + fi + + if [ ! -x "$VENV_PYTHON" ]; then + stage "Creating virtual environment" + info "Creating $VENV_DIR" + info "This can take a minute on Buildroot flash storage." + python3 -m venv "$VENV_DIR" + info "Bootstrapping pip, setuptools, and wheel" + "$VENV_PIP" install --upgrade --no-cache-dir pip setuptools wheel + info "Virtual environment is ready" + else + info "Using existing virtual environment at $VENV_DIR" + fi +} + +ensure_venv_build_backend() { + if "$VENV_PYTHON" - <<'PY' +import setuptools +import setuptools.build_meta +import wheel +PY + then + info "venv build backend is ready" + return 0 + fi + + stage "Rebuilding virtual environment" + warn "Existing venv is contaminated or incomplete; recreating it cleanly." + rm -rf "$VENV_DIR" + python3 -m venv "$VENV_DIR" + "$VENV_PIP" install --upgrade --no-cache-dir pip setuptools wheel + + if "$VENV_PYTHON" - <<'PY' +import setuptools +import setuptools.build_meta +import wheel +PY + then + info "venv build backend repaired" + return 0 + fi + + fail "Unable to prepare an isolated venv with setuptools.build_meta on this Buildroot image." +} + +cleanup_legacy_install_state() { + local removed=0 + local path + + for path in \ + "$INSTALL_DIR/repeater" \ + "$INSTALL_DIR/pymc_core" \ + "$INSTALL_DIR/pyMC_Repeater" \ + "$INSTALL_DIR/pyMC_core" + do + if [ -e "$path" ]; then + rm -rf "$path" + removed=1 + info "Removed stale source tree at $path" + fi + done + + if [ "$removed" -eq 0 ]; then + info "No stale source-tree paths found under $INSTALL_DIR" + fi +} + +get_r2_wheel_base() { + local machine_arch arch_tag platform_tag py_tag wheel_base + + [ "$R2_ENABLED" -eq 1 ] || return 1 + + machine_arch=$(uname -m) + case "$machine_arch" in + aarch64) arch_tag="arm64"; platform_tag="aarch64" ;; + armv7l|armv7) arch_tag="armv7"; platform_tag="armv7l" ;; + x86_64) arch_tag="x86_64"; platform_tag="x86_64" ;; + *) return 1 ;; + esac + + py_tag=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311") + wheel_base="${R2_BASE_URL}/${arch_tag}/${platform_tag}/${py_tag}" + printf '%s\n' "$wheel_base" +} + +preinstall_r2_wheels() { + local wheel_base + + wheel_base=$(get_r2_wheel_base 2>/dev/null || true) + [ -n "$wheel_base" ] || return 0 + + stage "Checking optional wheel cache" + info "Native Python modules come from the Buildroot image." + info "Skipping native wheel preload from ${wheel_base}/index.html" +} + +install_buildroot_dependencies() { + local wheel_base + local deps + + wheel_base=$(get_r2_wheel_base 2>/dev/null || true) + deps=$(set_wheel_dependencies) + stage "Installing Python dependency wheels" + if [ -n "$wheel_base" ]; then + info "Using Rightup wheels: ${wheel_base}/index.html" + fi + info "Using piwheels fallback: ${PIWHEELS_INDEX_URL}" + + if [ -n "$wheel_base" ]; then + # shellcheck disable=SC2086 + "$VENV_PIP" install --upgrade --no-cache-dir --only-binary=:all: \ + --find-links "${wheel_base}/index.html" \ + --extra-index-url "${PIWHEELS_INDEX_URL}" \ + $deps + else + # shellcheck disable=SC2086 + "$VENV_PIP" install --upgrade --no-cache-dir --only-binary=:all: \ + --extra-index-url "${PIWHEELS_INDEX_URL}" \ + $deps + fi +} + +link_system_site_packages() { + local venv_site_dir pth_file system_paths + + venv_site_dir=$("$VENV_PYTHON" - <<'PY' +import site +for path in site.getsitepackages(): + if path.endswith("site-packages"): + print(path) + break +PY +) + [ -n "$venv_site_dir" ] || fail "Could not determine venv site-packages directory." + + system_paths=$(python3 - <<'PY' +import site +for path in site.getsitepackages(): + if path.startswith("/usr/lib/python") and path.endswith("site-packages"): + print(path) +PY +) + + [ -n "$system_paths" ] || return 0 + + pth_file="$venv_site_dir/buildroot-system-site-packages.pth" + printf '%s\n' "$system_paths" > "$pth_file" + info "Linked image-provided Python runtime into the venv" +} + +install_core_into_venv() { + local core_repo core_ref core_spec + + core_repo="$PYMC_CORE_REPO" + case "$core_repo" in + *.git) ;; + *) core_repo="${core_repo}.git" ;; + esac + core_ref=$(resolve_core_ref) + core_spec="pyMC_core[hardware] @ git+${core_repo}@${core_ref}" + stage "Installing pyMC_core" + info "Repo: ${PYMC_CORE_REPO}" + info "Ref: ${core_ref}" + "$VENV_PIP" install --upgrade --no-cache-dir --no-deps --no-build-isolation "$core_spec" +} + +install_repeater_package() { + stage "Installing pyMC Repeater into venv" + info "Installing checked-out repo without re-resolving dependencies" + "$VENV_PIP" install --upgrade --no-cache-dir --no-deps --no-build-isolation "$SCRIPT_DIR" +} + +create_init_script() { + cat > "$INIT_SCRIPT" <<'EOF' +#!/bin/sh +DAEMON="__DAEMON__" +PIDFILE="__PIDFILE__" +LOGFILE="__LOGFILE__" +WORKDIR="__WORKDIR__" +CONFIG_FILE="__CONFIG_FILE__" +RUN_AS="__RUN_AS__" + +start() { + mkdir -p "$(dirname "$PIDFILE")" "$(dirname "$LOGFILE")" "$WORKDIR" + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "__SERVICE_NAME__ is already running." + return 0 + fi + start-stop-daemon --start --quiet --background --make-pidfile --pidfile "$PIDFILE" \ + --chuid "$RUN_AS" --exec /bin/sh -- -c "cd \"$WORKDIR\" && exec \"$DAEMON\" -m repeater.main --config \"$CONFIG_FILE\" >>\"$LOGFILE\" 2>&1" +} + +stop() { + if [ ! -f "$PIDFILE" ]; then + echo "__SERVICE_NAME__ is not running." + return 0 + fi + start-stop-daemon --stop --quiet --retry 5 --pidfile "$PIDFILE" >/dev/null 2>&1 || true + rm -f "$PIDFILE" +} + +status() { + if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then + echo "__SERVICE_NAME__ is running." + return 0 + fi + echo "__SERVICE_NAME__ is stopped." + return 1 +} + +case "${1:-}" in + start) start ;; + stop) stop ;; + restart) stop; start ;; + status) status ;; + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac +EOF + sed -i \ + -e "s|__DAEMON__|${VENV_PYTHON}|g" \ + -e "s|__PIDFILE__|${PIDFILE}|g" \ + -e "s|__LOGFILE__|${LOGFILE}|g" \ + -e "s|__WORKDIR__|${DATA_DIR}|g" \ + -e "s|__CONFIG_FILE__|${CONFIG_DIR}/config.yaml|g" \ + -e "s|__RUN_AS__|${SERVICE_USER}|g" \ + -e "s|__SERVICE_NAME__|${SERVICE_NAME}|g" \ + "$INIT_SCRIPT" + chmod 0755 "$INIT_SCRIPT" +} + +get_primary_ip() { + ip -o -4 addr show dev eth0 2>/dev/null | awk 'NR==1 { sub(/\/.*/, "", $4); print $4; exit }' +} + +get_config_value() { + local key_path="$1" + local fallback="${2:-}" + + python3 - "$CONFIG_DIR/config.yaml" "$key_path" "$fallback" <<'PY' +import sys +import yaml + +config_path, key_path, fallback = sys.argv[1:4] +try: + with open(config_path, "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} +except FileNotFoundError: + print(fallback) + raise SystemExit(0) + +current = data +for part in key_path.split("."): + if isinstance(current, dict) and part in current: + current = current[part] + else: + print(fallback) + raise SystemExit(0) + +if current in (None, ""): + print(fallback) +elif isinstance(current, float): + rendered = ("%f" % current).rstrip("0").rstrip(".") + print(rendered or "0") +else: + print(current) +PY +} + +list_buildroot_boards() { + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +for index, (key, entry) in enumerate(data.get("buildroot_hardware", {}).items(), start=1): + print(f"{index}|{key}|{entry.get('name', key)}|{entry.get('description', '')}") +PY +} + +resolve_buildroot_board() { + local choice="$1" + + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" "$choice" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +choice = sys.argv[2].strip().lower() +boards = list((data.get("buildroot_hardware") or {}).items()) + +for index, (key, entry) in enumerate(boards, start=1): + aliases = {str(index), key.lower(), str(entry.get("name", "")).lower()} + aliases.update(alias.lower() for alias in entry.get("aliases", [])) + if choice in aliases: + print(key) + raise SystemExit(0) + +raise SystemExit(1) +PY +} + +get_default_buildroot_board() { + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +default_board = data.get("default_board") +boards = data.get("buildroot_hardware", {}) +if default_board and default_board in boards: + print(default_board) +else: + for key in boards: + print(key) + break +PY +} + +get_buildroot_board_label() { + local board_key="$1" + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" "$board_key" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +entry = (data.get("buildroot_hardware") or {}).get(sys.argv[2], {}) +print(entry.get("name", sys.argv[2])) +PY +} + +get_buildroot_board_field() { + local board_key="$1" + local field="$2" + + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" "$board_key" "$field" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +entry = (data.get("buildroot_hardware") or {}).get(sys.argv[2], {}) +value = entry.get(sys.argv[3], "") +if value is None: + value = "" +print(value) +PY +} + +list_radio_presets() { + python3 - "$RADIO_PRESETS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +entries = ((data.get("config") or {}).get("suggested_radio_settings") or {}).get("entries", []) +for index, entry in enumerate(entries, start=1): + print(f"{index}|{entry.get('title', '')}|{entry.get('description', '')}") +PY +} + +resolve_radio_preset() { + local choice="$1" + + python3 - "$RADIO_PRESETS_JSON" "$choice" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +choice = sys.argv[2].strip().lower() +entries = ((data.get("config") or {}).get("suggested_radio_settings") or {}).get("entries", []) +for index, entry in enumerate(entries, start=1): + title = entry.get("title", "") + if choice in {str(index), title.lower()}: + print(title) + raise SystemExit(0) + +raise SystemExit(1) +PY +} + +get_default_radio_preset() { + python3 - "$BUILDROOT_RADIO_SETTINGS_JSON" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +print(data.get("default_radio_preset", "")) +PY +} + +get_radio_preset_field() { + local preset_title="$1" + local field="$2" + + python3 - "$RADIO_PRESETS_JSON" "$preset_title" "$field" <<'PY' +import json +import sys + +with open(sys.argv[1], "r", encoding="utf-8") as fh: + data = json.load(fh) + +preset_title, field = sys.argv[2:4] +entries = ((data.get("config") or {}).get("suggested_radio_settings") or {}).get("entries", []) +for entry in entries: + if entry.get("title") == preset_title: + print(entry.get(field, "")) + raise SystemExit(0) + +raise SystemExit(1) +PY +} + +get_radio_frequency_mhz() { + python3 - "$CONFIG_DIR/config.yaml" <<'PY' +import yaml + +with open(__import__("sys").argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} +freq = ((data.get("radio") or {}).get("frequency")) or 910525000 +print(f"{float(freq) / 1_000_000:.3f}".rstrip("0").rstrip(".")) +PY +} + +get_radio_bandwidth_khz() { + python3 - "$CONFIG_DIR/config.yaml" <<'PY' +import yaml + +with open(__import__("sys").argv[1], "r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} +bw = ((data.get("radio") or {}).get("bandwidth")) or 62500 +print(f"{float(bw) / 1000:.3f}".rstrip("0").rstrip(".")) +PY +} + +select_buildroot_board() { + local choice="${LUCKFOX_RADIO_PROFILE:-${PYMC_RADIO_PROFILE:-${PYMC_BUILDROOT_BOARD:-}}}" + local default_board + + default_board=$(get_default_buildroot_board) + + if [ -n "$choice" ]; then + resolve_buildroot_board "$choice" || fail "Unknown Buildroot board choice: $choice" + return 0 + fi + + printf 'Select Luckfox radio board:\n' >&2 + while IFS='|' read -r index key name description; do + [ -n "$index" ] || continue + printf ' %s) %s' "$index" "$name" >&2 + [ -n "$description" ] && printf ' - %s' "$description" >&2 + printf '\n' >&2 + done <&2 + while IFS='|' read -r index title description; do + [ -n "$index" ] || continue + printf ' %s) %s' "$index" "$title" >&2 + [ -n "$description" ] && printf ' - %s' "$description" >&2 + printf '\n' >&2 + done </dev/null +} + +get_version() { + if [ -x "$VENV_PYTHON" ]; then + "$VENV_PYTHON" -c "from importlib.metadata import version; print(version('pymc_repeater'))" 2>/dev/null || echo "not installed" + else + echo "not installed" + fi +} + +doctor() { + stage "Checking Buildroot image baseline" + + for cmd in bash git python3 dialog jq wget sudo sqlite3 start-stop-daemon; do + if command -v "$cmd" >/dev/null 2>&1; then + info "found $cmd" + else + warn "missing $cmd" + fi + done + + if python3 -m venv --help >/dev/null 2>&1; then + info "python venv support available" + else + warn "python venv support missing" + fi + + if python3 - <<'PY' +modules = [ + "sqlite3", + "yaml", + "cherrypy", + "cherrypy_cors", + "autocommand", + "jaraco.collections", + "jaraco.text", + "paho.mqtt.client", + "psutil", + "jwt", + "ws4py", + "nacl", + "periphery", + "spidev", + "serial", + "usb", + "Crypto", +] +for module in modules: + __import__(module) +PY + then + info "python runtime packages are present" + else + warn "python runtime packages are missing" + fi + + for path in /dev/spidev* /dev/gpiochip*; do + [ -e "$path" ] && info "detected $path" + done +} + +check_venv_runtime() { + "$VENV_PYTHON" - <<'PY' +checks = [ + ("import yaml", "PyYAML"), + ("import cherrypy", "CherryPy"), + ("import cherrypy_cors", "cherrypy-cors"), + ("import paho.mqtt.client", "paho-mqtt"), + ("import psutil", "psutil"), + ("import jwt", "PyJWT"), + ("import ws4py", "ws4py"), + ("import nacl", "PyNaCl"), + ("import periphery", "python-periphery"), + ("import spidev", "spidev"), + ("import serial", "pyserial"), + ("import usb", "pyusb"), + ("from Crypto.Cipher import AES", "pycryptodome AES backend"), +] +failed = [] +for stmt, label in checks: + try: + exec(stmt, {}) + except Exception as exc: + failed.append((label, exc)) + +if failed: + print("Buildroot runtime validation failed:") + for label, exc in failed: + print(f" - {label}: {exc}") + raise SystemExit(1) +PY +} + +install_repeater() { + local git_version ip_address + + ensure_root + stage "Preparing Buildroot installation" + install_system_packages + ensure_service_user + + stage "Preparing directories and config" + info "Install dir: $INSTALL_DIR" + info "Config dir: $CONFIG_DIR" + info "Data dir: $DATA_DIR" + mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR" "$DATA_DIR/.config/pymc_repeater" + chown -R root:root "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR" + chmod 755 "$INSTALL_DIR" "$DATA_DIR" + chmod 750 "$CONFIG_DIR" "$LOG_DIR" + + cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml.example" + [ -f "$CONFIG_DIR/config.yaml" ] || cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml" + cp "$SCRIPT_DIR/radio-settings.json" "$DATA_DIR/" 2>/dev/null || true + cp "$SCRIPT_DIR/radio-presets.json" "$DATA_DIR/" 2>/dev/null || true + + ensure_venv + ensure_venv_build_backend + stage "Cleaning legacy install state" + cleanup_legacy_install_state + + if [ -d "$SCRIPT_DIR/.git" ]; then + stage "Inspecting checked-out repo version" + info "Fetching tags for setuptools_scm version detection" + git -C "$SCRIPT_DIR" fetch --tags 2>/dev/null || true + git_version=$(python3 -m setuptools_scm 2>/dev/null || echo "1.0.5") + export SETUPTOOLS_SCM_PRETEND_VERSION="$git_version" + info "Using version: $git_version" + else + export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5" + info "Using fallback version: 1.0.5" + fi + + preinstall_r2_wheels + install_buildroot_dependencies + install_core_into_venv + install_repeater_package + link_system_site_packages + + stage "Validating installed runtime" + if check_venv_runtime; then + info "Installed Python runtime looks usable" + else + fail "Installed packages are present but one or more native modules are unusable on this image." + fi + + seed_repeater_config + + stage "Writing Buildroot init service" + create_init_script + + stage "Starting service" + "$INIT_SCRIPT" restart + + ip_address=$(get_primary_ip) + if is_running; then + printf '\nService is running on: http://%s:8000\n' "${ip_address}" + else + fail "Installation completed but the service failed to start. Check: sh $0 logs" + fi +} + +upgrade_repeater() { + ensure_root + is_installed || fail "Service is not installed." + + ensure_venv + ensure_venv_build_backend + stage "Cleaning legacy install state" + cleanup_legacy_install_state + preinstall_r2_wheels + + stage "Upgrading pyMC Repeater" + install_buildroot_dependencies + install_core_into_venv + install_repeater_package + link_system_site_packages + stage "Validating installed runtime" + if check_venv_runtime; then + info "Installed Python runtime looks usable" + else + fail "Installed packages are present but one or more native modules are unusable on this image." + fi + "$INIT_SCRIPT" restart +} + +uninstall_repeater() { + ensure_root + + stage "Removing service" + service_exists && "$INIT_SCRIPT" stop || true + rm -f "$INIT_SCRIPT" + rm -rf "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" "$DATA_DIR" +} + +manage_service() { + local action="$1" + ensure_root + service_exists || fail "Service is not installed." + "$INIT_SCRIPT" "$action" +} + +show_status() { + local ip_address version + version=$(get_version) + ip_address=$(get_primary_ip) + + if ! is_installed; then + printf 'Installation Status: Not Installed\n' + return 0 + fi + + printf 'Installation Status: Installed\n' + printf 'Version: %s\n' "$version" + printf 'Install Directory: %s\n' "$INSTALL_DIR" + printf 'Config Directory: %s\n' "$CONFIG_DIR" + printf 'Log File: %s\n' "$LOGFILE" + printf 'Dashboard: http://%s:8000\n' "$ip_address" + if is_running; then + printf 'Service Status: Running\n' + else + printf 'Service Status: Stopped\n' + fi +} + +show_logs() { + mkdir -p "$LOG_DIR" + touch "$LOGFILE" + tail -f "$LOGFILE" +} + +delegate_to_stock_manage() { + exec bash "$SCRIPT_DIR/manage.sh" "$@" +} + +usage() { + cat <<'EOF' +Usage: bash buildroot-manage.sh + +Commands: + doctor Check Buildroot/Luckfox prerequisites + install Install pyMC Repeater on the Buildroot image + upgrade Upgrade the Buildroot installation from the checked-out repo + config Prompt for repeater settings and rewrite config.yaml + configure Same as config + radio-profile Reapply the Luckfox board radio config only + start Start the init.d service + stop Stop the init.d service + restart Restart the init.d service + status Show Buildroot service status + logs Tail the Buildroot log file + uninstall Remove the Buildroot installation +EOF +} + +case "${1:-}" in + doctor) + doctor + ;; + install) + install_repeater + ;; + upgrade) + upgrade_repeater + ;; + config|configure) + configure_repeater + ;; + radio-profile) + configure_radio_profile + ;; + start|stop|restart) + manage_service "$1" + ;; + status) + show_status + ;; + logs) + show_logs + ;; + uninstall) + uninstall_repeater + ;; + ""|help|-h|--help) + usage + ;; + *) + fail "Unknown command: ${1}" + ;; +esac diff --git a/config.yaml.example b/config.yaml.example index ccd1436..1060e7b 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -1,9 +1,15 @@ # Default Repeater Configuration +# radio_type: sx1262 | kiss (use kiss for serial KISS TNC modem) +radio_type: sx1262 repeater: # Node name for logging and identification node_name: "mesh-repeater-01" + # TX mode: forward | monitor | no_tx (default: forward) + # forward = repeat on; monitor = no repeat but companions/tenants can send; no_tx = all TX off + # mode: forward + # Geographic location (optional) # Latitude in decimal degrees (-90 to 90) latitude: 0.0 @@ -14,8 +20,20 @@ repeater: # If not specified, a new identity will be generated identity_file: null + # Identity key (alternative to identity_file) + # Store the private key directly in config as binary (set by convert_firmware_key.sh) + # If both identity_file and identity_key are set, identity_key takes precedence + # identity_key: null + + # Owner information (shown to clients requesting owner info) + owner_info: "" + # Duplicate packet cache TTL in seconds - cache_ttl: 60 + cache_ttl: 3600 + + # Maximum number of hops a flood packet may have already traversed before + # this repeater forwards it. + max_flood_hops: 64 # Score-based transmission filtering # Enable quality-based packet filtering and adaptive delays @@ -39,12 +57,149 @@ repeater: # with its node information (node type 2 - repeater) allow_discovery: true + # Incoming advert rate limiter (per advert public key) + # Uses a token bucket to smooth bursts. + advert_rate_limit: + # Master switch for token bucket limiting + enabled: false + # Max burst size allowed immediately per pubkey + # Keep this small for long advert intervals. + bucket_capacity: 2 + # Number of tokens added each refill interval + refill_tokens: 1 + # Refill interval in seconds (10 hours) + refill_interval_seconds: 36000 + # Optional hard minimum spacing between adverts from same pubkey + # Set 0 to disable (recommended - mesh retransmissions are normal in active networks) + min_interval_seconds: 0 + + # Penalty box for repeat advert limit violations (per pubkey) + advert_penalty_box: + # Master switch for escalating cooldowns + enabled: false + # Number of violations within decay window before cooldown starts + violation_threshold: 2 + # Reset violation count if pubkey stays quiet for this long + violation_decay_seconds: 43200 + # First penalty duration in seconds + base_penalty_seconds: 21600 + # Exponential growth factor for repeated violations + penalty_multiplier: 2.0 + # Maximum penalty duration cap + max_penalty_seconds: 86400 + + # Adaptive rate limiting based on mesh activity + # Rate limits scale with mesh busyness: quiet mesh = lenient, busy mesh = strict + advert_adaptive: + # Master switch for adaptive scaling + enabled: false + # EWMA smoothing factor (0.0-1.0, higher = faster response) + ewma_alpha: 0.1 + # Seconds without metrics change before tier change takes effect (hysteresis) + hysteresis_seconds: 300 + # Tier thresholds based on adverts per minute EWMA + thresholds: + quiet_max: 0.05 # Below this = QUIET tier (no limiting) + normal_max: 0.20 # Below this = NORMAL tier (1x limits) + busy_max: 0.50 # Below this = BUSY tier (0.5x capacity) + # Above busy_max = CONGESTED tier (0.25x capacity) + + # Security settings for login/authentication (shared across all identities) + 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 + # Mesh Network Configuration mesh: - # Global flood policy - controls whether the repeater allows or denies flooding by default - # true = allow flooding globally, false = deny flooding globally - # Individual transport keys can override this setting - global_flood_allow: true + # Unscoped flood policy - controls whether the repeater allows or denies unscoped flooding + # true = allow unscoped flooding, false = deny flooding globally + unscoped_flood_allow: true + + # Path hash mode for flood packets (0-hop): per-hop hash size in path encoding + # 0 = 1-byte hashes (legacy), 1 = 2-byte, 2 = 3-byte. Must match mesh convention. + # Affects originated adverts and any other flood packets sent by the repeater. + path_hash_mode: 0 + + # Flood loop detection mode + # off = disabled, minimal = allow up to 3 self-hashes, moderate = allow up to 1, strict = allow 0 + loop_detect: minimal + +# Multiple Identity Configuration (Optional) +# Define additional identities for the repeater to manage +# Each identity operates independently with its own key pair and configuration +identities: + # Room Server Identities + # Each room server acts as a separate logical node on the mesh + room_servers: + # Example room server configuration (commented out by default) + # - name: "TestBBS" + # identity_key: "your_room_identity_key_hex_here" + # type: "room_server" + # + # # Room-specific settings + # settings: + # node_name: "Test BBS Room" + # latitude: 0.0 + # 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" + # type: "room_server" + # settings: + # node_name: "Social Hub" + # latitude: 0.0 + # 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" + # tcp_timeout: 120 # seconds; default 120 when omitted; 0 = disable (no timeout) + # - name: "BotCompanion" + # identity_key: "another_companion_identity_key_hex" + # settings: + # node_name: "meshcore-bot" + # tcp_port: 5001 + # tcp_timeout: 120 # seconds; default 120 when omitted; 0 = disable (no timeout) + +# Radio hardware type +# Supported: +# - sx1262 (Linux spidev + system GPIO) +# - sx1262_ch341 (CH341 USB-to-SPI + CH341 GPIO 0-7) +radio_type: sx1262 + +# CH341 USB-to-SPI adapter settings (only used when radio_type: sx1262_ch341) +# NOTE: VID/PID are integers. Hex is also accepted in YAML, e.g. 0x1A86. +ch341: + vid: 6790 # 0x1A86 + pid: 21778 # 0x5512 radio: # Frequency in Hz (869.618 MHz for EU) @@ -68,19 +223,25 @@ radio: # Sync word (LoRa network ID) sync_word: 13380 - # Enable CRC checking - crc_enabled: true - # 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 +# NOTE: +# - When radio_type: sx1262, these pins are BCM GPIO numbers. +# - When radio_type: sx1262_ch341, these pins are CH341 GPIO numbers (0-7). sx1262: # SPI bus and chip select + # NOTE: For CH341 these are not used but are still required parameters. bus_id: 0 cs_id: 0 - # GPIO pins (BCM numbering) + # GPIO pins cs_pin: 21 reset_pin: 18 busy_pin: 20 @@ -95,6 +256,8 @@ sx1262: rxled_pin: -1 use_dio3_tcxo: false + dio3_tcxo_voltage: 1.8 + use_dio2_rf: false # Waveshare hardware flag is_waveshare: false @@ -114,56 +277,48 @@ duty_cycle: # Maximum airtime per minute in milliseconds max_airtime_per_minute: 3600 - # 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" - # MQTT publishing configuration (optional) - mqtt: - # Enable/disable MQTT publishing - enabled: false - - # MQTT broker settings - broker: "localhost" - port: 1883 - - # Authentication (optional) - username: null - password: null - - # Base topic for publishing - # Messages will be published to: {base_topic}/{node_name}/{packet|advert} - base_topic: "meshcore/repeater" - # Data retention settings 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 +mqtt: iata_code: "Test" # e.g., "SFO", "LHR", "Test" - broker_index: 0 # Which LetsMesh broker (0=EU, 1=US West) - status_interval: 300 + status_interval: 300 # How often a status message is sent (in seconds) owner: "" email: "" - - # Block specific packet types from being published to LetsMesh + brokers: [] + + # Below is the broker object schema: + # enabled: true|false # Enable this specific mqtt broker + # name: "" # Internal name for this broker + # host: "" # hostname or ip of mqtt endpoints + # port: # Typically 443 for websocket endpoints or 1883 for tcp + # transport: "tcp" or "websockets" + # audience: "" # For JWT auth'd endpoints, this is usually the host unless always stated by endpoint owners + # use_jwt_auth: true|false # Does this endpoint require JWT auth + # username: "" # Username for basic auth. If empty or missing, uses anonymous access + # password: "" # Password for basic auth. Required if username is set + # format: letsmesh|mqtt + # retain_status: true|false # Sets MQTT "retain" on status messages so they remain on the broker when disconnected. Also enforces a QOS of 1 (guaranteed delivery) + + # Block specific packet types from being published to the MQTT endpoint # 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: [] + # disallowed_packet_types: [] # - REQ # Don't publish requests # - RESPONSE # Don't publish responses # - TXT_MSG # Don't publish text messages @@ -176,6 +331,48 @@ letsmesh: # - TRACE # Don't publish trace packets # - RAW_CUSTOM # Don't publish custom raw packets + # Example of using the US and EU LetsMesh endpoints + # brokers: + # - name: US West (LetsMesh v1) + # host: mqtt-us-v1.letsmesh.net + # port: 443 + # audience: mqtt-us-v1.letsmesh.net + # use_jwt_auth: true + # enabled: true + + # - name: Europe (LetsMesh v1) + # host: mqtt-eu-v1.letsmesh.net + # port: 443 + # audience: mqtt-eu-v1.letsmesh.net + # use_jwt_auth: true + # enabled: true + +# pyMC_Glass control-plane integration (optional) +glass: + # Enable repeater -> pyMC_Glass /inform loop + enabled: false + + # Base URL of Glass backend + # Example local dev: "http://localhost:8080" + # Example production: "https://glass.example.com" + base_url: "http://localhost:8080" + + # Inform interval in seconds (used as initial/default interval; + # backend may override via noop.interval response) + inform_interval_seconds: 30 + + # HTTP timeout per inform request + request_timeout_seconds: 10 + + # Verify TLS certificates when using HTTPS + verify_tls: true + + # Optional bearer token for future authenticated inform endpoints + api_token: "" + + # Where cert_renewal payloads are written + cert_store_dir: "/etc/pymc_repeater/glass" + logging: # Log level: DEBUG, INFO, WARNING, ERROR level: INFO diff --git a/convert_firmware_key.sh b/convert_firmware_key.sh new file mode 100755 index 0000000..335c9aa --- /dev/null +++ b/convert_firmware_key.sh @@ -0,0 +1,299 @@ +#!/bin/bash +# Convert MeshCore firmware 64-byte private key to pyMC_Repeater format +# +# Usage: sudo ./convert_firmware_key.sh <64-byte-hex-key> [--output-format=] [config-path] +# Example: sudo ./convert_firmware_key.sh 987BDA619630197351F2B3040FD19B2EE0DEE357DD69BBEEE295786FA78A4D5F298B0BF1B7DE73CBC23257CDB2C562F5033DF58C232916432948B0F6BA4448F2 + +set -e + +if [ $# -eq 0 ]; then + echo "Error: No key provided" + echo "" + echo "Usage: sudo $0 <64-byte-hex-key> [--output-format=] [config-path]" + echo "" + echo "This script imports a 64-byte MeshCore firmware private key into" + echo "pyMC_Repeater for full identity compatibility." + echo "" + echo "The 64-byte key format: [32-byte scalar][32-byte nonce]" + echo " - Enables same node address as firmware device" + echo " - Supports signing using MeshCore/orlp ed25519 algorithm" + echo " - Fully compatible with pyMC_core LocalIdentity" + echo "" + echo "Arguments:" + echo " --output-format: Optional output format (yaml|identity, default: yaml)" + echo " yaml - Store in config.yaml (embedded binary)" + echo " identity - Save to identity.key file (base64 encoded)" + echo " config-path: Optional path to config.yaml (default: /etc/pymc_repeater/config.yaml)" + echo "" + echo "Examples:" + echo " # Save to config.yaml (default)" + echo " sudo $0 987BDA619630197351F2B3040FD19B2EE0DEE357DD69BBEEE295786FA78A4D5F298B0BF1B7DE73CBC23257CDB2C562F5033DF58C232916432948B0F6BA4448F2" + echo "" + echo " # Save to identity.key file" + echo " sudo $0 987BDA619630197351F2B3040FD19B2EE0DEE357DD69BBEEE295786FA78A4D5F298B0BF1B7DE73CBC23257CDB2C562F5033DF58C232916432948B0F6BA4448F2 --output-format=identity" + exit 1 +fi + +# Check if running with sudo/root +if [ "$EUID" -ne 0 ]; then + echo "Error: This script must be run with sudo to update config.yaml" + echo "Usage: sudo $0 <64-byte-hex-key>" + exit 1 +fi + +FULL_KEY="$1" +OUTPUT_FORMAT="yaml" # Default format +CONFIG_PATH="" + +# Parse arguments +shift # Remove the key argument +while [ $# -gt 0 ]; do + case "$1" in + --output-format=*) + OUTPUT_FORMAT="${1#*=}" + ;; + *) + CONFIG_PATH="$1" + ;; + esac + shift +done + +# Validate output format +if [ "$OUTPUT_FORMAT" != "yaml" ] && [ "$OUTPUT_FORMAT" != "identity" ]; then + echo "Error: Invalid output format '$OUTPUT_FORMAT'. Must be 'yaml' or 'identity'" + exit 1 +fi + +# Set default config path if not provided +if [ -z "$CONFIG_PATH" ]; then + CONFIG_PATH="/etc/pymc_repeater/config.yaml" +fi + +# Validate hex string +if ! [[ "$FULL_KEY" =~ ^[0-9a-fA-F]+$ ]]; then + echo "Error: Key must be a hexadecimal string" + exit 1 +fi + +KEY_LEN=${#FULL_KEY} + +if [ "$KEY_LEN" -ne 128 ]; then + echo "Error: Key must be 64 bytes (128 hex characters), got $KEY_LEN characters" + exit 1 +fi + +# Check if config/identity file location exists (only for yaml format or if saving identity.key) +if [ "$OUTPUT_FORMAT" = "yaml" ]; then + # Check if config exists + if [ ! -f "$CONFIG_PATH" ]; then + echo "Error: Config file not found: $CONFIG_PATH" + exit 1 + fi +else + # For identity format, use system-wide location matching config.yaml + IDENTITY_DIR="/etc/pymc_repeater" + IDENTITY_PATH="$IDENTITY_DIR/identity.key" +fi + +echo "=== MeshCore Firmware Key Import ===" +echo "" +echo "Output format: $OUTPUT_FORMAT" +if [ "$OUTPUT_FORMAT" = "yaml" ]; then + echo "Target file: $CONFIG_PATH" +else + echo "Target file: $IDENTITY_PATH" +fi +echo "" +echo "Input (64-byte firmware key):" +echo " $FULL_KEY" +echo "" + +# Verify public key derivation and import key using Python with safe YAML handling +python3 </dev/null; then + read -p "Restart pymc-repeater service now? (yes/no): " RESTART + if [ "$RESTART" = "yes" ]; then + systemctl restart pymc-repeater + echo "✓ Service restarted" + echo "" + echo "Check logs for new identity:" + echo " sudo journalctl -u pymc-repeater -f | grep -i 'identity\|hash'" + else + echo "Remember to restart the service:" + echo " sudo systemctl restart pymc-repeater" + fi + else + echo "Note: pymc-repeater service is not running" + echo "Start it with: sudo systemctl start pymc-repeater" + fi +else + echo "Identity key saved to file." + echo "" + if systemctl is-active --quiet pymc-repeater 2>/dev/null; then + read -p "Restart pymc-repeater service now? (yes/no): " RESTART + if [ "$RESTART" = "yes" ]; then + systemctl restart pymc-repeater + echo "✓ Service restarted" + echo "" + echo "Check logs for new identity:" + echo " sudo journalctl -u pymc-repeater -f | grep -i 'identity\|hash'" + else + echo "Remember to restart the service:" + echo " sudo systemctl restart pymc-repeater" + fi + else + echo "Note: pymc-repeater service is not running" + echo "Start it with: sudo systemctl start pymc-repeater" + fi +fi diff --git a/debian/.gitignore b/debian/.gitignore new file mode 100644 index 0000000..e18f4ce --- /dev/null +++ b/debian/.gitignore @@ -0,0 +1,6 @@ +*.debhelper +*.debhelper.log +*.substvars +.debhelper/ +files +pymc-repeater/ diff --git a/debian/changelog b/debian/changelog new file mode 100644 index 0000000..3508527 --- /dev/null +++ b/debian/changelog @@ -0,0 +1,6 @@ +pymc-repeater (1.0.5~dev0) unstable; urgency=medium + + * Development build from git commit 7112da9 + * Version: 1.0.5.post0 + + -- Lloyd Tue, 30 Dec 2025 12:55:47 +0000 diff --git a/debian/control b/debian/control new file mode 100644 index 0000000..83a70d0 --- /dev/null +++ b/debian/control @@ -0,0 +1,43 @@ +Source: pymc-repeater +Section: net +Priority: optional +Maintainer: Lloyd +Build-Depends: debhelper-compat (= 13), + dh-python, + python3-all, + python3-setuptools, + python3-setuptools-scm, + python3-wheel, + python3-pip, + python3-yaml, + python3-cherrypy3, + python3-paho-mqtt, + python3-psutil, + git +Standards-Version: 4.6.2 +Homepage: https://github.com/rightup/pyMC_Repeater +X-Python3-Version: >= 3.8 + +Package: pymc-repeater +Architecture: all +Depends: ${python3:Depends}, + ${misc:Depends}, + python3-yaml, + python3-cherrypy3, + python3-paho-mqtt, + python3-psutil, + python3-jwt, + python3-pip, + python3-rrdtool, + libffi-dev, + jq +Recommends: python3-periphery, + python3-spidev +Description: PyMC Repeater Daemon + A mesh networking repeater daemon for LoRa devices. + . + This package provides the pymc-repeater service for managing + mesh network repeater functionality with a web interface. + . + Note: This package will install pymc_core, cherrypy-cors, and ws4py + from PyPI during postinst as they are not available in Debian repos. diff --git a/debian/debhelper-build-stamp b/debian/debhelper-build-stamp new file mode 100644 index 0000000..a8bda05 --- /dev/null +++ b/debian/debhelper-build-stamp @@ -0,0 +1 @@ +pymc-repeater diff --git a/debian/pymc-repeater.dirs b/debian/pymc-repeater.dirs new file mode 100644 index 0000000..d79d584 --- /dev/null +++ b/debian/pymc-repeater.dirs @@ -0,0 +1,3 @@ +etc/pymc_repeater +var/log/pymc_repeater +usr/share/pymc_repeater diff --git a/debian/pymc-repeater.install b/debian/pymc-repeater.install new file mode 100644 index 0000000..2cbb0af --- /dev/null +++ b/debian/pymc-repeater.install @@ -0,0 +1,3 @@ +config.yaml.example usr/share/pymc_repeater/ +radio-presets.json usr/share/pymc_repeater/ +radio-settings.json usr/share/pymc_repeater/ diff --git a/debian/pymc-repeater.postinst b/debian/pymc-repeater.postinst new file mode 100755 index 0000000..d2d57d7 --- /dev/null +++ b/debian/pymc-repeater.postinst @@ -0,0 +1,57 @@ +#!/bin/sh +set -e + +case "$1" in + configure) + # Create system user + if ! getent passwd pymc-repeater >/dev/null; then + adduser --system --group --home /var/lib/pymc-repeater \ + --gecos "PyMC Repeater Service" pymc-repeater + fi + + # Add user to gpio and spi groups for hardware access + if getent group gpio >/dev/null; then + usermod -a -G gpio pymc-repeater + fi + if getent group spi >/dev/null; then + usermod -a -G spi pymc-repeater + fi + # Create and set permissions on data directory + mkdir -p /var/lib/pymc_repeater + chown -R pymc-repeater:pymc-repeater /var/lib/pymc_repeater + chmod 750 /var/lib/pymc_repeater + # Set permissions + chown -R pymc-repeater:pymc-repeater /etc/pymc_repeater + chown -R pymc-repeater:pymc-repeater /var/log/pymc-repeater + chmod 750 /etc/pymc_repeater + chmod 750 /var/log/pymc-repeater + + # Copy example config if no config exists + if [ ! -f /etc/pymc_repeater/config.yaml ]; then + cp /usr/share/pymc_repeater/config.yaml.example /etc/pymc_repeater/config.yaml + chown pymc-repeater:pymc-repeater /etc/pymc_repeater/config.yaml + chmod 640 /etc/pymc_repeater/config.yaml + fi + + # Install pymc_core from PyPI if not already installed + if ! python3 -c "import pymc_core" 2>/dev/null; then + 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 + fi + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/debian/pymc-repeater.postrm b/debian/pymc-repeater.postrm new file mode 100755 index 0000000..5762452 --- /dev/null +++ b/debian/pymc-repeater.postrm @@ -0,0 +1,18 @@ +#!/bin/sh +set -e + +case "$1" in + purge) + # Remove user and directories + if getent passwd pymc-repeater >/dev/null; then + deluser --system pymc-repeater || true + fi + rm -rf /etc/pymc-repeater + rm -rf /var/log/pymc-repeater + rm -rf /var/lib/pymc-repeater + ;; +esac + +#DEBHELPER# + +exit 0 diff --git a/debian/pymc-repeater.service b/debian/pymc-repeater.service new file mode 100644 index 0000000..b2a9893 --- /dev/null +++ b/debian/pymc-repeater.service @@ -0,0 +1,15 @@ +[Unit] +Description=PyMC Repeater Daemon +After=network.target + +[Service] +Type=simple +User=pymc-repeater +Group=pymc-repeater +WorkingDirectory=/etc/pymc-repeater +ExecStart=/usr/bin/pymc-repeater +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target diff --git a/debian/rules b/debian/rules new file mode 100755 index 0000000..90bb323 --- /dev/null +++ b/debian/rules @@ -0,0 +1,22 @@ +#!/usr/bin/make -f +# -*- makefile -*- + +export PYBUILD_NAME=pymc-repeater +export DH_VERBOSE=1 + +%: + dh $@ --with python3 --buildsystem=pybuild + +override_dh_auto_test: + # Skip tests - cherrypy-cors not available in Debian repos + # Tests pass in development with: pip install cherrypy-cors + + override_dh_auto_clean: + dh_auto_clean + rm -rf build/ + rm -rf *.egg-info/ + rm -rf .pybuild/ + rm -f repeater/_version.py + +override_dh_installsystemd: + dh_installsystemd --name=pymc-repeater diff --git a/debian/source/format b/debian/source/format new file mode 100644 index 0000000..89ae9db --- /dev/null +++ b/debian/source/format @@ -0,0 +1 @@ +3.0 (native) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1048993 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + pymc-repeater: + build: . + container_name: pymc-repeater + restart: unless-stopped + ports: + - 8000:8000 + devices: + # SPI DEVICES (Your path may differ) + - /dev/spidev0.0 + - /dev/gpiochip0 + # USB DEVICES (Your path may differ) + - /dev/bus/usb/002:/dev/bus/usb/002 + # SPI DEVICES PERMISSIONS + cap_add: + - SYS_RAWIO + # USB DEVICSE PERMISSIONS + group_add: + - plugdev + volumes: + - ./config.yaml:/etc/pymc_repeater/config.yaml + - ./data:/var/lib/pymc_repeater diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..2dfee91 --- /dev/null +++ b/dockerfile @@ -0,0 +1,38 @@ +FROM python:3.12-slim-bookworm + +ENV INSTALL_DIR=/opt/pymc_repeater \ + CONFIG_DIR=/etc/pymc_repeater \ + DATA_DIR=/var/lib/pymc_repeater \ + PYTHONUNBUFFERED=1 \ + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PYMC_REPEATER=1.0.5 + +# Install runtime dependencies only +RUN apt-get update && apt-get install -y \ + libffi-dev \ + python3-rrdtool \ + jq \ + wget \ + libusb-1.0-0 \ + swig \ + git \ + build-essential \ + python3-dev \ + && rm -rf /var/lib/apt/lists/* + +# Create runtime directories +RUN mkdir -p ${INSTALL_DIR} ${CONFIG_DIR} ${DATA_DIR} + +WORKDIR ${INSTALL_DIR} + +# Copy source +COPY repeater ./repeater +COPY pyproject.toml . +COPY radio-presets.json . +COPY radio-settings.json . + +# Install package +RUN pip install --no-cache-dir . + +EXPOSE 8000 + +ENTRYPOINT ["python3", "-m", "repeater.main", "--config", "/etc/pymc_repeater/config.yaml"] diff --git a/docs/pr_hash_once.md b/docs/pr_hash_once.md new file mode 100644 index 0000000..d8e7cef --- /dev/null +++ b/docs/pr_hash_once.md @@ -0,0 +1,346 @@ +# PR: Compute Packet Hash Once Per Forwarded Packet + +**Branch:** `perf/hash-once` +**Base:** `rightup/fix-perfom-speed` +**Files changed:** `repeater/engine.py` (1 file, ~51 lines net) + +--- + +## Problem + +`packet.calculate_packet_hash()` runs a SHA-256 digest over the full serialised +packet bytes, converts the result to a hex string, and uppercases it. Before +this change the hot forwarding path triggered this computation **three times per +packet**: + +| Call site | Where | When | +|-----------|-------|------| +| `__call__` line 162 | `pkt_hash_full = packet.calculate_packet_hash()...` | Every received packet | +| `flood_forward` / `direct_forward` via `is_duplicate` | `pkt_hash = packet.calculate_packet_hash()...` | Every packet that reaches the forward check | +| `flood_forward` / `direct_forward` via `mark_seen` | `pkt_hash = packet_hash or packet.calculate_packet_hash()...` | Every packet that passes the duplicate check | + +And on the drop path, a fourth computation: + +| Call site | Where | When | +|-----------|-------|------| +| `_get_drop_reason` → `is_duplicate` | `pkt_hash = packet.calculate_packet_hash()...` | Every dropped packet | + +The hash computed in `__call__` was already available as `pkt_hash_full` but was +never passed into `process_packet`, `flood_forward`, `direct_forward`, +`is_duplicate`, `mark_seen`, or `_get_drop_reason`. Each of those methods +recomputed it independently. + +--- + +## Root Cause + +The `packet_hash` optional parameter existed on `mark_seen` but not on +`is_duplicate`, `flood_forward`, `direct_forward`, `process_packet`, or +`_get_drop_reason`. The call chain therefore had no way to propagate the +already-computed hash. + +--- + +## Solution + +Thread the pre-computed `pkt_hash_full` from `__call__` down through the call +chain as an optional `packet_hash: Optional[str] = None` parameter. Each method +uses the provided hash if present, or falls back to computing it — preserving +backward compatibility for any caller that doesn't have a pre-computed hash. + +``` +Before: + __call__ → calculate_packet_hash() #1 + → process_packet + → flood_forward + → is_duplicate → calculate_packet_hash() #2 + → mark_seen → calculate_packet_hash() #3 + (drop path) + → _get_drop_reason + → is_duplicate → calculate_packet_hash() #4 + +After: + __call__ → calculate_packet_hash() #1 (only computation) + → process_packet(packet_hash=pkt_hash_full) + → flood_forward(packet_hash=pkt_hash_full) + → is_duplicate(packet_hash=pkt_hash_full) uses provided hash ✓ + → mark_seen(packet_hash=pkt_hash_full) uses provided hash ✓ + (drop path) + → _get_drop_reason(packet_hash=pkt_hash_full) + → is_duplicate(packet_hash=pkt_hash_full) uses provided hash ✓ +``` + +--- + +## Methods Changed + +### `is_duplicate(packet, packet_hash=None)` + +```python +# Before +def is_duplicate(self, packet: Packet) -> bool: + pkt_hash = packet.calculate_packet_hash().hex().upper() # always recomputed + if pkt_hash in self.seen_packets: + return True + return False + +# After +def is_duplicate(self, packet: Packet, packet_hash: Optional[str] = None) -> bool: + """... + INVARIANT: purely synchronous — no await points. The caller relies on + is_duplicate + mark_seen being atomic within the asyncio event loop. + Do NOT add any await here without revisiting that invariant. + """ + pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper() + return pkt_hash in self.seen_packets +``` + +### `_get_drop_reason(packet, packet_hash=None)` + +```python +# Before +def _get_drop_reason(self, packet: Packet) -> str: + if self.is_duplicate(packet): ... # recomputes hash + +# After +def _get_drop_reason(self, packet: Packet, packet_hash: Optional[str] = None) -> str: + if self.is_duplicate(packet, packet_hash=packet_hash): ... # propagates hash +``` + +### `flood_forward(packet, packet_hash=None)` + +```python +# Before +def flood_forward(self, packet: Packet) -> Optional[Packet]: + ... + if self.is_duplicate(packet): ... # recomputes + self.mark_seen(packet) # recomputes + +# After +def flood_forward(self, packet: Packet, packet_hash: Optional[str] = None) -> Optional[Packet]: + """... + INVARIANT: purely synchronous — no await points. + """ + ... + if self.is_duplicate(packet, packet_hash=packet_hash): ... # propagates + self.mark_seen(packet, packet_hash=packet_hash) # propagates +``` + +### `direct_forward(packet, packet_hash=None)` — same pattern as `flood_forward` + +### `process_packet(packet, snr=0.0, packet_hash=None)` + +```python +# Before +def process_packet(self, packet, snr=0.0): + fwd_pkt = self.flood_forward(packet) # no hash + +# After +def process_packet(self, packet, snr=0.0, packet_hash=None): + """... + packet_hash: pre-computed SHA-256 hex from __call__; eliminates 2 SHA-256 + calls per forwarded packet by propagating the hash through the call chain. + """ + fwd_pkt = self.flood_forward(packet, packet_hash=packet_hash) +``` + +### `__call__` — two call-site changes + +```python +# Before +result = (None if ... else self.process_packet(processed_packet, snr)) +... +drop_reason = processed_packet.drop_reason or self._get_drop_reason(processed_packet) + +# After +result = (None if ... else self.process_packet(processed_packet, snr, packet_hash=pkt_hash_full)) +... +drop_reason = processed_packet.drop_reason or self._get_drop_reason( + processed_packet, packet_hash=pkt_hash_full +) +``` + +--- + +## What Was Not Changed + +`record_packet_only` (line 446) and `record_duplicate` (line 486) each compute +the hash independently. These are separate recording paths (called from the +inject path and from the raw-packet subscriber, respectively) that have no +`pkt_hash_full` from `__call__` in scope. Changing them would require a larger +refactor with no benefit to the forwarding hot path, so they are left unchanged. + +The fallback `packet_hash or packet.calculate_packet_hash()...` pattern in +`is_duplicate`, `mark_seen`, and `_build_packet_record` ensures external callers +(e.g. `TraceHelper.is_duplicate(packet)` from trace processing) continue to work +without any change. + +--- + +## Invariant Comments Added + +`flood_forward`, `direct_forward`, and `is_duplicate` now carry explicit docstring +invariants: + +> **INVARIANT:** purely synchronous — no await points. The is_duplicate + +> mark_seen pair is atomic within the asyncio event loop. Do NOT add any await +> here without revisiting that invariant in `__call__` / `process_packet`. + +These invariants were implicit before. Making them explicit means a future +contributor adding an `await` inside these methods will see the warning and +understand the consequence: the duplicate-check and mark-seen can no longer be +guaranteed atomic, allowing the same packet to be forwarded twice under concurrent +task dispatch. + +--- + +## Quantification + +On a Raspberry Pi running CPython 3.13, `hashlib.sha256` on a 50–200 byte +LoRa payload takes approximately 1–3 µs. The `.hex().upper()` string conversion +adds another ~0.5 µs. Savings per forwarded packet: ~3–8 µs. + +At 3 packets/second sustained forwarding rate this saves ~10–25 µs/second, which +is negligible in absolute terms. The more significant benefit is correctness and +clarity: + +- One canonical hash value per packet in the forwarding path. +- No possibility of the hash changing between the `is_duplicate` check and the + `mark_seen` call if `calculate_packet_hash` had any mutable state (it doesn't, + but the pattern is now provably correct). +- Explicit invariant documentation closes a latent trap for future contributors. + +--- + +## Test Plan + +### Unit tests (no hardware) + +**T1 — Hash computed exactly once per forwarded packet** + +```python +async def test_hash_computed_once_for_flood(): + call_count = 0 + original = Packet.calculate_packet_hash + + def counting_hash(self): + nonlocal call_count + call_count += 1 + return original(self) + + with patch.object(Packet, "calculate_packet_hash", counting_hash): + await engine(flood_packet, metadata={}) + + assert call_count == 1, f"Expected 1 hash computation, got {call_count}" +``` + +**T2 — Hash computed exactly once per dropped (duplicate) packet** + +```python +async def test_hash_computed_once_for_duplicate(): + # Mark packet seen first + engine.seen_packets[packet.calculate_packet_hash().hex().upper()] = time.time() + + call_count = 0 + original = Packet.calculate_packet_hash + def counting_hash(self): + nonlocal call_count; call_count += 1; return original(self) + + with patch.object(Packet, "calculate_packet_hash", counting_hash): + await engine(packet, metadata={}) + + # One computation in __call__ for pkt_hash_full; should not trigger again + # in process_packet → flood_forward → is_duplicate (drop path via _get_drop_reason) + assert call_count == 1 +``` + +**T3 — External callers of `is_duplicate` without hash still work** + +```python +def test_is_duplicate_without_hash(): + """TraceHelper and other external callers pass no hash — must still work.""" + pkt = make_test_packet() + engine.seen_packets[pkt.calculate_packet_hash().hex().upper()] = time.time() + + assert engine.is_duplicate(pkt) is True # no packet_hash arg + assert engine.is_duplicate(pkt, packet_hash="WRONGHASH") is False +``` + +**T4 — mark_seen / is_duplicate agree on the same hash** + +```python +def test_mark_then_is_duplicate_consistent(): + pkt = make_test_packet() + pkt_hash = pkt.calculate_packet_hash().hex().upper() + + assert engine.is_duplicate(pkt, packet_hash=pkt_hash) is False + engine.mark_seen(pkt, packet_hash=pkt_hash) + assert engine.is_duplicate(pkt, packet_hash=pkt_hash) is True + # Same result without the pre-computed hash (fallback path) + assert engine.is_duplicate(pkt) is True +``` + +**T5 — flood_forward / direct_forward signatures are backward compatible** + +```python +def test_flood_forward_no_hash_arg(): + """Callers that don't pass packet_hash must still work (fallback compute).""" + pkt = make_flood_packet() + result = engine.flood_forward(pkt) # no packet_hash — must not raise + assert result is not None or pkt.drop_reason is not None +``` + +### Integration / field tests (with hardware) + +**T6 — Forwarding throughput unchanged** + +1. Forward 100 packets at maximum duty-cycle budget. +2. Verify all eligible packets are forwarded (same count as before change). +3. Verify no `Duplicate` drops that were not present before. + +**T7 — Duplicate detection unchanged** + +1. Send the same packet twice within 1 second. +2. Verify the first is forwarded and the second is logged as `"Duplicate"`. + +**T8 — CPU profile shows reduced `calculate_packet_hash` calls** + +1. Enable Python profiling (`cProfile`) on the repeater for 60 seconds. +2. Compare `calculate_packet_hash` call count before and after. + +**Expected:** call count approximately halved for workloads where most packets +are forwarded (≤ 1 call per forwarded packet vs ≥ 3 before). + +--- + +## Proof of Correctness + +### Why the fallback `packet_hash or packet.calculate_packet_hash()` is safe + +`packet_hash` is either the correct hash (passed from `__call__`) or `None`. +If it is `None`, the fallback computes the hash fresh — identical to the old +behaviour. There is no case where a wrong hash is used: the only source of a +non-None `packet_hash` is `pkt_hash_full = packet.calculate_packet_hash()...` +in `__call__`, computed over the same `processed_packet` (a deep copy of the +received packet, unchanged between hash computation and the call to +`process_packet`). + +### Why passing the hash through a deep-copied packet is correct + +`processed_packet = copy.deepcopy(packet)` (line 178) happens before +`pkt_hash_full` is passed to `process_packet`. The deep copy does not change +the packet's wire representation — `calculate_packet_hash()` calls +`packet.write_to()` which serialises the packet's fields. The copy has the +same fields, so `deepcopy(packet).calculate_packet_hash() == packet.calculate_packet_hash()`. +Passing the hash computed from the original to the copy is correct. + +### Why the invariant is critical + +asyncio only yields execution at `await` points. `flood_forward` and +`direct_forward` have no `await`, so they run atomically from the event loop's +perspective. The `is_duplicate` check and the `mark_seen` call inside them +cannot be interleaved with another coroutine. If a future change added an +`await` between them, two concurrent `_route_packet` tasks could both pass the +duplicate check for the same packet before either marked it seen — sending the +same packet twice. The invariant comment documents this so the risk is visible +at the point where it could be broken. diff --git a/docs/pr_in_flight_cap.md b/docs/pr_in_flight_cap.md new file mode 100644 index 0000000..df6cbc9 --- /dev/null +++ b/docs/pr_in_flight_cap.md @@ -0,0 +1,349 @@ +# PR: Bounded In-Flight Task Counter + Simplified Route Task Management + +**Branch:** `perf/in-flight-cap` +**Base:** `rightup/fix-perfom-speed` +**Files changed:** `repeater/packet_router.py` (1 file, ~33 lines net) + +--- + +## Background + +The queue loop dispatches each incoming packet as an `asyncio.create_task` so TX +delay timers run concurrently — this is correct behaviour. The previous +implementation tracked these tasks in a `set[asyncio.Task]` (`_route_tasks`) for +two reasons: + +1. **Error surfacing** — the done-callback read `task.result()` to log exceptions. +2. **Shutdown cancellation** — `stop()` cancelled and awaited all tasks in the set. + +This PR replaces the set with a simple integer counter and tightens the companion +deduplication prune threshold. + +--- + +## Problems + +### Problem 1 — Unbounded task accumulation + +LoRa airtime naturally limits steady-state throughput to a handful of in-flight +tasks at any time. But burst arrivals can spike the count temporarily: + +- **Multi-hop flood amplification**: a single source packet is forwarded by every + repeater in range, each of which re-broadcasts it. A node at a mesh junction + may receive 5–10 copies within 100 ms, each scheduling a separate `delayed_send` + task. +- **Collision retries**: hardware-level collisions produce duplicate RF bursts that + all arrive within the same RX window. +- **Bridge nodes**: high-traffic gateway nodes connect multiple mesh segments and + forward both directions simultaneously. + +Under these conditions `_route_tasks` can accumulate dozens of sleeping tasks. +Each holds a reference to the packet, the forwarded packet copy, a closure over +`delayed_send`, and associated asyncio task overhead. There is no cap; the set +grows until the duty-cycle gate finally fires for each task. + +### Problem 2 — `_route_tasks` set adds O(1) cost on every packet but O(n) cost on shutdown + +Every packet adds one entry to `_route_tasks` and removes it in the done-callback. +This is O(1) per operation, but the `stop()` shutdown path iterates the entire set +to cancel and gather all tasks — O(n) where n is however many tasks happen to be +in-flight at shutdown time. On a busy node this could delay clean shutdown. + +### Problem 3 — `_COMPANION_DEDUPE_PRUNE_THRESHOLD = 1000` is too high + +The companion delivery deduplication dict prunes itself only when it exceeds 1000 +entries. With a 60-second TTL, each PATH/protocol-response packet adds one entry. +On a busy mesh with 50+ nodes sending adverts and PATH packets, the dict can grow +to hundreds of entries before a prune is triggered — keeping stale entries in +memory for up to 60 seconds × 1000/rate entries worth of time. + +--- + +## Solution + +### Replace `_route_tasks` set with `_in_flight` counter + +An integer counter provides the same protection (tasks complete; done-callback +fires) without holding strong references to each task object: + +```python +# __init__ +self._in_flight: int = 0 +self._max_in_flight: int = 30 + +# _process_queue — drop early if cap reached +if self._in_flight >= self._max_in_flight: + logger.warning("In-flight task cap reached (%d/%d), dropping packet", ...) + continue +self._in_flight += 1 +task = asyncio.create_task(self._route_packet(packet)) +task.add_done_callback(self._on_route_done) + +# done-callback +def _on_route_done(self, task): + self._in_flight -= 1 + if not task.cancelled() and task.exception(): + logger.error("_route_packet raised: %s", task.exception(), ...) +``` + +### Cap at 30 concurrent in-flight tasks + +30 is chosen as a ceiling that is: +- **Never reached in normal operation**: LoRa airtime at SF8/125 kHz limits + throughput to ~2–3 packets per second; with delays of 0.5–5 s each, the + steady-state in-flight count is at most 5–15 tasks. +- **High enough not to drop legitimate traffic**: a burst of 30 nearly-simultaneous + packets would require every node in a large mesh to transmit within 1 second. +- **Low enough to protect against pathological scenarios**: a misconfigured node + flooding the channel or a software bug causing infinite re-queuing. + +### Tighten companion dedup prune threshold to 200 + +200 entries at 60 s TTL means a sweep is triggered after ~200 unique PATH/response +packets arrive without any expiry. This is far more than a typical companion +session (which sees a handful of active connections) but prevents multi-hour +accumulation on a busy mesh. + +--- + +## Trade-off: Shutdown Cancellation + +The previous `_route_tasks` set allowed `stop()` to explicitly cancel and await +all in-flight tasks on shutdown. The counter approach does not. + +**Why this is acceptable:** + +1. In-flight `_route_packet` tasks are sleeping inside `delayed_send` (waiting for + their TX delay timer). When the event loop is shut down — whether via + `asyncio.run()` completing, `loop.stop()`, or `SIGTERM` handling — Python + cancels all pending tasks automatically. + +2. Even under the old approach, cancelling a sleeping `delayed_send` means the + packet is not transmitted. The result is the same whether cancellation happens + explicitly in `stop()` or implicitly when the event loop closes. + +3. For a graceful shutdown where we want to *wait* for in-flight packets to + complete transmission, the right mechanism is `stop()` awaiting the queue to + drain *before* cancelling the router task — not cancelling sleeping tasks. + Neither the old code nor this PR implements that, so no regression. + +--- + +## Why This Is the Right Approach + +### Alternative A — Keep `_route_tasks` set, add a size cap + +```python +if len(self._route_tasks) >= 30: + logger.warning(...) + continue +``` + +Works, but the set still holds a strong reference to every Task object for the +duration of its sleep. The counter holds an integer. Task objects in Python 3.12+ +are already strongly referenced by the event loop scheduler; the set reference is +redundant for preventing GC cancellation. + +### Alternative B — `asyncio.Semaphore` + +```python +self._sem = asyncio.Semaphore(30) +async with self._sem: + await self._route_packet(packet) +``` + +Correct but changes the queue loop from fire-and-forget to blocking: the loop +would wait at `async with self._sem` for a slot to open, stalling packet reads +while a slot is occupied. That reintroduces the queue freeze the concurrent +dispatch was designed to prevent. A semaphore is the right tool for *rate- +limiting* producers; a counter cap at the dispatch site is the right tool for +bounding *background* tasks. + +### Alternative C — Integer counter (this PR) + +- O(1) increment and decrement. +- No strong reference to task objects beyond the event loop's own reference. +- Drop decision is synchronous and immediate — no sleeping on semaphore. +- Error logging preserved in `_on_route_done`. +- Simpler code, easier to reason about. + +--- + +## Changes — `repeater/packet_router.py` only + +| Location | Change | Reason | +|----------|--------|--------| +| Module level | Remove `_COMPANION_DEDUPE_PRUNE_THRESHOLD = 1000` | Replaced with inline literal `200`; no need for a named constant for a single usage site | +| `__init__` | Remove `self._route_tasks = set()`; add `self._in_flight = 0`, `self._max_in_flight = 30` | Replace set-based tracking with counter | +| `stop()` | Remove `_route_tasks` cancellation block | Tasks complete or are cancelled by event loop shutdown; explicit cancellation not needed | +| `_on_route_task_done` → `_on_route_done` | Simpler done-callback: decrement counter + log exceptions | Error logging preserved; set management removed | +| `_should_deliver_path_to_companions` | `> _COMPANION_DEDUPE_PRUNE_THRESHOLD` → `> 200` with explanatory comment | Lower threshold; comment explains the sizing rationale | +| `_process_queue` | Check `_in_flight >= _max_in_flight` before `create_task`; increment `_in_flight`; use `_on_route_done` | Cap accumulation; counter tracks live task count | + +--- + +## Test Plan + +### Unit tests (no hardware) + +**T1 — Counter increments and decrements correctly** + +```python +async def test_in_flight_counter(): + router = PacketRouter(mock_daemon) + await router.start() + + assert router._in_flight == 0 + + # Enqueue a packet that takes time to process + async def slow_route(pkt): + await asyncio.sleep(0.1) + + router._route_packet = slow_route + await router.enqueue(make_test_packet()) + await asyncio.sleep(0.01) # let queue loop run + + assert router._in_flight == 1 # task is sleeping + + await asyncio.sleep(0.15) # task finishes + assert router._in_flight == 0 # counter decremented by done-callback +``` + +**T2 — Cap enforced: packet dropped when at limit** + +```python +async def test_cap_drops_packet_at_limit(): + router = PacketRouter(mock_daemon) + router._max_in_flight = 2 + router._in_flight = 2 # simulate cap reached + + dropped = [] + original_create_task = asyncio.create_task + asyncio.create_task = lambda coro: dropped.append(coro) + + await router._process_queue_once(make_test_packet()) + + assert dropped == [], "create_task must not be called when cap is reached" + asyncio.create_task = original_create_task +``` + +**T3 — Exceptions in `_route_packet` are logged, not swallowed** + +```python +async def test_exception_logged(): + router = PacketRouter(mock_daemon) + + async def failing_route(pkt): + raise ValueError("simulated error") + + router._route_packet = failing_route + with patch("repeater.packet_router.logger") as mock_log: + task = asyncio.create_task(failing_route(make_test_packet())) + router._in_flight = 1 + task.add_done_callback(router._on_route_done) + await asyncio.gather(task, return_exceptions=True) + mock_log.error.assert_called_once() + + assert router._in_flight == 0 +``` + +**T4 — Companion dedup dict pruned at 200, not 1000** + +```python +def test_companion_dedup_prune_threshold(): + router = PacketRouter(mock_daemon) + future_time = time.time() + 999 + + # Fill with 199 entries (all unexpired) — no prune + router._companion_delivered = {f"key{i}": future_time for i in range(199)} + pkt = make_path_packet() + router._should_deliver_path_to_companions(pkt) + assert len(router._companion_delivered) == 200 # added one, no prune yet + + # 201st entry triggers prune — all unexpired so count stays at 201 + router._companion_delivered[f"key_extra"] = future_time + assert len(router._companion_delivered) == 201 + + # Force prune by making all existing entries expired + past_time = time.time() - 1 + router._companion_delivered = {f"key{i}": past_time for i in range(201)} + router._should_deliver_path_to_companions(pkt) + # All expired entries pruned; only the new entry remains + assert len(router._companion_delivered) == 1 +``` + +### Integration / field tests (with hardware) + +**T5 — Burst flood: verify cap fires under pathological load** + +1. Configure a test mesh with 4+ nodes all in range of the repeater. +2. Have all nodes send a flood packet simultaneously. +3. Observe repeater logs. + +**Expected:** `_in_flight` peaks in low single digits (LoRa airtime prevents +large bursts); no `"In-flight task cap reached"` warning fires under normal +conditions, confirming the cap is never a bottleneck in practice. + +**T6 — Counter reaches zero after all packets processed** + +1. Send a burst of 10 packets. +2. Wait 10 seconds (longer than max TX delay of 5 s). +3. Query `router._in_flight` from a debug endpoint or log. + +**Expected:** `_in_flight == 0` after all delays expire and packets transmit. + +**T7 — Error in `_route_packet` is logged and counter is decremented** + +1. Temporarily introduce a deliberate exception in `_route_packet`. +2. Send a packet. +3. Check logs for the error message and verify the repeater continues operating + (counter decremented, queue still draining). + +**T8 — Normal forwarding throughput unchanged** + +1. Send packets at a steady rate of 1 every 10 seconds for 5 minutes. +2. Verify all packets are forwarded with no warnings or errors. +3. Confirm `_in_flight` never exceeds 3–4 during normal operation. + +--- + +## Proof of Correctness + +### Counter vs set: why the counter is sufficient + +The `_route_tasks` set solved two problems: + +1. **GC protection**: In Python < 3.12, a task with no strong references other + than the event loop's internal weakref could be garbage collected before + completing. Python 3.12+ strengthened task references in the event loop. + However, even in earlier versions, the set was unnecessary once `create_task` + returns — the caller holds the reference, and the done-callback fires reliably + because the event loop holds the task alive until completion. + +2. **Explicit shutdown cancellation**: The counter loses this. As argued above, + the outcome is identical — sleeping tasks are cancelled either explicitly by + `stop()` or implicitly by the event loop at shutdown — and no packet that + hasn't been transmitted yet can complete its send after the radio is shut down + anyway. + +### Why `_on_route_done` is a done-callback and not a `try/finally` inside `_route_packet` + +A `try/finally` block inside `_route_packet` would also decrement the counter. +Done-callbacks are preferable because: + +- They fire even if the task is externally cancelled (e.g. by event loop shutdown), + whereas `finally` may not run if `CancelledError` is not caught. +- They decouple counter management from `_route_packet` logic — `_route_packet` + has no knowledge of or dependency on the cap mechanism. +- They keep the pattern consistent with the rest of the codebase's use of + `add_done_callback` for task lifecycle management. + +### Why 30 and not a smaller number like 10 + +At SF8, 125 kHz bandwidth, a 30-byte payload takes ~111 ms airtime and produces +a TX delay of roughly 0.5–3 s. With a 60-second duty-cycle window and 3.6 s +max airtime, the node can forward at most ~32 packets per minute at full budget. +If all 32 arrive within one second (they cannot physically, but as an upper +bound), 32 tasks would be in-flight simultaneously. A cap of 30 is aggressive +enough to protect against unbounded growth but not so low that it would drop +legitimate traffic under any realistic burst scenario. diff --git a/docs/pr_tx_serialization.md b/docs/pr_tx_serialization.md new file mode 100644 index 0000000..b9e84d0 --- /dev/null +++ b/docs/pr_tx_serialization.md @@ -0,0 +1,395 @@ +# PR: Serialise Radio TX and Close Duty-Cycle TOCTOU Race + +**Branch:** `fix/tx-serialization` +**Base:** `rightup/fix-perfom-speed` +**Files changed:** `repeater/engine.py` (1 file, ~30 lines net) + +--- + +## Problem + +Two separate bugs share the same root cause: concurrent `delayed_send` coroutines +racing each other at transmission time. + +### Bug 1 — Interleaved SPI/serial commands to the radio + +The queue loop (added in an earlier commit) dispatches each incoming packet as an +`asyncio.create_task`, so multiple `delayed_send` coroutines can have their sleep +timers running concurrently. That is correct and intentional — it mirrors how +firmware nodes use a hardware timer so the radio keeps listening during a TX delay. + +However the LoRa radio is **half-duplex**: it can only transmit one packet at a +time. When two delay timers expire at nearly the same moment both coroutines call +`dispatcher.send_packet` simultaneously. `send_packet` issues a sequence of +SPI/serial register writes to the radio; two tasks interleaving these writes +produces undefined radio state and the transmission of neither packet is reliable. + +### Bug 2 — TOCTOU gap in duty-cycle enforcement + +`__call__` calls `can_transmit()` before scheduling a task: + +```python +# __call__ (before this fix) +can_tx, wait_time = self.airtime_mgr.can_transmit(airtime_ms) +if not can_tx: + ... # drop or defer +tx_task = await self.schedule_retransmit(fwd_pkt, delay, airtime_ms, ...) +``` + +`record_tx()` is only called later, inside `delayed_send`, after the sleep +completes. Between the check and the debit there is a window that spans the +entire TX delay (up to several seconds). Two packets that both pass the check +before either has slept and recorded its airtime will **both** be transmitted even +if transmitting both would exceed the duty-cycle budget. + +Under normal single-packet conditions this window is harmless. Under burst +conditions — multi-hop amplification, collision retries, or a busy mesh segment +where several packets arrive within the same delay window — multiple tasks pass +the advisory check simultaneously, and the duty-cycle limit is exceeded. + +--- + +## Root Cause + +There is no mutual exclusion around the radio send path. Each `delayed_send` +coroutine independently checks duty-cycle, sleeps, and transmits without +coordinating with any other concurrent coroutine doing the same thing. + +--- + +## Solution + +Add `self._tx_lock = asyncio.Lock()` (initialised in `__init__`) and acquire it +inside `delayed_send` **after** the sleep completes: + +``` +Delay timers run concurrently (unchanged): + Task A: sleep(1.2s) ──────────────────► acquire _tx_lock → check → TX A → release + Task B: sleep(0.9s) ──────────────────► acquire _tx_lock (waits) ──────────► check → TX B → release + Task C: sleep(2.1s) ────────────────────────────────────────────────────────────────► ... + +Radio: one packet at a time, duty-cycle state always stable inside the lock. +``` + +Inside the lock, a **second** `can_transmit()` call is made immediately before +sending. Because only one task holds the lock at a time, airtime state is stable +at this point and `record_tx()` follows on success — check and debit are +effectively atomic. This closes the TOCTOU window completely. + +The upfront `can_transmit()` in `__call__` is retained as an **advisory** fast +path: it still drops or defers packets that are obviously over budget before a +delay task is even scheduled, avoiding unnecessary sleep timers. It is no longer +the enforcement point. + +--- + +## Why This Is the Right Approach + +### Alternative A — Move `record_tx()` before the sleep + +```python +# hypothetical +self.airtime_mgr.record_tx(airtime_ms) # reserve before sleeping +await asyncio.sleep(delay) +await self.dispatcher.send_packet(...) # actual TX +``` + +Records airtime even if the send fails (exception, LBT busy, radio error) — +the budget is debited for a packet that was never transmitted. Over time this +inflates the apparent airtime, causing the node to throttle legitimate traffic +it actually has budget for. Requires a compensating `release_airtime()` on +every failure path, creating new complexity and failure modes. + +### Alternative B — A single global advisory check (status quo before this PR) + +Already demonstrated to fail under burst conditions (two tasks both pass before +either records its airtime). + +### Alternative C — asyncio.Lock (this PR) + +- Delay timers remain concurrent — no regression on the primary non-blocking TX + improvement. +- The check-and-debit pair is atomic within the lock — no TOCTOU window. +- No phantom airtime on send failure — `record_tx()` is only called on success. +- One `asyncio.Lock` object, no new state machines or compensating paths. +- The lock is `async`, so it only blocks other TX tasks, not the event loop or + the packet RX queue. + +### Why `asyncio.Lock` rather than `threading.Lock` + +The entire repeater runs on a single asyncio event loop. `asyncio.Lock` only +yields at `await` points; it does not involve OS threads or context switches. +A `threading.Lock` would work but is semantically wrong here (this is not a +thread-safety problem) and would block the event loop thread if held across an +`await`. + +--- + +## Changes + +### `repeater/engine.py` + +**1. Move `import random` to module level** + +```python +# before (inside _calculate_tx_delay): +def _calculate_tx_delay(self, packet, snr=0.0): + import random + ... + +# after (top of file, with other stdlib imports): +import random +``` + +This is a housekeeping fix bundled with this PR because `random` is a stdlib +module that should never be imported inside a hot-path function — Python caches +the import after the first call, but the attribute lookup and cache check still +run on every call. Moving it to module level is the standard pattern. + +**2. Add `self._tx_lock` to `__init__`** + +```python +# Serialise all radio TX calls. +# +# Background: since the queue loop dispatches each packet as an +# asyncio.create_task, multiple _route_packet coroutines can have their +# TX delay timers running concurrently — which is the intended behaviour +# (firmware nodes do the same with a hardware timer). However, the +# LoRa radio is half-duplex: it can only transmit one packet at a time. +# Without serialisation, two tasks whose delay timers expire near- +# simultaneously both call dispatcher.send_packet, interleaving SPI/serial +# commands to the radio and both passing the LBT check before either has +# actually transmitted. +# +# _tx_lock is acquired after each delay sleep and held for the entire +# send_packet call. Delays still run concurrently; only the radio +# access is serialised. This also eliminates the TOCTOU gap in duty-cycle +# enforcement — see schedule_retransmit / delayed_send for details. +self._tx_lock = asyncio.Lock() +``` + +**3. Acquire lock inside `delayed_send`, add authoritative duty-cycle gate** + +```python +async def delayed_send(): + await asyncio.sleep(delay) + + # Acquire the TX lock *after* the delay so that delay timers for + # multiple packets still run concurrently (matching firmware). Only + # one coroutine enters the radio send path at a time. + async with self._tx_lock: + # ── Authoritative duty-cycle gate ───────────────────────────── + # The upfront can_transmit() call in __call__ is advisory: it + # avoids scheduling packets that are obviously over budget, but + # it cannot prevent a race between two tasks whose delay timers + # expire at almost the same moment. Both tasks pass the advisory + # check before either has recorded its airtime, then both try to + # transmit. + # + # Inside _tx_lock only one task runs at a time, so airtime state + # is stable here. The check and the subsequent record_tx() are + # effectively atomic — no TOCTOU window. + if airtime_ms > 0: + can_tx_now, _ = self.airtime_mgr.can_transmit(airtime_ms) + if not can_tx_now: + logger.warning( + "Packet dropped at TX time: duty-cycle exceeded " + "(airtime=%.1fms)", airtime_ms, + ) + return + + 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) + self._record_packet_sent(fwd_pkt) + if airtime_ms > 0: + self.airtime_mgr.record_tx(airtime_ms) + ... +``` + +--- + +## Invariants Maintained + +| Property | Before | After | +|----------|--------|-------| +| Delay timers run concurrently | ✅ | ✅ | +| Radio accessed by one task at a time | ❌ | ✅ | +| Duty-cycle check and debit atomic | ❌ | ✅ | +| Airtime recorded only on TX success | ✅ | ✅ | +| Event loop not blocked by lock | ✅ | ✅ (asyncio.Lock) | + +--- + +## Test Plan + +### Unit tests (can run without hardware) + +**T1 — Serial TX ordering** + +```python +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +async def test_tx_serialized(): + """Two tasks whose delays expire simultaneously must not interleave.""" + send_order = [] + send_lock = asyncio.Lock() + + async def mock_send(pkt, **kw): + # Confirm the _tx_lock is already held when we enter send_packet + assert send_lock.locked(), "send_packet called without _tx_lock held" + send_order.append(pkt) + await asyncio.sleep(0) # yield; a second task must not enter here + + engine._tx_lock = send_lock # replace with the mock lock reference + engine.dispatcher.send_packet = mock_send + + t1 = asyncio.create_task(engine.schedule_retransmit(pkt_a, delay=0.01, airtime_ms=100)) + t2 = asyncio.create_task(engine.schedule_retransmit(pkt_b, delay=0.01, airtime_ms=100)) + await asyncio.gather(t1, t2) + + assert len(send_order) == 2 # both transmitted + assert send_order[0] is not send_order[1] # different packets +``` + +**T2 — Authoritative duty-cycle gate blocks over-budget second packet** + +```python +async def test_second_packet_dropped_when_over_budget(): + """When first TX fills the budget, second task must be dropped inside the lock.""" + # Set a tiny budget: 50ms per minute + engine.airtime_mgr.max_airtime_per_minute = 50 + + sent = [] + async def mock_send(pkt, **kw): + sent.append(pkt) + + engine.dispatcher.send_packet = mock_send + + # Each packet costs ~111ms (SF8, BW125, 30-byte payload) — first passes, second must not + t1 = asyncio.create_task(engine.schedule_retransmit(pkt_a, delay=0.01, airtime_ms=111)) + t2 = asyncio.create_task(engine.schedule_retransmit(pkt_b, delay=0.01, airtime_ms=111)) + await asyncio.gather(t1, t2) + + assert len(sent) == 1, f"Expected 1 TX, got {len(sent)}" +``` + +**T3 — Airtime not debited on TX failure** + +```python +async def test_airtime_not_recorded_on_send_failure(): + before = engine.airtime_mgr.total_airtime_ms + + async def failing_send(pkt, **kw): + raise RuntimeError("radio error") + + engine.dispatcher.send_packet = failing_send + + with pytest.raises(RuntimeError): + await engine.schedule_retransmit(pkt, delay=0, airtime_ms=100) + + assert engine.airtime_mgr.total_airtime_ms == before, \ + "Airtime must not be recorded when send raises" +``` + +**T4 — Advisory check still drops before scheduling (fast path not regressed)** + +```python +async def test_advisory_check_still_drops_obvious_overage(): + """__call__ should not even schedule a task when clearly over budget.""" + engine.airtime_mgr.max_airtime_per_minute = 0 # budget exhausted + + tasks_created = [] + original = asyncio.create_task + asyncio.create_task = lambda coro: tasks_created.append(coro) or original(coro) + + await engine(over_budget_packet, metadata={}) + + assert not tasks_created, "No task should be created when advisory check fails" +``` + +### Integration / field tests (with hardware) + +**T5 — Burst scenario: 5 packets arrive within the same delay window** + +1. Connect the repeater to a radio. +2. Using a second node, send 5 FLOOD packets in quick succession (< 100 ms apart) + with a low RSSI score so the repeater's delay is ~1–2 s for all of them. +3. Monitor the radio with a spectrum analyser or a third node running in monitor + mode. + +**Expected (after this fix):** +- Transmissions are sequential — no overlapping on-air signals. +- `Retransmitted packet` log lines appear one after another, each with a non-zero + airtime value. +- No `Retransmit failed` errors in the log. +- Duty-cycle log shows airtime accumulating correctly. + +**Expected (before this fix, to confirm the bug existed):** +- Occasional `Retransmit failed` errors under burst load. +- Airtime tracking diverging from actual on-air time (double-counted or missed). + +**T6 — Duty-cycle enforcement under burst** + +1. Set `max_airtime_per_minute` to a low value (e.g. 500 ms) in config. +2. Send 10 packets rapidly so the repeater tries to forward all 10. +3. Observe logs. + +**Expected:** +- First N packets transmitted (total airtime ≤ 500 ms). +- Subsequent packets log `"Packet dropped at TX time: duty-cycle exceeded"` from + inside `delayed_send` (not just the advisory drop). +- `airtime_mgr.get_stats()["utilization_percent"]` reads ≤ 100%. + +**T7 — Normal single-packet forwarding not regressed** + +1. Send one packet every 5 seconds (well within duty-cycle budget). +2. Verify each packet is forwarded with correct airtime logged. +3. Verify no lock contention warnings in the log. + +**T8 — Local TX retry path (local_transmission=True) still works** + +1. Send a command that triggers a local transmission (e.g. a ping reply). +2. Briefly block the radio (simulate with a mock) so the first attempt fails. +3. Verify the retry fires after 1 s and the packet is eventually transmitted. + +--- + +## Proof of Correctness + +### Why `asyncio.Lock` is sufficient (no OS-level synchronisation needed) + +Python's asyncio event loop is **single-threaded**. All coroutines share one +thread and only yield execution at `await` points. Between two consecutive +`await` calls in a coroutine, the event loop does not switch to another coroutine. + +`asyncio.Lock.acquire()` suspends the current coroutine if the lock is held, +returning control to the event loop. `asyncio.Lock.release()` wakes the next +waiter. Because `send_packet` is awaited inside the lock, no other TX task can +run until the current one releases the lock and the event loop gets a chance to +schedule the next waiter. + +There is no possibility of the race seen with `threading.Lock` where an OS thread +can be preempted mid-instruction. + +### Why the advisory check in `__call__` cannot be removed + +The advisory check is still necessary as a fast path. If it were removed, every +incoming packet — even when the node is clearly at 100% duty-cycle — would +schedule a `delayed_send` task that would sleep for the full TX delay (up to +several seconds) before the lock drops it. Under a sustained flood of incoming +packets this wastes memory and CPU. The advisory check prunes the queue early at +negligible cost. + +### Why `record_tx()` must be inside the lock (not before or after) + +- **Before the send:** records airtime for a packet that may never be transmitted + (send could fail, LBT could reject it). Budget is overcounted. +- **After releasing the lock:** a second task could pass the authoritative + `can_transmit()` check between `send_packet` returning and `record_tx()` being + called — the TOCTOU window reopens at a smaller scale. +- **Inside the lock, after a successful send:** the budget is debited exactly once + for exactly the packets that were actually transmitted. The lock ensures no + other task reads airtime state between the check and the debit. diff --git a/manage.sh b/manage.sh index 3ac627b..aa8dcde 100755 --- a/manage.sh +++ b/manage.sh @@ -4,16 +4,96 @@ set -e INSTALL_DIR="/opt/pymc_repeater" +VENV_DIR="$INSTALL_DIR/venv" +VENV_PIP="$VENV_DIR/bin/pip" +VENV_PYTHON="$VENV_DIR/bin/python" CONFIG_DIR="/etc/pymc_repeater" LOG_DIR="/var/log/pymc_repeater" SERVICE_USER="repeater" SERVICE_NAME="pymc-repeater" +SILENT_MODE="${PYMC_SILENT:-${SILENT:-}}" + +# R2 Wheels Configuration improves install speed on ARM devices +R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps" +R2_ENABLED=1 # Set to 0 to disable R2 wheels and always build from source + +# --------------------------------------------------------------------------- +# Virtual-environment helpers +# --------------------------------------------------------------------------- + +# Create (or re-create) the dedicated venv for pymc_repeater +ensure_venv() { + if [ ! -x "$VENV_PYTHON" ]; then + echo ">>> Creating virtual environment at $VENV_DIR ..." + python3 -m venv --system-site-packages "$VENV_DIR" + # Upgrade pip inside the venv + "$VENV_PIP" install --upgrade pip setuptools wheel >/dev/null 2>&1 || true + fi +} + +# Migrate an existing system-pip install into the venv. +# Idempotent: safe to call on every upgrade. +migrate_to_venv() { + echo ">>> Checking for legacy system-pip installation..." + + # 1. Ensure the venv exists + ensure_venv + + # 2. Remove legacy PYTHONPATH from the service unit + local svc_unit="/etc/systemd/system/pymc-repeater.service" + if [ -f "$svc_unit" ]; then + if grep -q 'PYTHONPATH' "$svc_unit" 2>/dev/null; then + sed -i '/^Environment=.*PYTHONPATH/d' "$svc_unit" + echo " ✓ Removed legacy PYTHONPATH from service unit" + fi + # 3. Fix WorkingDirectory if still pointing at old source + if grep -q 'WorkingDirectory=/opt/pymc_repeater' "$svc_unit" 2>/dev/null; then + sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/pymc_repeater|' "$svc_unit" + echo " ✓ Fixed WorkingDirectory in service unit" + fi + # 4. Ensure ExecStart uses the venv python + if grep -q 'ExecStart=/usr/bin/python3' "$svc_unit" 2>/dev/null; then + sed -i "s|ExecStart=/usr/bin/python3|ExecStart=$VENV_PYTHON|" "$svc_unit" + echo " ✓ Updated ExecStart to use venv python" + fi + systemctl daemon-reload + fi + + # 5. Remove the package from system python (best-effort) + python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true + python3 -m pip uninstall -y pymc_core 2>/dev/null || true + echo " ✓ Cleaned up system-level packages (if any)" + + # 6. Remove stale source trees that could shadow the venv package + if [ -d "$INSTALL_DIR/repeater" ]; then + rm -rf "$INSTALL_DIR/repeater" + echo " ✓ Removed stale source tree from $INSTALL_DIR/repeater" + fi +} + +is_silent_flag() { + case "${1:-}" in + --silent|-y|silent) return 0 ;; + *) return 1 ;; + esac +} + +is_interactive_flag() { + case "${1:-}" in + --interactive|-i|interactive) return 0 ;; + *) return 1 ;; + esac +} # Check if we're running in an interactive terminal if [ ! -t 0 ] || [ -z "$TERM" ]; then - echo "Error: This script requires an interactive terminal." - echo "Please run from SSH or a local terminal, not via file manager." - exit 1 + if [[ "$1" =~ ^(upgrade|start|stop|restart)$ ]] && ! is_interactive_flag "$2"; then + : + else + echo "Error: This script requires an interactive terminal." + echo "Please run from SSH or a local terminal, not via file manager." + exit 1 + fi fi # Check if whiptail is available, fallback to dialog @@ -70,12 +150,21 @@ is_running() { systemctl is-active "$SERVICE_NAME" >/dev/null 2>&1 } +# Function to check if service is enabled +is_enabled() { + systemctl is-enabled "$SERVICE_NAME" >/dev/null 2>&1 +} + # Function to get current version get_version() { - if [ -f "$INSTALL_DIR/pyproject.toml" ]; then - grep "^version" "$INSTALL_DIR/pyproject.toml" | cut -d'"' -f2 2>/dev/null || echo "unknown" + # Read version from the pip-installed package in the venv + if [ -x "$VENV_PYTHON" ]; then + "$VENV_PYTHON" -c "from importlib.metadata import version; print(version('pymc_repeater'))" 2>/dev/null \ + || echo "not installed" else - echo "not installed" + # Fallback: try system python for pre-migration installs + python3 -c "from importlib.metadata import version; print(version('pymc_repeater'))" 2>/dev/null \ + || echo "not installed" fi } @@ -93,10 +182,11 @@ 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" \ + "reset" "reset existing installation to defaults" \ "uninstall" "Remove pyMC Repeater completely" \ "config" "Configure radio settings" \ "start" "Start the service" \ @@ -105,7 +195,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 @@ -116,7 +206,14 @@ show_main_menu() { ;; "upgrade") if is_installed; then - upgrade_repeater + upgrade_repeater "false" + else + show_error "pyMC Repeater is not installed!\n\nUse 'install' first." + fi + ;; + "reset") + if is_installed; then + reset_repeater else show_error "pyMC Repeater is not installed!\n\nUse 'install' first." fi @@ -132,19 +229,22 @@ show_main_menu() { configure_radio ;; "start") - manage_service "start" + manage_service "start" "false" ;; "stop") - manage_service "stop" + manage_service "stop" "false" ;; "restart") - manage_service "restart" + manage_service "restart" "false" ;; "logs") clear - echo "=== Live Logs (Press Ctrl+C to return) ===" + echo -e "\033[1;36m╔══════════════════════════════════════════════════════════════════════╗\033[0m" + echo -e "\033[1;36m║\033[0m \033[1;37mpyMC Repeater - Live Logs\033[0m \033[1;36m║\033[0m" + echo -e "\033[1;36m║\033[0m \033[0;90m(Press Ctrl+C to return)\033[0m \033[1;36m║\033[0m" + echo -e "\033[1;36m╚══════════════════════════════════════════════════════════════════════╝\033[0m" echo "" - journalctl -u "$SERVICE_NAME" -f + journalctl -u "$SERVICE_NAME" -f -o cat --no-hostname | sed -e 's/.*ERROR.*/\x1b[1;31m&\x1b[0m/' -e 's/.*CRITICAL.*/\x1b[1;41;37m&\x1b[0m/' -e 's/.*WARNING.*/\x1b[1;33m&\x1b[0m/' -e 's/.*INFO.*/\x1b[0;32m&\x1b[0m/' -e 's/.*DEBUG.*/\x1b[0;36m&\x1b[0m/' ;; "status") show_detailed_status @@ -162,52 +262,99 @@ 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 Raspberry Pi as a LoRa mesh network repeater.\n\nPress OK to continue..." 12 70 - - # SPI Check - CONFIG_FILE="" - if [ -f "/boot/firmware/config.txt" ]; then - CONFIG_FILE="/boot/firmware/config.txt" - elif [ -f "/boot/config.txt" ]; then - CONFIG_FILE="/boot/config.txt" + + # Welcome screen (Bypass if the script was passd with the "install" option, assume we want a silent install) + if [[ "${1:-}" != "install" ]]; then + $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 fi - - if [ -n "$CONFIG_FILE" ] && ! grep -q "dtparam=spi=on" "$CONFIG_FILE" 2>/dev/null && ! grep -q "spi_bcm2835" /proc/modules 2>/dev/null; then - if ask_yes_no "SPI Not Enabled" "\nSPI interface is required but not enabled!\n\nWould you like to enable it now?\n(This will require a reboot)"; then - echo "dtparam=spi=on" >> "$CONFIG_FILE" - show_info "SPI Enabled" "\nSPI has been enabled in $CONFIG_FILE\n\nSystem will reboot now. Please run this script again after reboot." - reboot - else - show_error "SPI is required for LoRa radio operation.\n\nPlease enable SPI manually and run this script again." - return + + # SPI Check - Universal approach that works on all boards (skip for CH341 USB-SPI adapter) + SPI_MISSING=0 + USES_CH341=0 + if [ -f "$CONFIG_DIR/config.yaml" ]; then + if grep -q "radio_type:.*sx1262_ch341" "$CONFIG_DIR/config.yaml" 2>/dev/null; then + USES_CH341=1 fi - elif [ -z "$CONFIG_FILE" ]; then - show_error "Could not find config.txt file.\n\nPlease enable SPI manually:\nsudo raspi-config -> Interfacing Options -> SPI -> Enable" - return fi - + + if [ "$USES_CH341" -eq 0 ] && ! ls /dev/spidev* >/dev/null 2>&1; then + # SPI devices not found, check if we're on a Raspberry Pi and can enable it + CONFIG_FILE="" + if [ -f "/boot/firmware/config.txt" ]; then + CONFIG_FILE="/boot/firmware/config.txt" + 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 + echo "dtparam=spi=on" >> "$CONFIG_FILE" + show_info "SPI Enabled" "\nSPI has been enabled in $CONFIG_FILE\n\nSystem will reboot now. Please run this script again after reboot." + reboot + else + if ask_yes_no "Continue Without SPI?" "\nSPI is required for LoRa radio operation and is not enabled.\n\nYou can continue the installation, but the radio will not work until SPI is enabled.\n\nContinue anyway?"; then + SPI_MISSING=1 + else + show_error "SPI is required for LoRa radio operation.\n\nPlease enable SPI manually and run this script again." + return + fi + fi + else + # Not a Raspberry Pi - provide generic instructions + if ask_yes_no "SPI Not Detected" "\nSPI interface is required but not detected (/dev/spidev* not found).\n\nPlease enable SPI in your system's configuration and ensure the SPI kernel module is loaded.\n\nFor Raspberry Pi: sudo raspi-config -> Interfacing Options -> SPI -> Enable\n\nContinue installation anyway?"; then + SPI_MISSING=1 + else + show_error "SPI interface is required but not detected (/dev/spidev* not found).\n\nPlease enable SPI in your system's configuration and ensure the SPI kernel module is loaded.\n\nFor Raspberry Pi: sudo raspi-config -> Interfacing Options -> SPI -> Enable" + return + fi + fi + fi + + if [ "$SPI_MISSING" -eq 1 ]; then + show_info "Warning" "\nContinuing without SPI enabled.\n\nLoRa radio will not work until SPI is enabled and /dev/spidev* is available." + fi + + # Get script directory for file copying during installation + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + # Installation progress - ( - echo "0"; echo "# Creating service user..." + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo " Installing pyMC Repeater" + echo "═══════════════════════════════════════════════════════════════" + echo "" + + 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 - + for grp in plugdev dialout gpio i2c spi; do + getent group "$grp" >/dev/null 2>&1 && usermod -a -G "$grp" "$SERVICE_USER" 2>/dev/null || true + done + 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 python3-pip python3-rrdtool wget swig build-essential python3-dev - + DEBIAN_FRONTEND=noninteractive apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-venv python3-rrdtool wget swig build-essential python3-dev + # Install polkit (package name varies by distro version) + DEBIAN_FRONTEND=noninteractive apt-get install -y policykit-1 2>/dev/null \ + || DEBIAN_FRONTEND=noninteractive apt-get install -y polkitd pkexec 2>/dev/null \ + || echo " Warning: Could not install polkit (sudo fallback will be used)" + # setuptools_scm needed for git version detection during build + pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || python3 -m pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || true + + echo "28"; echo "# Creating virtual environment..." + ensure_venv + # Install mikefarah yq v4 if not already installed if ! command -v yq &> /dev/null || [[ "$(yq --version 2>&1)" != *"mikefarah/yq"* ]]; then + echo ">>> Installing yq..." YQ_VERSION="v4.40.5" YQ_BINARY="yq_linux_arm64" if [[ "$(uname -m)" == "x86_64" ]]; then @@ -215,56 +362,220 @@ install_repeater() { elif [[ "$(uname -m)" == "armv7"* ]]; then YQ_BINARY="yq_linux_arm" fi - wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" && chmod +x /usr/local/bin/yq + wget -qO /usr/local/bin/yq "https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/${YQ_BINARY}" 2>/dev/null && chmod +x /usr/local/bin/yq fi - - echo "30"; echo "# Installing files..." - cp -r repeater "$INSTALL_DIR/" - cp pyproject.toml "$INSTALL_DIR/" - cp README.md "$INSTALL_DIR/" - cp setup-radio-config.sh "$INSTALL_DIR/" 2>/dev/null || true - cp radio-settings.json "$INSTALL_DIR/" 2>/dev/null || true - + + echo "29"; echo "# Installing files..." + cp "$SCRIPT_DIR/manage.sh" "$INSTALL_DIR/" 2>/dev/null || true + 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 config.yaml.example "$CONFIG_DIR/config.yaml.example" + cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml.example" if [ ! -f "$CONFIG_DIR/config.yaml" ]; then - cp config.yaml.example "$CONFIG_DIR/config.yaml" + cp "$SCRIPT_DIR/config.yaml.example" "$CONFIG_DIR/config.yaml" fi - + echo "55"; echo "# Installing systemd service..." - cp pymc-repeater.service /etc/systemd/system/ + cp "$SCRIPT_DIR/pymc-repeater.service" /etc/systemd/system/ systemctl daemon-reload - + + echo "58"; echo "# Installing udev rules for CH341..." + if [ -f "$SCRIPT_DIR/../pyMC_core/99-ch341.rules" ]; then + cp "$SCRIPT_DIR/../pyMC_core/99-ch341.rules" /etc/udev/rules.d/99-ch341.rules + udevadm control --reload-rules 2>/dev/null || true + udevadm trigger 2>/dev/null || true + fi + echo "65"; echo "# Setting permissions..." - chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater + # Venv stays root-owned (pip runs as root); service user only needs read+execute + chown -R "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater chmod 750 "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater + # Ensure manage.sh and support files in INSTALL_DIR are accessible + chown root:root "$INSTALL_DIR" + chmod 755 "$INSTALL_DIR" # Ensure the service user can create subdirectories in their home directory chmod 755 /var/lib/pymc_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 + + # Work out which version of polkit is installed + + POLKIT_VERSION=$(pkaction --version 2>/dev/null | awk '{print $NF}') + if echo "$POLKIT_VERSION" | awk '{ exit ($1 > 0.105) ? 0 : 1 }'; then + echo "Polkit 0.106 or greater detected, using rules file" + echo ">>> Configuring polkit for service management..." + mkdir -p /etc/polkit-1/rules.d + cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF' +polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") == "pymc-repeater.service" && + subject.user == "repeater") { + return polkit.Result.YES; + } +}); +EOF + chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules + else + echo "Polkit 0.105 or less detected, using pkla file" + mkdir -p /etc/polkit-1/localauthority/50-local.d + cat > /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla <<'EOF' +[Allow repeater to restart pymc-repeater service] +Identity=unix-user:repeater +Action=org.freedesktop.systemd1.manage-units +ResultAny=yes +ResultInactive=yes +ResultActive=yes +EOF + chmod 0644 /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla + fi + + # Also configure sudoers as fallback for service restart + echo ">>> Configuring sudoers for service management..." + mkdir -p /etc/sudoers.d + cat > /etc/sudoers.d/pymc-repeater <<'EOF' +# Allow repeater user to manage the pymc-repeater service without password +repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater, /usr/local/bin/pymc-do-upgrade +EOF + chmod 0440 /etc/sudoers.d/pymc-repeater + + echo ">>> Installing OTA upgrade wrapper..." + cat > /usr/local/bin/pymc-do-upgrade <<'UPGRADEEOF' +#!/bin/bash +# pymc-do-upgrade: invoked by the repeater service user via sudo for OTA upgrades. +# Usage: sudo /usr/local/bin/pymc-do-upgrade [channel] [pretend-version] +set -e +CHANNEL="${1:-main}" +PRETEND_VERSION="${2:-}" +VENV_DIR="/opt/pymc_repeater/venv" +VENV_PIP="$VENV_DIR/bin/pip" +VENV_PYTHON="$VENV_DIR/bin/python" +# Validate: only allow safe git ref characters +if ! [[ "$CHANNEL" =~ ^[a-zA-Z0-9._/-]{1,80}$ ]]; then + echo "Invalid channel name: $CHANNEL" >&2 + exit 1 +fi +# If caller supplied a version string, tell setuptools_scm to use it (sudo +# strips env vars so it is passed as a positional argument instead). +[ -n "$PRETEND_VERSION" ] && export SETUPTOOLS_SCM_PRETEND_VERSION="$PRETEND_VERSION" +# ---- Migration: ensure venv exists (handles upgrades from system-pip era) ---- +if [ ! -x "$VENV_PYTHON" ]; then + echo "[pymc-do-upgrade] Creating venv at $VENV_DIR ..." + python3 -m venv --system-site-packages "$VENV_DIR" + "$VENV_PIP" install --upgrade pip setuptools wheel >/dev/null 2>&1 || true +fi +# ---- Migration: clean up legacy service unit issues ---- +SVC_UNIT=/etc/systemd/system/pymc-repeater.service +if grep -q 'PYTHONPATH' "$SVC_UNIT" 2>/dev/null; then + sed -i '/^Environment=.*PYTHONPATH/d' "$SVC_UNIT" + systemctl daemon-reload +fi +if grep -q 'WorkingDirectory=/opt/pymc_repeater' "$SVC_UNIT" 2>/dev/null; then + sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/pymc_repeater|' "$SVC_UNIT" + systemctl daemon-reload +fi +if grep -q 'ExecStart=/usr/bin/python3' "$SVC_UNIT" 2>/dev/null; then + sed -i "s|ExecStart=/usr/bin/python3|ExecStart=$VENV_PYTHON|" "$SVC_UNIT" + systemctl daemon-reload +fi +# ---- Remove stale source trees that shadow the venv package ---- +[ -d /opt/pymc_repeater/repeater ] && rm -rf /opt/pymc_repeater/repeater +# ---- Remove old system-level packages to avoid confusion ---- +python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true +python3 -m pip uninstall -y pymc_core 2>/dev/null || true +# ---- Try R2 wheels first for faster OTA upgrades ---- +R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps" +MACHINE_ARCH=$(uname -m) +case "$MACHINE_ARCH" in + aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;; + armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;; + x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;; + *) ARCH_TAG=""; PLATFORM_TAG="" ;; +esac +if [ -n "$ARCH_TAG" ]; then + PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311") + WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}" + echo "[pymc-do-upgrade] Trying dependencies from R2 wheels..." + "$VENV_PIP" install --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null || true +fi +# ---- Install pymc_repeater from git ---- +exec "$VENV_PIP" install \ + --upgrade \ + --no-cache-dir \ + "pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}" +UPGRADEEOF + chmod 0755 /usr/local/bin/pymc-do-upgrade + 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 ===" echo "" - echo "Installing pymc_repeater and dependencies (including pymc_core from GitHub)..." + echo "Installing pymc_repeater and dependencies (including pymc_core from PyPI)..." echo "This may take a few minutes..." echo "" - + SCRIPT_DIR="$(dirname "$0")" cd "$SCRIPT_DIR" + + # Calculate version from git for setuptools_scm + if [ -d .git ]; then + git fetch --tags 2>/dev/null || true + GIT_VERSION=$(python3 -m setuptools_scm 2>/dev/null || echo "1.0.5") + export SETUPTOOLS_SCM_PRETEND_VERSION="$GIT_VERSION" + echo "Installing version: $GIT_VERSION" + else + export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5" + fi + # We don't have any binary wheels available for these on a LuckFox, so we need to ignore them on that platform. + if ! grep -q "Luckfox Pico" /proc/device-tree/model 2>/dev/null; then + # Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi) + export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil + fi + echo "Note: Using optimized binary wheels for faster installation" + echo "" + + # Ensure venv exists + ensure_venv + + echo "Installing pymc_repeater into venv ($VENV_DIR)..." - if python3 -m pip install --break-system-packages --force-reinstall --no-cache-dir --ignore-installed .; then + # Attempt R2 wheels first for faster installation + if [ "$R2_ENABLED" -eq 1 ]; then + MACHINE_ARCH=$(uname -m) + case "$MACHINE_ARCH" in + aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;; + armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;; + x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;; + *) ARCH_TAG=""; PLATFORM_TAG="" ;; + esac + if [ -n "$ARCH_TAG" ]; then + PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311") + WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}" + echo " Checking for R2 wheels (${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG})..." + echo " Trying install from R2 pre-built wheels..." + "$VENV_PIP" install --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null && R2_SUCCESS=1 || R2_SUCCESS=0 + if [ "$R2_SUCCESS" -eq 1 ]; then + echo " ✓ R2 wheels installed" + else + echo " - R2 wheels unavailable for this platform/tag, falling back" + fi + fi + fi + + if "$VENV_PIP" install --upgrade --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" @@ -274,75 +585,174 @@ install_repeater() { echo "Please check the error messages above and try again." read -p "Press Enter to continue..." || true fi - - # Radio configuration - echo "" - echo "=== Radio Configuration ===" - - # Run radio configuration - SCRIPT_DIR="$(dirname "$0")" - RADIO_SCRIPT="$SCRIPT_DIR/setup-radio-config.sh" - - if [ -f "$RADIO_SCRIPT" ]; then - clear - echo "=== pyMC Repeater Radio Configuration ===" - echo "" - - if bash "$RADIO_SCRIPT" "$CONFIG_DIR"; then - echo "" - echo "=== Radio Configuration Complete ===" - echo "Restarting service with new configuration..." - systemctl restart "$SERVICE_NAME" 2>/dev/null || true - sleep 2 - else - echo "⚠ Radio configuration failed, but installation is complete." - echo "You can run radio configuration later from the main menu." - fi - else - echo "⚠ Radio configuration script not found at $RADIO_SCRIPT" - echo "Installation complete, but you'll need to configure radio settings manually." - fi - + # Show final results sleep 2 local ip_address=$(hostname -I | awk '{print $1}') if is_running; then - local msg="\nInstallation and configuration completed successfully!\n\n✓ Service is running\n✓ Radio configured\n\nWeb Dashboard: http://$ip_address:8000\n\nView logs: Select 'logs' from main menu" - show_info "Installation Complete" "$msg" + clear + echo "═══════════════════════════════════════════════════════════════" + echo " ✓ Installation Completed Successfully!" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo "Service is running on:" + echo " → http://$ip_address:8000" + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo " NEXT STEP: Complete Web Setup Wizard" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo "Open the web dashboard in your browser to complete setup:" + echo "" + echo " 1. Navigate to: http://$ip_address:8000" + echo " 2. Complete the 5-step setup wizard:" + echo " • Choose repeater name" + echo " • Select hardware board" + echo " • Configure radio settings" + echo " • Set admin password" + echo " 3. Log in to your configured repeater" + echo "" + # Container detection: warn about host-side udev rules + if [ -f /run/host/container-manager ] || [ -n "${container:-}" ] || grep -qsai 'container=' /proc/1/environ 2>/dev/null || [ -f /.dockerenv ]; then + echo "═══════════════════════════════════════════════════════════════" + echo " ⚠ CONTAINER ENVIRONMENT DETECTED" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo " USB device udev rules do NOT work inside containers." + echo " You MUST install the CH341 udev rule on the HOST machine:" + echo "" + echo " echo 'SUBSYSTEM==\"usb\", ATTR{idVendor}==\"1a86\", ATTR{idProduct}==\"5512\", MODE=\"0666\"' \\" + echo " | sudo tee /etc/udev/rules.d/99-ch341.rules" + echo " sudo udevadm control --reload-rules" + echo " sudo udevadm trigger --subsystem-match=usb --action=change" + echo "" + echo " Then unplug and replug the CH341 USB adapter." + echo "" + fi + echo "═══════════════════════════════════════════════════════════════" + echo "" + if [[ "${1:-}" != "install" ]]; then #Headless install support + read -p "Press Enter to return to main menu..." || true + fi else show_error "Installation completed but service failed to start!\n\nCheck logs from the main menu for details." fi } -# Upgrade function -upgrade_repeater() { +# Reset function +reset_repeater() { + local config_file="$CONFIG_DIR/config.yaml" + local updated_example="$CONFIG_DIR/config.yaml.example" + 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 - + + 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 + echo " ✓ Configuration backed up" + fi + echo "3/4 Restore default config.yaml from config.yaml.example" + cp $updated_example $config_file + sleep 5 + # Reload systemd and start the service + echo "4/4 Restart the service" + systemctl daemon-reload + systemctl start "$SERVICE_NAME" + # Show final results + sleep 2 + local ip_address=$(hostname -I | awk '{print $1}') + if is_running; then + clear + echo "═══════════════════════════════════════════════════════════════" + echo " ✓ Reset Completed Successfully!" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo "Service is running on:" + echo " → http://$ip_address:8000" + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo " NEXT STEP: Complete Web Setup Wizard" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo "Open the web dashboard in your browser to complete setup:" + echo "" + echo " 1. Navigate to: http://$ip_address:8000" + echo " 2. Complete the 5-step setup wizard:" + echo " • Choose repeater name" + echo " • Select hardware board" + echo " • Configure radio settings" + echo " • Set admin password" + echo " 3. Log in to your configured repeater" + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo "" + read -p "Press Enter to return to main menu..." || true + else + show_error "Installation completed but service failed to start!\n\nCheck logs from the main menu for details." + fi + fi +} + +# Upgrade function +upgrade_repeater() { + local silent="${1:-false}" + if [ "$EUID" -ne 0 ]; then + if [[ "$silent" == "true" ]]; then + echo "Upgrade requires root privileges. Please run: sudo $0 upgrade" + else + show_error "Upgrade requires root privileges.\n\nPlease run: sudo $0" + fi + return 1 + fi + + local current_version=$(get_version) + + if [[ "$silent" != "true" ]]; then + if ! ask_yes_no "Confirm Upgrade" "Current version: $current_version\n\nThis will upgrade pyMC Repeater while preserving your configuration.\n\nContinue?"; then + return 0 + fi + # 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." - + else + echo "Starting upgrade process..." + echo "Current version: $current_version" + fi + 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 python3-pip python3-rrdtool wget swig build-essential python3-dev - + apt-get install -y libffi-dev libusb-1.0-0 sudo jq pip python3-venv python3-rrdtool wget swig build-essential python3-dev + # Install polkit (package name varies by distro version) + apt-get install -y policykit-1 2>/dev/null \ + || apt-get install -y polkitd pkexec 2>/dev/null \ + || echo " Warning: Could not install polkit (sudo fallback will be used)" + pip install --break-system-packages setuptools_scm >/dev/null 2>&1 || python3 -m 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" @@ -355,131 +765,302 @@ 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 "[4/9] Installing new files..." - cp -r repeater "$INSTALL_DIR/" 2>/dev/null || true - cp pyproject.toml "$INSTALL_DIR/" 2>/dev/null || true - cp README.md "$INSTALL_DIR/" 2>/dev/null || true - cp pymc-repeater.service /etc/systemd/system/ 2>/dev/null || true + + echo "[4/9] Installing files..." + SCRIPT_DIR="$(dirname "$0")" + if ! cp "$SCRIPT_DIR/pymc-repeater.service" /etc/systemd/system/; then + echo " ⚠ Warning: Failed to update service file – old service file may remain" + fi + 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 " ✓ 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 "[5.5/9] Ensuring user groups and udev rules..." + for grp in plugdev dialout gpio i2c spi; do + getent group "$grp" >/dev/null 2>&1 && usermod -a -G "$grp" "$SERVICE_USER" 2>/dev/null || true + done + # Install/update CH341 udev rules + SCRIPT_DIR_UPGRADE="$(cd "$(dirname "$0")" && pwd)" + if [ -f "$SCRIPT_DIR_UPGRADE/../pyMC_core/99-ch341.rules" ]; then + cp "$SCRIPT_DIR_UPGRADE/../pyMC_core/99-ch341.rules" /etc/udev/rules.d/99-ch341.rules + udevadm control --reload-rules 2>/dev/null || true + udevadm trigger 2>/dev/null || true + echo " ✓ CH341 udev rules updated" + elif [ -f /etc/udev/rules.d/99-ch341.rules ]; then + echo " ✓ CH341 udev rules already present" + fi + echo " ✓ User groups updated" + 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 + + # Venv stays root-owned (pip runs as root); service user only needs read+execute + chown -R "$SERVICE_USER:$SERVICE_USER" "$CONFIG_DIR" "$LOG_DIR" /var/lib/pymc_repeater 2>/dev/null || true + chown root:root "$INSTALL_DIR" 2>/dev/null || true + chmod 755 "$INSTALL_DIR" 2>/dev/null || true chmod 750 "$CONFIG_DIR" "$LOG_DIR" 2>/dev/null || true chmod 755 /var/lib/pymc_repeater 2>/dev/null || true + # Pre-create the .config directory that the service will need mkdir -p /var/lib/pymc_repeater/.config/pymc_repeater 2>/dev/null || true chown -R "$SERVICE_USER:$SERVICE_USER" /var/lib/pymc_repeater/.config 2>/dev/null || true - echo " ✓ Permissions updated" + # Configure polkit for passwordless service restart + POLKIT_VERSION=$(pkaction --version 2>/dev/null | awk '{print $NF}') + if echo "$POLKIT_VERSION" | awk '{ exit ($1 > 0.105) ? 0 : 1 }'; then + echo "Polkit 0.106 or greater detected, using rules file" + echo ">>> Configuring polkit for service management..." + mkdir -p /etc/polkit-1/rules.d + cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <<'EOF' +polkit.addRule(function(action, subject) { + if (action.id == "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") == "pymc-repeater.service" && + subject.user == "repeater") { + return polkit.Result.YES; + } +}); +EOF + chmod 0644 /etc/polkit-1/rules.d/10-pymc-repeater.rules + else + echo "Polkit 0.105 or less detected, using pkla file" + mkdir -p /etc/polkit-1/localauthority/50-local.d + cat > /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla <<'EOF' +[Allow repeater to restart pymc-repeater service] +Identity=unix-user:repeater +Action=org.freedesktop.systemd1.manage-units +ResultAny=yes +ResultInactive=yes +ResultActive=yes +EOF + chmod 0644 /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla + fi + # Also configure sudoers as fallback for service restart + mkdir -p /etc/sudoers.d + cat > /etc/sudoers.d/pymc-repeater <<'EOF' +# Allow repeater user to manage the pymc-repeater service without password +repeater ALL=(root) NOPASSWD: /usr/bin/systemctl restart pymc-repeater, /usr/bin/systemctl stop pymc-repeater, /usr/bin/systemctl start pymc-repeater, /usr/bin/systemctl status pymc-repeater, /usr/local/bin/pymc-do-upgrade +EOF + chmod 0440 /etc/sudoers.d/pymc-repeater + # Install / refresh OTA upgrade wrapper + cat > /usr/local/bin/pymc-do-upgrade <<'UPGRADEEOF' +#!/bin/bash +# pymc-do-upgrade: invoked by the repeater service user via sudo for OTA upgrades. +# Usage: sudo /usr/local/bin/pymc-do-upgrade [channel] [pretend-version] +set -e +CHANNEL="${1:-main}" +PRETEND_VERSION="${2:-}" +VENV_DIR="/opt/pymc_repeater/venv" +VENV_PIP="$VENV_DIR/bin/pip" +VENV_PYTHON="$VENV_DIR/bin/python" +# Validate: only allow safe git ref characters +if ! [[ "$CHANNEL" =~ ^[a-zA-Z0-9._/-]{1,80}$ ]]; then + echo "Invalid channel name: $CHANNEL" >&2 + exit 1 +fi +# If caller supplied a version string, tell setuptools_scm to use it (sudo +# strips env vars so it is passed as a positional argument instead). +[ -n "$PRETEND_VERSION" ] && export SETUPTOOLS_SCM_PRETEND_VERSION="$PRETEND_VERSION" +# ---- Migration: ensure venv exists (handles upgrades from system-pip era) ---- +if [ ! -x "$VENV_PYTHON" ]; then + echo "[pymc-do-upgrade] Creating venv at $VENV_DIR ..." + python3 -m venv --system-site-packages "$VENV_DIR" + "$VENV_PIP" install --upgrade pip setuptools wheel >/dev/null 2>&1 || true +fi +# ---- Migration: clean up legacy service unit issues ---- +SVC_UNIT=/etc/systemd/system/pymc-repeater.service +if grep -q 'PYTHONPATH' "$SVC_UNIT" 2>/dev/null; then + sed -i '/^Environment=.*PYTHONPATH/d' "$SVC_UNIT" + systemctl daemon-reload +fi +if grep -q 'WorkingDirectory=/opt/pymc_repeater' "$SVC_UNIT" 2>/dev/null; then + sed -i 's|WorkingDirectory=/opt/pymc_repeater|WorkingDirectory=/var/lib/pymc_repeater|' "$SVC_UNIT" + systemctl daemon-reload +fi +if grep -q 'ExecStart=/usr/bin/python3' "$SVC_UNIT" 2>/dev/null; then + sed -i "s|ExecStart=/usr/bin/python3|ExecStart=$VENV_PYTHON|" "$SVC_UNIT" + systemctl daemon-reload +fi +# ---- Remove stale source trees that shadow the venv package ---- +[ -d /opt/pymc_repeater/repeater ] && rm -rf /opt/pymc_repeater/repeater +# ---- Remove old system-level packages to avoid confusion ---- +python3 -m pip uninstall -y pymc_repeater 2>/dev/null || true +python3 -m pip uninstall -y pymc_core 2>/dev/null || true + # ---- Try R2 wheels first for faster OTA upgrades ---- + R2_BASE_URL="https://wheel.pymc.dev/pymc_build_deps" + MACHINE_ARCH=$(uname -m) + case "$MACHINE_ARCH" in + aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;; + armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;; + x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;; + *) ARCH_TAG=""; PLATFORM_TAG="" ;; + esac + if [ -n "$ARCH_TAG" ]; then + PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311") + WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}" + echo "[pymc-do-upgrade] Trying dependencies from R2 wheels..." + "$VENV_PIP" install --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null || true + fi + # ---- Install pymc_repeater from git ---- + exec "$VENV_PIP" install \ + --upgrade \ + --no-cache-dir \ + "pymc_repeater[hardware] @ git+https://github.com/rightup/pyMC_Repeater.git@${CHANNEL}" +UPGRADEEOF + chmod 0755 /usr/local/bin/pymc-do-upgrade + 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 "Updating pymc_repeater and dependencies (including pymc_core from PyPI)..." echo "This may take a few minutes..." echo "" - + # Install from source directory to properly resolve Git dependencies SCRIPT_DIR="$(dirname "$0")" cd "$SCRIPT_DIR" - - if python3 -m pip install --break-system-packages --force-reinstall --no-cache-dir --ignore-installed .; then - echo "" - echo "✓ Python package update completed successfully!" + + # Calculate version from git for setuptools_scm + if [ -d .git ]; then + git fetch --tags 2>/dev/null || true + GIT_VERSION=$(python3 -m setuptools_scm 2>/dev/null || echo "1.0.5") + export SETUPTOOLS_SCM_PRETEND_VERSION="$GIT_VERSION" + echo "Upgrading to version: $GIT_VERSION" else - echo "" - echo "⚠ Python package update failed, but continuing..." + export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5" + fi + + # We don't have any binary wheels available for these on a LuckFox, so we need to ignore them on that platform. + if ! grep -q "Luckfox Pico" /proc/device-tree/model 2>/dev/null; then + # Force binary wheels for slow-to-compile packages (much faster on Raspberry Pi) + export PIP_ONLY_BINARY=pycryptodome,cffi,PyNaCl,psutil + fi + echo "Note: Using optimized binary wheels for faster installation" + echo "" + + # Migrate from system pip to venv (idempotent) + migrate_to_venv + + # Install into the venv (clean, no system-packages flags needed) + echo "Upgrading pymc_repeater into venv ($VENV_DIR)..." + + # Attempt R2 wheels first for faster installation + if [ "$R2_ENABLED" -eq 1 ]; then + MACHINE_ARCH=$(uname -m) + case "$MACHINE_ARCH" in + aarch64) ARCH_TAG="arm64"; PLATFORM_TAG="aarch64" ;; + armv7l|armv7) ARCH_TAG="armv7"; PLATFORM_TAG="armv7l" ;; + x86_64) ARCH_TAG="x86_64"; PLATFORM_TAG="x86_64" ;; + *) ARCH_TAG=""; PLATFORM_TAG="" ;; + esac + if [ -n "$ARCH_TAG" ]; then + PY_TAG=$("$VENV_PYTHON" -c 'import sys; v=f"cp{sys.version_info.major}{sys.version_info.minor}"; print(f"{v}-{v}")' 2>/dev/null || echo "cp311-cp311") + WHEEL_BASE="${R2_BASE_URL}/${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG}" + echo " Checking for R2 wheels (${ARCH_TAG}/${PLATFORM_TAG}/${PY_TAG})..." + echo " Trying install from R2 pre-built wheels..." + "$VENV_PIP" install --find-links "${WHEEL_BASE}/index.html" --no-cache-dir "pycryptodome>=3.23.0" "PyNaCl>=1.5.0" cffi "pyyaml>=6.0.0" 2>/dev/null && R2_SUCCESS=1 || R2_SUCCESS=0 + if [ "$R2_SUCCESS" -eq 1 ]; then + echo " ✓ R2 wheels installed" + else + echo " - R2 wheels unavailable for this platform/tag, falling back" + fi + fi fi + if "$VENV_PIP" install --upgrade --no-cache-dir .[hardware]; then + echo "" + echo "✓ Package and dependencies upgraded successfully!" + else + echo "" + echo "⚠ Package upgrade failed, but continuing..." + fi + + 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" + # Container detection: warn about host-side udev rules + local container_note="" + if [ -f /run/host/container-manager ] || [ -n "${container:-}" ] || grep -qsai 'container=' /proc/1/environ 2>/dev/null || [ -f /.dockerenv ]; then + container_note="\n\n⚠ CONTAINER DETECTED:\nUSB udev rules must be set on the HOST, not here.\nSee documentation for CH341 host-side setup." + fi + if [[ "$silent" == "true" ]]; then + echo "Upgrade completed successfully!" + echo "Version: $current_version -> $new_version" + echo "✓ Service is running" + echo "✓ Configuration preserved" + if [[ -n "$container_note" ]]; then + echo "$container_note" + fi + else + show_info "Upgrade Complete" "Upgrade completed successfully!\n\nVersion: $current_version → $new_version\n\n✓ Service is running\n✓ Configuration preserved${container_note}" + fi else echo " ✗ Service failed to start" - show_error "Upgrade completed but service failed to start!\n\nVersion updated: $current_version → $new_version\n\nCheck logs from the main menu for details." + if [[ "$silent" == "true" ]]; then + echo "Upgrade completed but service failed to start!" + echo "Version updated: $current_version -> $new_version" + echo "Check logs from the main menu for details." + else + show_error "Upgrade completed but service failed to start!\n\nVersion updated: $current_version → $new_version\n\nCheck logs from the main menu for details." + fi fi echo "=== Upgrade Complete ===" - fi } # Radio Configuration function configure_radio() { - # Check if config exists - if [ ! -f "$CONFIG_DIR/config.yaml" ]; then - show_error "Configuration file not found!\n\nPlease install pyMC Repeater first or ensure $CONFIG_DIR/config.yaml exists." + # Check if service is running + if ! is_running; then + show_error "Service is not running!\n\nPlease start the service first from the main menu." return fi - - # Check if setup script exists - SCRIPT_DIR="$(dirname "$0")" - RADIO_SCRIPT="$SCRIPT_DIR/setup-radio-config.sh" - - if [ ! -f "$RADIO_SCRIPT" ]; then - show_error "Radio configuration script not found!\n\nExpected: $RADIO_SCRIPT" - return - fi - - # Ask for confirmation - if ask_yes_no "Configure Radio Settings" "This will update your radio configuration including:\n\n- Repeater name\n- Hardware settings\n- Frequency and LoRa parameters\n\nThe service will be restarted after configuration.\n\nContinue?"; then - - # Show info that configuration is starting - show_info "Radio Configuration" "Starting radio configuration...\n\nThe configuration script will now run in the terminal.\n\nFollow the prompts to configure your radio settings." - - # Clear screen and run the configuration script + + # 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 - echo "=== pyMC Repeater Radio Configuration ===" + echo "═══════════════════════════════════════════════════════════════" + echo " Web-Based Radio Configuration" + echo "═══════════════════════════════════════════════════════════════" echo "" - - # Run the setup script with the config directory - if bash "$RADIO_SCRIPT" "$CONFIG_DIR"; then - echo "" - echo "=== Configuration Complete ===" - - # Restart service if it's installed and running - if is_installed; then - echo "Restarting service..." - if [ "$EUID" -eq 0 ]; then - systemctl restart "$SERVICE_NAME" 2>/dev/null || true - sleep 2 - - if is_running; then - echo "✓ Service restarted successfully" - show_info "Configuration Complete" "Radio configuration updated successfully!\n\n✓ Service restarted\n✓ New settings applied\n\nPress OK to return to main menu." - else - echo "✗ Service failed to restart" - show_error "Configuration updated but service failed to restart!\n\nCheck logs from the main menu for details." - fi - else - show_info "Configuration Complete" "Radio configuration updated successfully!\n\n⚠ Run as root to restart the service automatically\n\nPress OK to return to main menu." - fi - else - show_info "Configuration Complete" "Radio configuration updated successfully!\n\nPress OK to return to main menu." - fi - else - show_error "Configuration failed!\n\nThe radio configuration script encountered an error.\n\nPress OK to return to main menu." - fi - - # Pause to let user see any messages + echo "To configure your radio settings:" + echo "" + echo " 1. Open a web browser" + echo " 2. Navigate to: http://$ip_address:8000/setup" + echo " 3. Complete the setup wizard:" + echo " • Choose repeater name" + echo " • Select hardware board" + echo " • Configure radio settings" + echo " • Update passwords if needed" + echo " 4. Service will restart automatically with new settings" + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo "Note: The web interface is much easier than the old" + echo " terminal-based configuration!" + echo "" + echo "═══════════════════════════════════════════════════════════════" echo "" read -p "Press Enter to return to main menu..." || true fi @@ -491,36 +1072,48 @@ 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..." + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo " Uninstalling pyMC Repeater" + echo "═══════════════════════════════════════════════════════════════" + echo "" + + 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 "50"; echo "# Removing polkit and sudoers rules..." + rm -f /etc/polkit-1/rules.d/10-pymc-repeater.rules || true + rm -f /etc/polkit-1/localauthority/50-local.d/10-pymc-repeater.pkla || true + rm -f /etc/sudoers.d/pymc-repeater + rm -f /usr/local/bin/pymc-do-upgrade + 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 } @@ -528,36 +1121,70 @@ uninstall_repeater() { # Service management manage_service() { local action=$1 - + local silent="${2:-false}" + if [ "$EUID" -ne 0 ]; then - show_error "Service management requires root privileges.\n\nPlease run: sudo $0" - return + if [[ "$silent" == "true" ]]; then + echo "Service management requires root privileges. Please run: sudo $0 $action" + else + show_error "Service management requires root privileges.\n\nPlease run: sudo $0" + fi + return 1 fi - + if ! service_exists; then - show_error "Service is not installed." - return + if [[ "$silent" == "true" ]]; then + echo "Service is not installed." + else + show_error "Service is not installed." + fi + return 1 fi - + case $action in "start") + if ! is_enabled; then + systemctl enable "$SERVICE_NAME" + fi systemctl start "$SERVICE_NAME" if is_running; then - show_info "Service Started" "\n✓ pyMC Repeater service has been started successfully." + if [[ "$silent" == "true" ]]; then + echo "✓ pyMC Repeater service has been started successfully." + else + show_info "Service Started" "\n✓ pyMC Repeater service has been started successfully." + fi else - show_error "Failed to start service!\n\nCheck logs for details." + if [[ "$silent" == "true" ]]; then + echo "Failed to start service!" + echo "Check logs for details." + else + show_error "Failed to start service!\n\nCheck logs for details." + fi fi ;; "stop") systemctl stop "$SERVICE_NAME" - show_info "Service Stopped" "\n✓ pyMC Repeater service has been stopped." + if [[ "$silent" == "true" ]]; then + echo "✓ pyMC Repeater service has been stopped." + else + show_info "Service Stopped" "\n✓ pyMC Repeater service has been stopped." + fi ;; "restart") systemctl restart "$SERVICE_NAME" if is_running; then - show_info "Service Restarted" "\n✓ pyMC Repeater service has been restarted successfully." + if [[ "$silent" == "true" ]]; then + echo "✓ pyMC Repeater service has been restarted successfully." + else + show_info "Service Restarted" "\n✓ pyMC Repeater service has been restarted successfully." + fi else - show_error "Failed to restart service!\n\nCheck logs for details." + if [[ "$silent" == "true" ]]; then + echo "Failed to restart service!" + echo "Check logs for details." + else + show_error "Failed to restart service!\n\nCheck logs for details." + fi fi ;; esac @@ -568,14 +1195,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" @@ -583,7 +1210,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: " @@ -592,14 +1219,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" } @@ -608,7 +1235,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" @@ -616,41 +1243,48 @@ 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 + + # Strip comments from user config before merge to prevent comment accumulation. + # yq preserves comments from both files, so each upgrade cycle would duplicate + # the header and inline comments. We keep only the example's comments. + local stripped_user="${config_file}.stripped" + "$YQ_CMD" eval '... comments=""' "$config_file" > "$stripped_user" 2>/dev/null || cp "$config_file" "$stripped_user" + + if "$YQ_CMD" eval-all '. as $item ireduce ({}; . * $item)' "$updated_example" "$stripped_user" > "$temp_merged" 2>/dev/null; then + rm -f "$stripped_user" # Verify the merged file is valid YAML if "$YQ_CMD" eval '.' "$temp_merged" > /dev/null 2>&1; then mv "$temp_merged" "$config_file" @@ -665,7 +1299,7 @@ validate_and_update_config() { fi else echo " ✗ Config merge failed, keeping original" - rm -f "$temp_merged" + rm -f "$temp_merged" "$stripped_user" return 1 fi } @@ -678,12 +1312,13 @@ if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then echo "" echo "Actions:" echo " install - Install pyMC Repeater" - echo " upgrade - Upgrade existing installation" + echo " upgrade - Upgrade existing installation (CLI is silent by default; use --interactive to show dialogs)" echo " uninstall - Remove pyMC Repeater" echo " config - Configure radio settings" - echo " start - Start the service" - echo " stop - Stop the service" - echo " restart - Restart the service" + echo " start - Start the service (CLI is silent by default; use --interactive to show dialogs)" + echo " stop - Stop the service (CLI is silent by default; use --interactive to show dialogs)" + echo " restart - Restart the service (CLI is silent by default; use --interactive to show dialogs)" + echo " logs - View live logs" echo " status - Show status" echo " debug - Show debug information" echo "" @@ -710,11 +1345,15 @@ fi # Handle command line arguments case "$1" in "install") - install_repeater + install_repeater install exit 0 ;; "upgrade") - upgrade_repeater + silent_mode="true" + if is_interactive_flag "${2:-}" || [[ "$SILENT_MODE" == "0" || "$SILENT_MODE" == "false" ]]; then + silent_mode="false" + fi + upgrade_repeater "$silent_mode" exit 0 ;; "uninstall") @@ -726,9 +1365,22 @@ case "$1" in exit 0 ;; "start"|"stop"|"restart") - manage_service "$1" + silent_mode="true" + if is_interactive_flag "${2:-}" || [[ "$SILENT_MODE" == "0" || "$SILENT_MODE" == "false" ]]; then + silent_mode="false" + fi + manage_service "$1" "$silent_mode" exit 0 ;; + "logs") + clear + echo -e "\033[1;36m╔══════════════════════════════════════════════════════════════════════╗\033[0m" + echo -e "\033[1;36m║\033[0m \033[1;37mpyMC Repeater - Live Logs\033[0m \033[1;36m║\033[0m" + echo -e "\033[1;36m║\033[0m \033[0;90m(Press Ctrl+C to return)\033[0m \033[1;36m║\033[0m" + echo -e "\033[1;36m╚══════════════════════════════════════════════════════════════════════╝\033[0m" + echo "" + journalctl -u "$SERVICE_NAME" -f -o cat --no-hostname | sed -e 's/.*ERROR.*/\x1b[1;31m&\x1b[0m/' -e 's/.*CRITICAL.*/\x1b[1;41;37m&\x1b[0m/' -e 's/.*WARNING.*/\x1b[1;33m&\x1b[0m/' -e 's/.*INFO.*/\x1b[0;32m&\x1b[0m/' -e 's/.*DEBUG.*/\x1b[0;36m&\x1b[0m/' + ;; "status") show_detailed_status exit 0 diff --git a/pymc-repeater.service b/pymc-repeater.service index c73b0ba..72c7eba 100644 --- a/pymc-repeater.service +++ b/pymc-repeater.service @@ -1,7 +1,5 @@ -""" -Systemd service file template for Py MC - Meshcore Repeater Daemon. -Install as /etc/systemd/system/pymc-repeater.service -""" +#Systemd service file template for Py MC - Meshcore Repeater Daemon. +#Install as /etc/systemd/system/pymc-repeater.service [Unit] Description=pyMC Repeater Daemon @@ -12,27 +10,29 @@ Wants=network-online.target Type=simple User=repeater Group=repeater -WorkingDirectory=/opt/pymc_repeater -Environment="PYTHONPATH=/opt/pymc_repeater" +WorkingDirectory=/var/lib/pymc_repeater -# Start command - use python module directly with proper path -ExecStart=/usr/bin/python3 -m repeater.main --config /etc/pymc_repeater/config.yaml +# Start command - use venv python to avoid system package conflicts +ExecStart=/opt/pymc_repeater/venv/bin/python -m repeater.main --config /etc/pymc_repeater/config.yaml # Restart on failure Restart=on-failure RestartSec=5 +# Allow up to 10s for graceful shutdown before SIGKILL +TimeoutStopSec=10 + # Resource limits -MemoryLimit=256M +MemoryHigh=256M # Logging StandardOutput=journal StandardError=journal SyslogIdentifier=pymc-repeater -# Security (relaxed for proper operation) -NoNewPrivileges=true +# Security (relaxed for service self-restart via sudo) ReadWritePaths=/var/log/pymc_repeater /var/lib/pymc_repeater /etc/pymc_repeater +SupplementaryGroups=plugdev dialout [Install] WantedBy=multi-user.target diff --git a/pyproject.toml b/pyproject.toml index dc3eee2..98d02a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] -requires = ["setuptools>=61.0", "wheel"] +requires = ["setuptools>=61.0", "wheel", "setuptools_scm>=8.0"] build-backend = "setuptools.build_meta" [project] name = "pymc_repeater" -version = "1.0.5" +dynamic = ["version"] authors = [ {name = "Lloyd", email = "lloyd@rightup.co.uk"}, ] @@ -29,19 +29,28 @@ classifiers = [ keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"] - dependencies = [ - "pymc_core[hardware]", + "pymc_core[hardware]==1.0.10", "pyyaml>=6.0.0", "cherrypy>=18.0.0", "paho-mqtt>=1.6.0", "cherrypy-cors==1.7.0", "psutil>=5.9.0", + "pyjwt>=2.8.0", + "ws4py>=0.6.0", ] [project.optional-dependencies] +# SX1262/SPI support (Linux only; required for Raspberry Pi HATs) +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", @@ -52,9 +61,20 @@ dev = [ [project.scripts] pymc-repeater = "repeater.main:main" +pymc-cli = "repeater.local_cli:main" -[tool.setuptools] -packages = ["repeater"] +[tool.setuptools.packages.find] +where = ["."] +include = ["repeater*"] + +[tool.setuptools.package-data] +repeater = [ + "web/html/*.html", + "web/html/*.ico", + "web/html/assets/**/*", + "web/*.yaml", + "web/*.html", +] [tool.black] line-length = 100 @@ -63,3 +83,8 @@ target-version = ['py38', 'py39', 'py310', 'py311', 'py312'] [tool.isort] profile = "black" line_length = 100 + +[tool.setuptools_scm] +version_scheme = "guess-next-dev" +local_scheme = "no-local-version" +version_file = "repeater/_version.py" diff --git a/radio-presets.json b/radio-presets.json index 40b4b7a..d173566 100644 --- a/radio-presets.json +++ b/radio-presets.json @@ -1 +1,159 @@ -{"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: NSW (Wide)", + "description": "915.800MHz / SF11 / BW250 / CR5", + "frequency": "915.800", + "spreading_factor": "11", + "bandwidth": "250", + "coding_rate": "5" + }, + { + "title": "Australia (Narrow)", + "description": "916.575MHz / SF7 / BW62.5 / CR8", + "frequency": "916.575", + "spreading_factor": "7", + "bandwidth": "62.5", + "coding_rate": "8" + }, + { + "title": "Australia: SA, WA, QLD", + "description": "923.125MHz / SF8 / BW62.5 / CR8", + "frequency": "923.125", + "spreading_factor": "8", + "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 diff --git a/radio-settings-buildroot.json b/radio-settings-buildroot.json new file mode 100644 index 0000000..16e1494 --- /dev/null +++ b/radio-settings-buildroot.json @@ -0,0 +1,76 @@ +{ + "default_board": "luckfox-pimesh-v2", + "default_radio_preset": "USA/Canada (Recommended)", + "buildroot_hardware": { + "luckfox-pimesh-v2": { + "name": "Luckfox PiMesh V2", + "description": "Luckfox Pico Pi with PiMesh-1W V2 / E22P wiring", + "hardware_id": "pimesh-1w-v2", + "tx_power": 22, + "aliases": [ + "1", + "v2", + "pimesh-v2", + "pimesh-1w-v2" + ], + "sx1262_overrides": { + "cs_pin": -1, + "reset_pin": 54, + "busy_pin": 122, + "irq_pin": 121, + "en_pin": 0, + "txen_pin": -1, + "rxen_pin": -1, + "use_dio2_rf": true, + "use_dio3_tcxo": true, + "dio3_tcxo_voltage": 1.8 + } + }, + "luckfox-pimesh-v1": { + "name": "Luckfox PiMesh V1", + "description": "Luckfox Pico Pi with PiMesh-1W V1 wiring", + "hardware_id": "pimesh-1w-v1", + "tx_power": 22, + "aliases": [ + "2", + "v1", + "pimesh-v1", + "pimesh-1w-v1" + ], + "sx1262_overrides": { + "cs_pin": 145, + "reset_pin": 54, + "busy_pin": 123, + "irq_pin": 55, + "en_pin": -1, + "txen_pin": 52, + "rxen_pin": 53, + "use_dio2_rf": false, + "use_dio3_tcxo": true, + "dio3_tcxo_voltage": 1.8 + } + }, + "luckfox-meshadv": { + "name": "Luckfox MeshAdv", + "description": "Luckfox Pico Pi with MeshAdv wiring", + "hardware_id": "meshadv", + "tx_power": 22, + "aliases": [ + "3", + "meshadv" + ], + "sx1262_overrides": { + "cs_pin": 145, + "reset_pin": 54, + "busy_pin": 123, + "irq_pin": 55, + "en_pin": -1, + "txen_pin": 52, + "rxen_pin": 53, + "use_dio2_rf": false, + "use_dio3_tcxo": true, + "dio3_tcxo_voltage": 1.8 + } + } + } +} diff --git a/radio-settings.json b/radio-settings.json index 93fc786..e3f74ad 100644 --- a/radio-settings.json +++ b/radio-settings.json @@ -16,8 +16,8 @@ "preamble_length": 17, "is_waveshare": true }, - "uconsole": { - "name": "uConsole LoRa Module", + "uconsole_aiov1": { + "name": "uConsole LoRa Module aio v1", "bus_id": 1, "cs_id": 0, "cs_pin": -1, @@ -31,24 +31,25 @@ "tx_power": 22, "preamble_length": 17 }, - "pimesh-1w-usa": { - "name": "PiMesh-1W (USA)", - "bus_id": 0, + "uconsole_aio_v2": { + "name": "uConsole LoRa Module aio v2", + "bus_id": 1, "cs_id": 0, - "cs_pin": 21, - "reset_pin": 18, - "busy_pin": 20, - "irq_pin": 16, - "txen_pin": 13, - "rxen_pin": 12, + "cs_pin": -1, + "reset_pin": 25, + "busy_pin": 24, + "irq_pin": 26, + "txen_pin": -1, + "rxen_pin": -1, "txled_pin": -1, "rxled_pin": -1, - "tx_power": 30, + "tx_power": 22, + "preamble_length": 17, "use_dio3_tcxo": true, - "preamble_length": 17 + "use_dio2_rf": true }, - "pimesh-1w-uk": { - "name": "PiMesh-1W (UK)", + "pimesh-1w-v1": { + "name": "PiMesh-1W (V1)", "bus_id": 0, "cs_id": 0, "cs_pin": 21, @@ -63,6 +64,24 @@ "use_dio3_tcxo": true, "preamble_length": 17 }, + "pimesh-1w-v2": { + "name": "PiMesh-1W (V2)", + "bus_id": 0, + "cs_id": 0, + "cs_pin": -1, + "reset_pin": 18, + "busy_pin": 5, + "irq_pin": 6, + "txen_pin": -1, + "rxen_pin": -1, + "txled_pin": -1, + "rxled_pin": -1, + "en_pin": 26, + "tx_power": 22, + "use_dio3_tcxo": true, + "use_dio2_rf": true, + "preamble_length": 17 + }, "meshadv-mini": { "name": "MeshAdv Mini", "bus_id": 0, @@ -78,7 +97,7 @@ "tx_power": 22, "preamble_length": 17 }, - "meshadv": { + "meshadv": { "name": "MeshAdv", "bus_id": 0, "cs_id": 0, @@ -93,6 +112,138 @@ "tx_power": 22, "use_dio3_tcxo": true, "preamble_length": 17 + }, + "zebra": { + "name": "ZebraHat-1W", + "bus_id": 0, + "cs_id": 0, + "cs_pin": 24, + "reset_pin": 17, + "busy_pin": 27, + "irq_pin": 22, + "txen_pin": -1, + "rxen_pin": -1, + "txled_pin": -1, + "rxled_pin": -1, + "tx_power": 18, + "use_dio3_tcxo": true, + "use_dio2_rf": true, + "preamble_length": 17 + }, + "femtofox-1W-SX": { + "name": "FemtoFox SX1262 (1W)", + "bus_id": 0, + "cs_id": 0, + "cs_pin": 16, + "gpio_chip": 1, + "use_gpiod_backend": true, + "reset_pin": 25, + "busy_pin": 22, + "irq_pin": 23, + "txen_pin": -1, + "rxen_pin": 24, + "txled_pin": -1, + "rxled_pin": -1, + "tx_power": 30, + "use_dio3_tcxo": true, + "preamble_length": 17 + }, + "femtofox-2W-SX": { + "name": "FemtoFox SX1262 (2W)", + "bus_id": 0, + "cs_id": 0, + "cs_pin": 16, + "gpio_chip": 1, + "use_gpiod_backend": true, + "reset_pin": 25, + "busy_pin": 22, + "irq_pin": 23, + "txen_pin": -1, + "rxen_pin": 24, + "txled_pin": -1, + "rxled_pin": -1, + "tx_power": 8, + "use_dio2_rf": true, + "use_dio3_tcxo": true + }, + "nebrahat": { + "name": "NebraHat-2W", + "bus_id": 0, + "cs_id": 0, + "cs_pin": 8, + "reset_pin": 18, + "busy_pin": 4, + "irq_pin": 22, + "txen_pin": -1, + "rxen_pin": 25, + "txled_pin": -1, + "rxled_pin": -1, + "tx_power": 8, + "use_dio3_tcxo": true, + "use_dio2_rf": true, + "preamble_length": 17 + }, + "ch341-usb-sx1262": { + "name": "CH341 USB-SPI + SX1262 (example)", + "description": "SX1262 via CH341 USB-to-SPI adapter. NOTE: pin numbers are CH341 GPIO 0-7, not BCM.", + "radio_type": "sx1262_ch341", + "vid": 6790, + "pid": 21778, + "bus_id": 0, + "cs_id": 0, + "cs_pin": 0, + "reset_pin": 2, + "busy_pin": 4, + "irq_pin": 6, + "txen_pin": -1, + "rxen_pin": 1, + "txled_pin": -1, + "rxled_pin": -1, + "tx_power": 22, + "use_dio2_rf": true, + "use_dio3_tcxo": true, + "dio3_tcxo_voltage": 1.8, + "preamble_length": 17, + "is_waveshare": false + }, + "ultrapeater-e22": { + "name": "Zindello Industries UltraPeater E22", + "bus_id": 0, + "cs_id": 0, + "cs_pin": 16, + "reset_pin": 22, + "busy_pin": 11, + "irq_pin": 10, + "txen_pin": 20, + "rxen_pin": 21, + "txled_pin": 8, + "rxled_pin": 1, + "tx_power": 22, + "use_dio2_rf": false, + "use_dio3_tcxo": true, + "preamble_length": 17, + "use_gpiod_backend": true, + "gpio_chip": 1 + }, + "ultrapeater-e22p": { + "name": "Zindello Industries UltraPeater E22P", + "bus_id": 0, + "cs_id": 0, + "cs_pin": 16, + "reset_pin": 22, + "busy_pin": 11, + "irq_pin": 10, + "txen_pin": 20, + "rxen_pin": -1, + "en_pin": 21, + "txled_pin": 8, + "rxled_pin": 1, + "tx_power": 22, + "use_dio2_rf": false, + "use_dio3_tcxo": true, + "preamble_length": 17, + "use_gpiod_backend": true, + "gpio_chip": 1 } } } diff --git a/repeater/__init__.py b/repeater/__init__.py index 68cdeee..19b87fa 100644 --- a/repeater/__init__.py +++ b/repeater/__init__.py @@ -1 +1,9 @@ -__version__ = "1.0.5" +try: + from ._version import version as __version__ +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 f456613..f67e80d 100644 --- a/repeater/airtime.py +++ b/repeater/airtime.py @@ -1,4 +1,5 @@ import logging +import math import time from typing import Tuple @@ -8,30 +9,77 @@ logger = logging.getLogger("AirtimeManager") class AirtimeManager: def __init__(self, config: dict): self.config = config + self.radio_config = config.get("radio", {}) self.max_airtime_per_minute = config.get("duty_cycle", {}).get( "max_airtime_per_minute", 3600 ) + # Store radio settings for airtime calculations + self.spreading_factor = self.radio_config.get("spreading_factor", 7) + self.bandwidth = self.radio_config.get("bandwidth", 125000) + self.coding_rate = self.radio_config.get("coding_rate", 5) + self.preamble_length = self.radio_config.get("preamble_length", 8) + # Track airtime in rolling window self.tx_history = [] # [(timestamp, airtime_ms), ...] self.window_size = 60 # seconds self.total_airtime_ms = 0 + self.total_rx_airtime_ms = 0 def calculate_airtime( self, payload_len: int, - spreading_factor: int = 7, - bandwidth_hz: int = 125000, + spreading_factor: int = None, + bandwidth_hz: int = None, + coding_rate: int = None, + preamble_len: int = None, + crc_enabled: bool = True, + explicit_header: bool = True, ) -> float: + """ + Calculate LoRa packet airtime using the Semtech reference formula. - bw_khz = bandwidth_hz / 1000 - symbol_time = (2**spreading_factor) / bw_khz - preamble_time = 8 * symbol_time - payload_symbols = (payload_len + 4.25) * 8 - payload_time = payload_symbols * symbol_time + Reference: https://www.semtech.com/design-support/lora-calculator - total_ms = preamble_time + payload_time - return total_ms + Args: + payload_len: Payload length in bytes + spreading_factor: SF7-SF12 (uses config value if None) + bandwidth_hz: Bandwidth in Hz (uses config value if None) + coding_rate: CR denominator, 5=4/5, 6=4/6, 7=4/7, 8=4/8 (uses config value if None) + 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 + """ + sf = spreading_factor or self.spreading_factor + bw_hz = (bandwidth_hz or self.bandwidth) + cr = coding_rate or self.coding_rate + 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 bw_hz <= 125000) else 0 + + # Symbol time in milliseconds: T_sym = 2^SF / BW_kHz + t_sym = (2 ** sf) / (bw_hz / 1000) + + # 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 def can_transmit(self, airtime_ms: float) -> Tuple[bool, float]: enforcement_enabled = self.config.get("duty_cycle", {}).get("enforcement_enabled", True) @@ -63,6 +111,10 @@ class AirtimeManager: self.total_airtime_ms += airtime_ms logger.debug(f"TX recorded: {airtime_ms: .1f}ms (total: {self.total_airtime_ms: .0f}ms)") + def record_rx(self, airtime_ms: float): + """Record received packet airtime (for total RX airtime stats).""" + self.total_rx_airtime_ms += airtime_ms + def get_stats(self) -> dict: now = time.time() self.tx_history = [(ts, at) for ts, at in self.tx_history if now - ts < self.window_size] @@ -75,4 +127,5 @@ class AirtimeManager: "max_airtime_ms": self.max_airtime_per_minute, "utilization_percent": utilization, "total_airtime_ms": self.total_airtime_ms, + "total_rx_airtime_ms": self.total_rx_airtime_ms, } diff --git a/repeater/companion/__init__.py b/repeater/companion/__init__.py new file mode 100644 index 0000000..f104252 --- /dev/null +++ b/repeater/companion/__init__.py @@ -0,0 +1,30 @@ +"""Companion identity support for pyMC Repeater. + +Exposes the MeshCore companion frame protocol over TCP for standard clients. +""" + +from .bridge import RepeaterCompanionBridge +from .constants import ( + CMD_APP_START, + CMD_GET_CONTACTS, + CMD_SEND_LOGIN, + CMD_SEND_TXT_MSG, + CMD_SYNC_NEXT_MESSAGE, + PUSH_CODE_MSG_WAITING, + RESP_CODE_ERR, + RESP_CODE_OK, +) +from .frame_server import CompanionFrameServer + +__all__ = [ + "CompanionFrameServer", + "RepeaterCompanionBridge", + "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/bridge.py b/repeater/companion/bridge.py new file mode 100644 index 0000000..dc0787e --- /dev/null +++ b/repeater/companion/bridge.py @@ -0,0 +1,122 @@ +""" +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 enum import Enum +from typing import Any, Callable, Optional + +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.""" + + 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 = "", + on_prefs_saved: Optional[Callable[[str], None]] = None, + ) -> None: + self._sqlite_handler = sqlite_handler + self._companion_hash = companion_hash + self._on_prefs_saved = on_prefs_saved + 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, + ) + # Load persisted prefs (e.g. node_name) from SQLite so matching uses last-saved name + self._load_prefs() + + 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) + prefs_safe = _to_json_safe(prefs_dict) + self._sqlite_handler.companion_save_prefs( + str(self._companion_hash), prefs_safe + ) + if self._on_prefs_saved: + try: + self._on_prefs_saved(self.prefs.node_name) + except Exception as e: + logger.warning("Failed to sync node_name to config: %s", e) + 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/companion/constants.py b/repeater/companion/constants.py new file mode 100644 index 0000000..2fa3b16 --- /dev/null +++ b/repeater/companion/constants.py @@ -0,0 +1,150 @@ +"""Companion frame protocol constants — re-exported from pyMC_core. + +All protocol constants now live in :mod:`pymc_core.companion.constants`. +This module re-exports them so existing repeater imports continue to work. +""" + +# 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 new file mode 100644 index 0000000..90aa22b --- /dev/null +++ b/repeater/companion/frame_server.py @@ -0,0 +1,178 @@ +""" +Repeater-specific CompanionFrameServer with SQLite persistence. + +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 logging +from typing import Optional + +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 + +logger = logging.getLogger("CompanionFrameServer") + + +class CompanionFrameServer(_BaseFrameServer): + """Adds SQLite persistence for messages, contacts, and channels. + + Constructor signature is intentionally kept compatible with the + previous monolithic implementation so ``main.py`` call-sites need + zero changes. + """ + + def __init__( + self, + bridge, + companion_hash: str, + port: int = 5000, + bind_address: str = "0.0.0.0", + client_idle_timeout_sec: Optional[int] = 8 * 60 * 60, # 8 hours + sqlite_handler=None, + local_hash: Optional[int] = None, + stats_getter=None, + control_handler=None, + ): + super().__init__( + bridge=bridge, + companion_hash=companion_hash, + port=port, + bind_address=bind_address, + client_idle_timeout_sec=client_idle_timeout_sec, + device_model="pyMC-Repeater-Companion", + device_version=None, # use FIRMWARE_VER_CODE from pyMC_core + build_date="13 Feb 2026", + local_hash=local_hash, + stats_getter=stats_getter, + control_handler=control_handler, + ) + self.sqlite_handler = sqlite_handler + + # ----------------------------------------------------------------- + # Persistence hook overrides + # ----------------------------------------------------------------- + + async def _persist_companion_message(self, msg_dict: dict) -> None: + """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.bridge.message_queue.pop_last() + + def _sync_next_from_persistence(self) -> 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), + ) + + # ----------------------------------------------------------------- + # 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 + self._write_frame(self._build_message_frame(msg)) + + @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 = [self._contact_to_dict(c) for c in contacts] + await asyncio.to_thread( + self.sqlite_handler.companion_save_contacts, + self.companion_hash, + dicts, + ) + + async def _save_channels(self) -> None: + """Persist channels to SQLite (non-blocking).""" + 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, + } + ) + await asyncio.to_thread( + self.sqlite_handler.companion_save_channels, + self.companion_hash, + channels, + ) + + async def stop(self) -> None: + """Persist contacts and channels before stopping (so they survive daemon restart).""" + if self.sqlite_handler: + try: + await self._save_contacts() + await self._save_channels() + except Exception as e: + logger.warning("Failed to persist contacts/channels on stop: %s", e) + await super().stop() diff --git a/repeater/companion/identity_resolve.py b/repeater/companion/identity_resolve.py new file mode 100644 index 0000000..a5b78ef --- /dev/null +++ b/repeater/companion/identity_resolve.py @@ -0,0 +1,190 @@ +"""Resolve companion config rows by registration name, identity key, or public key prefix.""" + +from __future__ import annotations + +import logging +from typing import Any, List, Optional, Set, Tuple + +from repeater.companion.utils import normalize_companion_identity_key + +logger = logging.getLogger(__name__) + +# Minimum hex chars for identity_key / public_key prefix disambiguation (4 bytes) +_MIN_PREFIX_HEX_LEN = 8 + + +def _companion_registration_name(entry: dict) -> str: + n = entry.get("name") + if n is None: + return "" + return str(n).strip() + + +def identity_key_bytes_from_config(identity_key: Any) -> Optional[bytes]: + """Parse companion identity_key from YAML (str hex or raw bytes).""" + if identity_key is None: + return None + if isinstance(identity_key, (bytes, bytearray, memoryview)): + raw = bytes(identity_key) + return raw if len(raw) in (32, 64) else None + if isinstance(identity_key, str): + try: + raw = bytes.fromhex(normalize_companion_identity_key(identity_key)) + except ValueError: + return None + return raw if len(raw) in (32, 64) else None + return None + + +def identity_key_hex_normalized(identity_key: Any) -> Optional[str]: + """Lowercase hex string of the raw key bytes (64 or 128 chars), or None.""" + raw = identity_key_bytes_from_config(identity_key) + if raw is None: + return None + return raw.hex().lower() + + +def derive_companion_public_key_hex(identity_key: Any) -> Optional[str]: + """Return ed25519 public key hex for a companion seed, or None if invalid.""" + raw = identity_key_bytes_from_config(identity_key) + if raw is None: + return None + try: + from pymc_core import LocalIdentity + + identity = LocalIdentity(seed=raw) + return identity.get_public_key().hex() + except Exception as e: + logger.debug("derive_companion_public_key_hex failed: %s", e) + return None + + +def suggest_companion_name_from_pubkey(pubkey_hex: str, prefix_len: int = 8) -> str: + """Stable default registration name: companion_.""" + p = pubkey_hex.strip().lower() + if p.startswith("0x"): + p = p[2:] + if len(p) < prefix_len: + prefix = p + else: + prefix = p[:prefix_len] + return f"companion_{prefix}" + + +def unique_suggested_name( + pubkey_hex: str, + existing_names: set, + prefix_len: int = 8, +) -> str: + """Like suggest_companion_name_from_pubkey but appends -2, -3, ... if name collides.""" + base = suggest_companion_name_from_pubkey(pubkey_hex, prefix_len=prefix_len) + if base not in existing_names: + return base + n = 2 + while f"{base}-{n}" in existing_names: + n += 1 + return f"{base}-{n}" + + +def find_companion_index( + companions: List[dict], + *, + name: Optional[str] = None, + identity_key: Optional[str] = None, + public_key_prefix: Optional[str] = None, +) -> Tuple[Optional[int], Optional[str]]: + """ + Find a single companion list index. + + Lookup priority when multiple fields are set: + 1) name (non-empty after strip) + 2) identity_key (full hex or unique prefix) + 3) public_key_prefix (unique prefix of derived public key hex) + + Returns (index, None) on success, or (None, error_message) on failure. + """ + name_s = str(name).strip() if name is not None else "" + idk = str(identity_key).strip() if identity_key is not None else "" + pkp = str(public_key_prefix).strip() if public_key_prefix is not None else "" + if pkp.lower().startswith("0x"): + pkp = pkp[2:].strip() + pkp = pkp.lower() + + if idk: + idk = normalize_companion_identity_key(idk).lower() + + if name_s: + matches = [i for i, c in enumerate(companions) if _companion_registration_name(c) == name_s] + if len(matches) == 1: + return matches[0], None + if len(matches) == 0: + return None, f"Companion '{name_s}' not found" + return None, f"Multiple companions named '{name_s}'" + + if idk: + if len(idk) < _MIN_PREFIX_HEX_LEN: + return None, ( + f"identity_key lookup must be at least {_MIN_PREFIX_HEX_LEN} hex characters" + ) + exact: List[int] = [] + prefix_matches: List[int] = [] + for i, c in enumerate(companions): + h = identity_key_hex_normalized(c.get("identity_key")) + if not h: + continue + if h == idk: + exact.append(i) + elif h.startswith(idk): + prefix_matches.append(i) + if len(exact) == 1: + return exact[0], None + if len(exact) > 1: + return None, "Multiple companions match identity_key (ambiguous)" + if len(prefix_matches) == 1: + return prefix_matches[0], None + if len(prefix_matches) == 0: + return None, "No companion matches identity_key" + return None, "Multiple companions match identity_key prefix (ambiguous)" + + if pkp: + if len(pkp) < _MIN_PREFIX_HEX_LEN: + return None, ( + f"public_key_prefix must be at least {_MIN_PREFIX_HEX_LEN} hex characters" + ) + matches: List[int] = [] + for i, c in enumerate(companions): + pub = derive_companion_public_key_hex(c.get("identity_key")) + if pub and pub.lower().startswith(pkp): + matches.append(i) + if len(matches) == 1: + return matches[0], None + if len(matches) == 0: + return None, "No companion matches public_key_prefix" + return None, "Multiple companions match public_key_prefix (ambiguous)" + + return None, "Missing companion lookup: provide name, identity_key, or public_key_prefix" + + +def heal_companion_empty_names(companions: List[dict]) -> bool: + """ + Assign companion_ names to entries with missing/blank registration names. + Mutates companions in place. Returns True if any entry was updated. + """ + names_in_use: Set[str] = set() + for c in companions: + n = _companion_registration_name(c) + if n: + names_in_use.add(n) + changed = False + for entry in companions: + if _companion_registration_name(entry): + continue + pk = derive_companion_public_key_hex(entry.get("identity_key")) + if not pk: + logger.warning("Skipping companion name heal: invalid or missing identity_key") + continue + new_name = unique_suggested_name(pk, names_in_use) + entry["name"] = new_name + names_in_use.add(new_name) + changed = True + return changed diff --git a/repeater/companion/utils.py b/repeater/companion/utils.py new file mode 100644 index 0000000..6f2d2c0 --- /dev/null +++ b/repeater/companion/utils.py @@ -0,0 +1,25 @@ +"""Shared utilities for Companion (e.g. validation for config sync).""" + +_INVALID_NODE_NAME_CHARS = "\n\r\x00" + + +def normalize_companion_identity_key(identity_key: str) -> str: + """Strip whitespace and remove optional 0x prefix so fromhex() is consistent across installs.""" + s = identity_key.strip() + if s.lower().startswith("0x"): + s = s[2:].strip() + return s + + +def validate_companion_node_name(value: str) -> str: + """Validate node_name for config sync: non-empty, max 31 bytes UTF-8, no control chars.""" + if not isinstance(value, str): + raise ValueError("node_name must be a string") + s = value.strip() + if not s: + raise ValueError("node_name cannot be empty") + if len(s.encode("utf-8")) > 31: + raise ValueError("node_name too long (max 31 bytes UTF-8)") + if any(c in s for c in _INVALID_NODE_NAME_CHARS): + raise ValueError("node_name contains invalid characters") + return s diff --git a/repeater/config.py b/repeater/config.py index 44de0c5..3ce3670 100644 --- a/repeater/config.py +++ b/repeater/config.py @@ -11,13 +11,13 @@ logger = logging.getLogger("Config") def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]: """ - Extract node name, radio configuration, and LetsMesh settings from config. + Extract node name, radio configuration, and MQTT settings from config. Args: config: Configuration dictionary Returns: - Dictionary with node_name, radio_config, and LetsMesh configuration + Dictionary with node_name, radio_config, and MQTT configuration """ node_name = config.get("repeater", {}).get("node_name", "PyMC-Repeater") radio_config = config.get("radio", {}) @@ -30,26 +30,17 @@ def get_node_info(config: Dict[str, Any]) -> Dict[str, Any]: radio_bw_khz = radio_bw / 1_000 radio_config_str = f"{radio_freq_mhz},{radio_bw_khz},{radio_sf},{radio_cr}" - letsmesh_config = config.get("letsmesh", {}) - - from pymc_core.protocol.utils import PAYLOAD_TYPES - - disallowed_types = letsmesh_config.get("disallowed_packet_types", []) - type_name_map = {name: code for code, name in PAYLOAD_TYPES.items()} - - disallowed_hex = [type_name_map.get(name.upper(), None) for name in disallowed_types] - disallowed_hex = [val for val in disallowed_hex if val is not None] # Filter out invalid names + # Handle getting the config from mqtt brokers, falling back to letsmesh if it doesn't exist + mqtt_config = config.get("mqtt_brokers", config.get("letsmesh", {})) return { "node_name": node_name, "radio_config": radio_config_str, - "iata_code": letsmesh_config.get("iata_code", "TEST"), - "broker_index": letsmesh_config.get("broker_index", 0), - "status_interval": letsmesh_config.get("status_interval", 60), - "model": letsmesh_config.get("model", "PyMC-Repeater"), - "disallowed_packet_types": disallowed_hex, - "email": letsmesh_config.get("email", ""), - "owner": letsmesh_config.get("owner", "") + "iata_code": mqtt_config.get("iata_code", "TEST"), + "status_interval": mqtt_config.get("status_interval", 60), + "model": mqtt_config.get("model", "PyMC-Repeater"), + "email": mqtt_config.get("email", ""), + "owner": mqtt_config.get("owner", ""), } @@ -77,9 +68,42 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]: if "mesh" not in config: config["mesh"] = {} - # Only auto-generate identity_key if not provided - if "identity_key" not in config["mesh"]: - config["mesh"]["identity_key"] = _load_or_create_identity_key() + if "glass" not in config: + config["glass"] = { + "enabled": False, + "base_url": "http://localhost:8080", + "inform_interval_seconds": 30, + "request_timeout_seconds": 10, + "verify_tls": True, + "api_token": "", + "cert_store_dir": "/etc/pymc_repeater/glass", + } + + # Ensure repeater.security exists with defaults for upgrades from older configs + if "repeater" not in config: + config["repeater"] = {} + if "security" not in config["repeater"]: + logger.warning( + "No 'security' section found under 'repeater' in config. " + "Adding defaults — please review and update passwords." + ) + config["repeater"]["security"] = { + "max_clients": 1, + "admin_password": "admin123", + "guest_password": "guest123", + "allow_read_only": False, + "jwt_secret": "", + "jwt_expiry_minutes": 60, + } + + # Only auto-generate identity_key if not provided under repeater section + if "identity_key" not in config["repeater"]: + # Check if identity_file is specified + identity_file = config["repeater"].get("identity_file") + if identity_file: + config["repeater"]["identity_key"] = _load_or_create_identity_key(path=identity_file) + else: + config["repeater"]["identity_key"] = _load_or_create_identity_key() if os.getenv("PYMC_REPEATER_LOG_LEVEL"): if "logging" not in config: @@ -107,14 +131,21 @@ 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: - yaml.safe_dump(config_data, f, default_flow_style=False, sort_keys=False) - + + # Save new config (allow_unicode=True so emojis etc. are not escaped as \U0001F47E) + with open(config_path, "w", encoding="utf-8") as f: + yaml.safe_dump( + config_data, + f, + default_flow_style=False, + sort_keys=False, + allow_unicode=True, + width=1000000, + ) + logger.info(f"Saved configuration to {config_path}") return True @@ -123,12 +154,12 @@ def save_config(config_data: Dict[str, Any], config_path: Optional[str] = None) return False -def update_global_flood_policy(allow: bool, config_path: Optional[str] = None) -> bool: +def update_unscoped_flood_policy(allow: bool, config_path: Optional[str] = None) -> bool: """ - Update the global flood policy in the configuration. + Update the unscoped flood policy in the configuration. Args: - allow: True to allow flooding globally, False to deny + allow: True to allow unscoped flooding, False to deny config_path: Path to config file (uses default if None) Returns: @@ -144,25 +175,31 @@ def update_global_flood_policy(allow: bool, config_path: Optional[str] = None) - # Set global flood policy config["mesh"]["global_flood_allow"] = allow + config["mesh"]["unscoped_flood_allow"] = allow # Save updated config return save_config(config, config_path) except Exception as e: - logger.error(f"Failed to update global flood policy: {e}") + logger.error(f"Failed to update unscoped flood policy: {e}") return False def _load_or_create_identity_key(path: Optional[str] = None) -> bytes: if path is None: - # Follow XDG spec - xdg_config_home = os.environ.get("XDG_CONFIG_HOME") - if xdg_config_home: - config_dir = Path(xdg_config_home) / "pymc_repeater" + # Check system-wide location first (matches config.yaml location) + system_key_path = Path("/etc/pymc_repeater/identity.key") + if system_key_path.exists(): + key_path = system_key_path else: - config_dir = Path.home() / ".config" / "pymc_repeater" - key_path = config_dir / "identity.key" + # Follow XDG spec + xdg_config_home = os.environ.get("XDG_CONFIG_HOME") + if xdg_config_home: + config_dir = Path(xdg_config_home) / "pymc_repeater" + else: + config_dir = Path.home() / ".config" / "pymc_repeater" + key_path = config_dir / "identity.key" else: key_path = Path(path) @@ -173,8 +210,8 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes: with open(key_path, "rb") as f: encoded = f.read() key = base64.b64decode(encoded) - if len(key) != 32: - raise ValueError(f"Invalid key length: {len(key)}, expected 32") + if len(key) not in (32, 64): + raise ValueError(f"Invalid key length: {len(key)}, expected 32 or 64") logger.info(f"Loaded existing identity key from {key_path}") return key except Exception as e: @@ -197,9 +234,20 @@ 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() + def _parse_int(value, *, default=None) -> int: + if value is None: + return default + if isinstance(value, int): + return value + if isinstance(value, str): + return int(value.strip().rstrip(','), 0) + raise ValueError(f"Invalid int value type: {type(value)}") - if radio_type == "sx1262": + radio_type = board_config.get("radio_type", "sx1262").lower().strip() + if radio_type == "kiss-modem": + radio_type = "kiss" + + if radio_type in ("sx1262", "sx1262_ch341"): from pymc_core.hardware.sx1262_wrapper import SX1262Radio # Get radio and SPI configuration - all settings must be in config file @@ -211,19 +259,37 @@ def get_radio_for_board(board_config: dict): if not radio_config: raise ValueError("Missing 'radio' section in configuration file") - # Build config with required fields - no defaults + # CH341 integration: swap SPI transport + GPIO backend to CH341 + if radio_type == "sx1262_ch341": + ch341_cfg = board_config.get("ch341") + if not ch341_cfg: + raise ValueError("Missing 'ch341' section in configuration file") + + from pymc_core.hardware.lora.LoRaRF.SX126x import set_spi_transport + from pymc_core.hardware.transports.ch341_spi_transport import CH341SPITransport + + vid = _parse_int(ch341_cfg.get("vid"), default=0x1A86) + pid = _parse_int(ch341_cfg.get("pid"), default=0x5512) + + # Create CH341 transport (also configures CH341 GPIO manager globally) + ch341_spi = CH341SPITransport(vid=vid, pid=pid, auto_setup_gpio=True) + set_spi_transport(ch341_spi) + combined_config = { - "bus_id": spi_config["bus_id"], - "cs_id": spi_config["cs_id"], - "cs_pin": spi_config["cs_pin"], - "reset_pin": spi_config["reset_pin"], - "busy_pin": spi_config["busy_pin"], - "irq_pin": spi_config["irq_pin"], - "txen_pin": spi_config["txen_pin"], - "rxen_pin": spi_config["rxen_pin"], - "txled_pin": spi_config.get("txled_pin", -1), - "rxled_pin": spi_config.get("rxled_pin", -1), + "bus_id": _parse_int(spi_config["bus_id"]), + "cs_id": _parse_int(spi_config["cs_id"]), + "cs_pin": _parse_int(spi_config["cs_pin"]), + "reset_pin": _parse_int(spi_config["reset_pin"]), + "busy_pin": _parse_int(spi_config["busy_pin"]), + "irq_pin": _parse_int(spi_config["irq_pin"]), + "txen_pin": _parse_int(spi_config["txen_pin"]), + "rxen_pin": _parse_int(spi_config["rxen_pin"]), + "txled_pin": _parse_int(spi_config.get("txled_pin", -1), default=-1), + "rxled_pin": _parse_int(spi_config.get("rxled_pin", -1), default=-1), + "en_pin": _parse_int(spi_config.get("en_pin", -1), default=-1), "use_dio3_tcxo": spi_config.get("use_dio3_tcxo", False), + "dio3_tcxo_voltage": float(spi_config.get("dio3_tcxo_voltage", 1.8)), + "use_dio2_rf": spi_config.get("use_dio2_rf", False), "is_waveshare": spi_config.get("is_waveshare", False), "frequency": int(radio_config["frequency"]), "tx_power": radio_config["tx_power"], @@ -234,6 +300,13 @@ def get_radio_for_board(board_config: dict): "sync_word": radio_config["sync_word"], } + # Add optional GPIO parameters if specified in config + # These wont be supported by older versions of pymc_core + if "gpio_chip" in spi_config: + combined_config["gpio_chip"] = _parse_int(spi_config["gpio_chip"], default=0) + if "use_gpiod_backend" in spi_config: + combined_config["use_gpiod_backend"] = spi_config["use_gpiod_backend"] + radio = SX1262Radio.get_instance(**combined_config) if hasattr(radio, "_initialized") and not radio._initialized: @@ -244,5 +317,52 @@ def get_radio_for_board(board_config: dict): return radio - else: - raise RuntimeError(f"Unknown radio type: {radio_type}. Supported: sx1262") + 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 + + raise RuntimeError( + f"Unknown radio type: {radio_type}. Supported: sx1262, sx1262_ch341, kiss (or kiss-modem)" + ) diff --git a/repeater/config_manager.py b/repeater/config_manager.py new file mode 100644 index 0000000..66af0e1 --- /dev/null +++ b/repeater/config_manager.py @@ -0,0 +1,240 @@ +import logging +import os +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 + daemon_instance: Optional reference to the daemon for live updates + """ + self.config_path = config_path + self.config = config + self.daemon = daemon_instance + + def save_to_file(self) -> bool: + """ + Save current config to YAML file. + + Returns: + True if successful, False otherwise + """ + try: + 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, + 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 + except Exception as 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'): + 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', 'glass'] + + # 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'): + self.daemon.repeater_handler.reload_runtime_config() + logger.info("Reloaded RepeaterHandler runtime config") + + # Also reload advert_helper config if repeater section changed + if self.daemon and hasattr(self.daemon, 'advert_helper') and self.daemon.advert_helper: + if 'repeater' in sections: + if hasattr(self.daemon.advert_helper, 'reload_config'): + self.daemon.advert_helper.reload_config() + logger.info("Reloaded AdvertHelper config") + + # Re-apply dispatcher path hash mode when mesh section changed + if 'mesh' in sections and self.daemon and hasattr(self.daemon, 'dispatcher'): + mesh_cfg = self.daemon.config.get("mesh", {}) + path_hash_mode = mesh_cfg.get("path_hash_mode", 0) + if path_hash_mode not in (0, 1, 2): + logger.warning( + f"Invalid mesh.path_hash_mode={path_hash_mode}, must be 0/1/2; using 0" + ) + path_hash_mode = 0 + self.daemon.dispatcher.set_default_path_hash_mode(path_hash_mode) + logger.info(f"Reloaded path hash mode: mesh.path_hash_mode={path_hash_mode}") + + 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]: + """ + 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 + - saved: bool - Whether config was saved to file + - live_updated: bool - Whether daemon was live updated + - error: str (optional) - Error message if failed + """ + 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 + result["saved"] = self.save_to_file() + + if not result["saved"]: + 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('.') + + if len(parts) == 1: + # Top-level key + updates = {parts[0]: value} + elif len(parts) == 2: + # Nested one level (most common case) + updates = {parts[0]: {parts[1]: value}} + else: + # Build nested dict for deeper paths + updates = {} + current = updates + for i, part in enumerate(parts[:-1]): + if i == 0: + current[part] = {} + current = current[part] + else: + 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 + ) + + 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 [] + } diff --git a/repeater/data_acquisition/__init__.py b/repeater/data_acquisition/__init__.py index 5df598e..c013421 100644 --- a/repeater/data_acquisition/__init__.py +++ b/repeater/data_acquisition/__init__.py @@ -1,6 +1,5 @@ +from .glass_handler import GlassHandler +from .rrdtool_handler import RRDToolHandler from .sqlite_handler import SQLiteHandler -from .rrdtool_handler import RRDToolHandler -from .mqtt_handler import MQTTHandler from .storage_collector import StorageCollector - -__all__ = ['SQLiteHandler', 'RRDToolHandler', 'MQTTHandler', 'StorageCollector'] \ No newline at end of file +__all__ = ["SQLiteHandler", "RRDToolHandler", "StorageCollector", "GlassHandler"] diff --git a/repeater/data_acquisition/glass_handler.py b/repeater/data_acquisition/glass_handler.py new file mode 100644 index 0000000..61b6269 --- /dev/null +++ b/repeater/data_acquisition/glass_handler.py @@ -0,0 +1,957 @@ +import asyncio +import hashlib +import json +import logging +import os +import ssl +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple +from urllib.parse import urlparse +from urllib import error, request + +import psutil +try: + import paho.mqtt.client as mqtt +except ImportError: + mqtt = None + +from repeater import __version__ +from repeater.service_utils import restart_service + +logger = logging.getLogger("GlassHandler") +_SENSITIVE_KEY_MARKERS = ( + "password", + "passphrase", + "secret", + "token", + "private_key", + "identity_key", + "client_key", + "api_key", +) +_SENSITIVE_KEY_EXCEPTIONS = ("pubkey", "public_key") + + +class GlassHandler: + def __init__(self, config: dict, daemon_instance=None, config_manager=None): + self.config = config + self.daemon_instance = daemon_instance + self.config_manager = config_manager + + self.enabled = False + self.base_url = "http://localhost:8080" + self.request_timeout_seconds = 10 + self.verify_tls = True + self.api_token = "" + self.inform_interval_seconds = 30 + self.cert_store_dir = "/etc/pymc_repeater/glass" + self._cert_expires_at: Optional[str] = None + self.mqtt_enabled = False + self.mqtt_broker_host = "localhost" + self.mqtt_broker_port = 1883 + self.mqtt_base_topic = "glass" + self.mqtt_tls_enabled = False + self.mqtt_username: Optional[str] = None + self.mqtt_password: Optional[str] = None + self.client_cert_path: Optional[str] = None + self.client_key_path: Optional[str] = None + self.ca_cert_path: Optional[str] = None + self._mqtt_client = None + self._mqtt_ready = False + self._mqtt_runtime_signature: Optional[ + Tuple[ + str, + int, + str, + bool, + bool, + Optional[str], + Optional[str], + Optional[str], + Optional[str], + Optional[str], + ] + ] = None + self._managed_settings_filename = "managed.json" + + self._task: Optional[asyncio.Task] = None + self._stop_event: Optional[asyncio.Event] = None + self._pending_command_results: List[Dict[str, Any]] = [] + self._pending_lock = asyncio.Lock() + + self._reload_runtime_settings() + + async def start(self) -> None: + self._reload_runtime_settings() + if not self.enabled: + logger.info("Glass integration disabled") + self._close_mqtt_publisher() + return + + if self._task and not self._task.done(): + return + self._sync_mqtt_publisher() + + self._stop_event = asyncio.Event() + self._task = asyncio.create_task(self._run_loop(), name="glass-inform-loop") + logger.info( + "Glass integration started (base_url=%s, inform_interval=%ss)", + self.base_url, + self.inform_interval_seconds, + ) + + async def stop(self) -> None: + if self._task: + if self._stop_event: + self._stop_event.set() + + try: + await self._task + except Exception as exc: + logger.debug("Glass task stop ignored exception: %s", exc) + finally: + self._task = None + self._stop_event = None + + self._close_mqtt_publisher() + + def _reload_runtime_settings(self) -> None: + glass_cfg = self.config.get("glass", {}) + self.enabled = bool(glass_cfg.get("enabled", False)) + + base_url = str(glass_cfg.get("base_url", "http://localhost:8080")).strip() + self.base_url = base_url.rstrip("/") if base_url else "http://localhost:8080" + + self.request_timeout_seconds = max(3, int(glass_cfg.get("request_timeout_seconds", 10))) + self.verify_tls = bool(glass_cfg.get("verify_tls", True)) + self.api_token = str(glass_cfg.get("api_token", "") or "").strip() + self.inform_interval_seconds = self._clamp_interval( + int(glass_cfg.get("inform_interval_seconds", self.inform_interval_seconds)) + ) + self.cert_store_dir = str( + glass_cfg.get("cert_store_dir", "/etc/pymc_repeater/glass") or "/etc/pymc_repeater/glass" + ) + self.client_cert_path = ( + str(glass_cfg.get("client_cert_path")).strip() + if glass_cfg.get("client_cert_path") + else None + ) + self.client_key_path = ( + str(glass_cfg.get("client_key_path")).strip() + if glass_cfg.get("client_key_path") + else None + ) + self.ca_cert_path = ( + str(glass_cfg.get("ca_cert_path")).strip() + if glass_cfg.get("ca_cert_path") + else None + ) + managed_cfg = self._load_managed_settings() + parsed_base_url = urlparse(self.base_url) + default_host = parsed_base_url.hostname or "localhost" + + self.mqtt_enabled = bool(managed_cfg.get("mqtt_enabled", False)) + host_value = managed_cfg.get("mqtt_broker_host", default_host) + self.mqtt_broker_host = str(host_value or default_host).strip() or default_host + try: + self.mqtt_broker_port = max(1, int(managed_cfg.get("mqtt_broker_port", 1883))) + except (TypeError, ValueError): + self.mqtt_broker_port = 1883 + topic_value = managed_cfg.get("mqtt_base_topic", "glass") + self.mqtt_base_topic = str(topic_value or "glass").strip("/") + self.mqtt_tls_enabled = bool(managed_cfg.get("mqtt_tls_enabled", False)) + username = managed_cfg.get("mqtt_username") + password = managed_cfg.get("mqtt_password") + self.mqtt_username = str(username).strip() if isinstance(username, str) and username else None + self.mqtt_password = str(password) if isinstance(password, str) and password else None + + def _managed_settings_path(self) -> Path: + return Path(self.cert_store_dir) / self._managed_settings_filename + + def _load_managed_settings(self) -> Dict[str, Any]: + path = self._managed_settings_path() + if not path.exists(): + return {} + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + logger.warning("Invalid Glass managed settings file at %s: %s", path, exc) + return {} + if not isinstance(raw, dict): + logger.warning("Ignoring non-object Glass managed settings file at %s", path) + return {} + return raw + + def _save_managed_settings(self, updates: Dict[str, Any], *, replace: bool) -> Tuple[bool, str]: + if not isinstance(updates, dict): + return False, "glass_managed must be an object" + + path = self._managed_settings_path() + path.parent.mkdir(parents=True, exist_ok=True) + current = {} if replace else self._load_managed_settings() + if not isinstance(current, dict): + current = {} + merged = dict(current) + merged.update(updates) + try: + path.write_text( + json.dumps(merged, indent=2, sort_keys=True), + encoding="utf-8", + ) + os.chmod(path, 0o600) + return True, "Managed settings updated" + except Exception as exc: + return False, f"Failed writing managed settings: {exc}" + + async def _run_loop(self) -> None: + while self._stop_event and not self._stop_event.is_set(): + self._reload_runtime_settings() + self._sync_mqtt_publisher() + try: + interval = await self._inform_once() + except Exception as exc: + logger.warning("Glass inform failed: %s", exc) + interval = self.inform_interval_seconds + + wait_seconds = self._clamp_interval(interval) + if not self._stop_event: + break + try: + await asyncio.wait_for(self._stop_event.wait(), timeout=wait_seconds) + except asyncio.TimeoutError: + continue + + async def _inform_once(self) -> int: + self._reload_runtime_settings() + if not self.enabled: + return self.inform_interval_seconds + + payload = await self._build_inform_payload() + response = await self._post_inform(payload) + + if payload.get("command_results"): + async with self._pending_lock: + self._pending_command_results = [] + + response_type = str(response.get("type", "noop")) + response_interval = response.get("interval") + + if response_type == "command": + await self._handle_command_response(response) + elif response_type == "config_update": + ok, message = self._apply_config_update( + response.get("config", {}), + str(response.get("merge_mode", "patch")), + ) + if ok: + logger.info("Applied Glass config update") + else: + logger.warning("Failed to apply Glass config update: %s", message) + elif response_type == "cert_renewal": + ok, message = self._apply_cert_renewal(response) + if ok: + logger.info("Applied Glass certificate renewal") + else: + logger.warning("Failed to apply Glass certificate renewal: %s", message) + elif response_type == "upgrade": + logger.warning("Glass upgrade action received but not implemented on repeater") + elif response_type != "noop": + logger.warning("Unknown Glass response type: %s", response_type) + + if isinstance(response_interval, int): + self.inform_interval_seconds = self._clamp_interval(response_interval) + return self.inform_interval_seconds + + async def _build_inform_payload(self) -> Dict[str, Any]: + if not self.daemon_instance or not getattr(self.daemon_instance, "local_identity", None): + raise RuntimeError("Local identity not available for Glass inform") + + stats = self.daemon_instance.get_stats() if self.daemon_instance else {} + local_identity = self.daemon_instance.local_identity + public_key = bytes(local_identity.get_public_key()).hex() + node_name = self.config.get("repeater", {}).get("node_name", "unknown-repeater") + + uptime_seconds = int(stats.get("uptime_seconds", 0)) + if uptime_seconds <= 0: + repeater_handler = getattr(self.daemon_instance, "repeater_handler", None) + if repeater_handler and getattr(repeater_handler, "start_time", None): + uptime_seconds = max(0, int(time.time() - repeater_handler.start_time)) + + tx_total = int(stats.get("sent_flood_count", 0)) + int(stats.get("sent_direct_count", 0)) + if tx_total <= 0: + tx_total = int(stats.get("forwarded_count", 0)) + + command_results = await self._get_pending_command_results() + settings_snapshot = self._build_settings_snapshot() + location = self._extract_location_from_settings(settings_snapshot) + + return { + "type": "inform", + "version": 1, + "node_name": node_name, + "pubkey": f"0x{public_key}", + "software_version": __version__, + "state": self.config.get("repeater", {}).get("mode", "forward"), + "location": location, + "uptime_seconds": uptime_seconds, + "config_hash": self._compute_config_hash(self.config), + "cert_expires_at": self._cert_expires_at, + "system": self._collect_system_stats(), + "radio": { + "frequency": int(self.config.get("radio", {}).get("frequency", 0)), + "spreading_factor": int(self.config.get("radio", {}).get("spreading_factor", 7)), + "bandwidth": int(self.config.get("radio", {}).get("bandwidth", 0)), + "tx_power": int(self.config.get("radio", {}).get("tx_power", 0)), + "noise_floor_dbm": stats.get("noise_floor_dbm"), + "mode": self.config.get("repeater", {}).get("mode", "forward"), + }, + "counters": { + "rx_total": int(stats.get("rx_count", 0)), + "tx_total": max(0, tx_total), + "forwarded": int(stats.get("forwarded_count", 0)), + "dropped": int(stats.get("dropped_count", 0)), + "duplicates": int(stats.get("flood_dup_count", 0)) + + int(stats.get("direct_dup_count", 0)), + "airtime_percent": float(stats.get("utilization_percent", 0.0)), + }, + "settings": settings_snapshot, + "command_results": command_results, + } + + def _build_settings_snapshot(self) -> Dict[str, Any]: + normalized = self._normalize_for_hash(self.config) + sanitized = self._sanitize_settings_for_export(normalized) + if isinstance(sanitized, dict): + return sanitized + return {} + + def _sanitize_settings_for_export(self, value: Any, key_name: Optional[str] = None) -> Any: + if isinstance(value, dict): + output: Dict[str, Any] = {} + for child_key, child_value in value.items(): + if self._is_sensitive_key(child_key): + output[child_key] = "" + continue + output[child_key] = self._sanitize_settings_for_export(child_value, child_key) + return output + if isinstance(value, list): + return [self._sanitize_settings_for_export(item, key_name) for item in value] + return value + + @staticmethod + def _is_sensitive_key(key: str) -> bool: + lowered = str(key).lower() + if any(exception in lowered for exception in _SENSITIVE_KEY_EXCEPTIONS): + return False + return any(marker in lowered for marker in _SENSITIVE_KEY_MARKERS) + + @staticmethod + def _normalize_location(value: Any) -> Optional[str]: + if isinstance(value, str): + text = value.strip() + if not text: + return None + parts = [part.strip() for part in text.split(",")] + if len(parts) != 2: + return None + try: + lat = float(parts[0]) + lng = float(parts[1]) + except ValueError: + return None + elif isinstance(value, dict): + lat = value.get("lat", value.get("latitude")) + lng = value.get("lng", value.get("longitude")) + try: + if lat is None or lng is None: + return None + lat = float(lat) + lng = float(lng) + except (TypeError, ValueError): + return None + elif isinstance(value, (list, tuple)) and len(value) == 2: + try: + lat = float(value[0]) + lng = float(value[1]) + except (TypeError, ValueError): + return None + else: + return None + + if lat < -90 or lat > 90 or lng < -180 or lng > 180: + return None + return f"{lat:.6f},{lng:.6f}" + + def _extract_location_from_settings(self, settings: Dict[str, Any]) -> Optional[str]: + repeater_settings = settings.get("repeater") + repeater_dict = repeater_settings if isinstance(repeater_settings, dict) else {} + candidates = [ + settings.get("location"), + repeater_dict.get("location"), + settings.get("gps"), + repeater_dict.get("gps"), + { + "lat": repeater_dict.get("latitude"), + "lng": repeater_dict.get("longitude"), + }, + ] + for candidate in candidates: + location = self._normalize_location(candidate) + if location: + return location + return None + + def _collect_system_stats(self) -> Dict[str, Any]: + temperature_c = None + try: + temperatures = psutil.sensors_temperatures() if hasattr(psutil, "sensors_temperatures") else {} + if temperatures: + for values in temperatures.values(): + if values: + temperature_c = values[0].current + break + except Exception: + temperature_c = None + + load_avg_1m = None + try: + if hasattr(os, "getloadavg"): + load_avg_1m = float(os.getloadavg()[0]) + except Exception: + load_avg_1m = None + + return { + "cpu_percent": float(psutil.cpu_percent(interval=None)), + "memory_percent": float(psutil.virtual_memory().percent), + "disk_percent": float(psutil.disk_usage("/").percent), + "temperature_c": temperature_c, + "load_avg_1m": load_avg_1m, + } + + async def _post_inform(self, payload: Dict[str, Any]) -> Dict[str, Any]: + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, self._post_inform_sync, payload) + + def _post_inform_sync(self, payload: Dict[str, Any]) -> Dict[str, Any]: + url = f"{self.base_url}/inform" + headers = {"Content-Type": "application/json"} + if self.api_token: + headers["Authorization"] = f"Bearer {self.api_token}" + + body = json.dumps(payload).encode("utf-8") + req = request.Request(url=url, data=body, method="POST", headers=headers) + ssl_context = self._build_ssl_context(url) + + try: + with request.urlopen( + req, + timeout=self.request_timeout_seconds, + context=ssl_context, + ) as response: + response_bytes = response.read() + except error.HTTPError as exc: + details = "" + try: + details = exc.read().decode("utf-8") + except Exception: + details = str(exc) + raise RuntimeError(f"HTTP {exc.code}: {details}") from exc + except error.URLError as exc: + raise RuntimeError(f"Connection error: {exc}") from exc + + if not response_bytes: + return {"type": "noop", "interval": self.inform_interval_seconds} + + try: + response_payload = json.loads(response_bytes.decode("utf-8")) + except Exception as exc: + raise RuntimeError("Invalid JSON response from Glass backend") from exc + + if not isinstance(response_payload, dict): + raise RuntimeError("Invalid response payload from Glass backend") + return response_payload + + def _build_ssl_context(self, url: str) -> Optional[ssl.SSLContext]: + if not str(url).startswith("https"): + return None + + if self.verify_tls: + if self.ca_cert_path: + ca_path = self._require_ssl_file(self.ca_cert_path, "ca_cert_path") + context = ssl.create_default_context(cafile=ca_path) + else: + context = ssl.create_default_context() + else: + context = ssl._create_unverified_context() + + if self.client_cert_path or self.client_key_path: + cert_path = self._require_ssl_file(self.client_cert_path, "client_cert_path") + key_path = self._require_ssl_file(self.client_key_path, "client_key_path") + context.load_cert_chain(certfile=cert_path, keyfile=key_path) + + return context + + @staticmethod + def _require_ssl_file(path_value: Optional[str], field_name: str) -> str: + if not path_value or not str(path_value).strip(): + raise RuntimeError(f"Missing {field_name} for Glass TLS configuration") + normalized = str(path_value).strip() + if not Path(normalized).exists(): + raise RuntimeError(f"Configured {field_name} does not exist: {normalized}") + return normalized + + async def _handle_command_response(self, response: Dict[str, Any]) -> None: + command_id = str(response.get("command_id", "")).strip() + action = str(response.get("action", "")).strip() + params = response.get("params", {}) + + if not command_id or not action: + logger.warning("Glass command response missing command_id or action") + return + + success = False + message = "Action failed" + details: Optional[Dict[str, Any]] = None + try: + success, message, details = await self._execute_command_action(action, params) + except Exception as exc: + success = False + message = f"Exception executing action: {exc}" + details = None + + await self._queue_command_result( + command_id=command_id, + status="success" if success else "failed", + message=message, + details=details, + ) + + async def _execute_command_action( + self, + action: str, + params: Any, + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + params = params if isinstance(params, dict) else {} + + if action == "restart_service": + success, message = restart_service() + return success, message, None + + if action == "send_advert": + if not self.daemon_instance or not hasattr(self.daemon_instance, "send_advert"): + return False, "send_advert unavailable", None + success = await self.daemon_instance.send_advert() + return success, "Advert sent" if success else "Failed to send advert", None + + if action == "set_mode": + mode = str(params.get("mode", "")).strip() + if mode not in ("forward", "monitor", "no_tx"): + return False, "Invalid mode parameter", None + success, message = self._apply_config_update( + {"repeater": {"mode": mode}}, + merge_mode="patch", + ) + return success, message, None + + if action == "set_inform_interval": + interval = params.get("interval_seconds", params.get("interval")) + if not isinstance(interval, int): + return False, "interval_seconds must be an integer", None + interval = self._clamp_interval(interval) + self.inform_interval_seconds = interval + success, message = self._apply_config_update( + {"glass": {"inform_interval_seconds": interval}}, + merge_mode="patch", + ) + return success, message, None + if action == "rotate_cert": + return True, "Certificate rotation requested", None + + if action == "config_update": + config_patch = params.get("config", params) + merge_mode = str(params.get("merge_mode", "patch")) + success, message = self._apply_config_update(config_patch, merge_mode=merge_mode) + return success, message, None + + if action == "transport_keys_sync": + success, message, details = self._apply_transport_keys_sync(params) + return success, message, details + + if action == "set_radio": + radio_values = params.get("radio", params) + if not isinstance(radio_values, dict): + return False, "radio settings must be an object", None + success, message = self._apply_config_update({"radio": radio_values}, merge_mode="patch") + return success, message, None + + if action == "run_diagnostic": + stats = self.daemon_instance.get_stats() if self.daemon_instance else {} + return True, ( + f"rx={int(stats.get('rx_count', 0))}, " + f"tx={int(stats.get('forwarded_count', 0))}, " + f"dropped={int(stats.get('dropped_count', 0))}" + ), None + + if action == "export_config": + normalized_config = self._normalize_for_hash(self.config) + return ( + True, + "Configuration exported", + { + "config": normalized_config, + "config_hash": self._compute_config_hash(self.config), + }, + ) + + return False, f"Unsupported action: {action}", None + + def _apply_config_update(self, updates: Any, merge_mode: str = "patch") -> Tuple[bool, str]: + if not isinstance(updates, dict) or not updates: + return False, "Config update payload must be a non-empty object" + merge_mode = merge_mode.lower().strip() + + if merge_mode not in ("patch", "replace"): + return False, f"Unsupported merge_mode: {merge_mode}" + updates_to_apply = dict(updates) + managed_updates = updates_to_apply.pop("glass_managed", None) + if managed_updates is not None: + managed_ok, managed_message = self._save_managed_settings( + managed_updates, + replace=merge_mode == "replace", + ) + if not managed_ok: + return False, managed_message + self._reload_runtime_settings() + self._sync_mqtt_publisher() + + if not updates_to_apply: + return True, "Managed settings updated" + + sections = list(updates_to_apply.keys()) + + if merge_mode == "replace": + for section, value in updates_to_apply.items(): + self.config[section] = value + if self.config_manager: + saved = self.config_manager.save_to_file() + live_updated = self.config_manager.live_update_daemon(sections) + return ( + bool(saved and live_updated), + "Config replaced" if saved and live_updated else "Failed to persist replace update", + ) + return True, "Config replaced" + + # patch mode + if self.config_manager: + result = self.config_manager.update_and_save( + updates=updates_to_apply, + live_update=True, + live_update_sections=sections, + ) + if result.get("success"): + if "glass" in sections: + self._reload_runtime_settings() + self._sync_mqtt_publisher() + return True, "Config patched" + return False, str(result.get("error", "Failed to patch config")) + self._deep_merge(self.config, updates_to_apply) + if "glass" in sections: + self._reload_runtime_settings() + self._sync_mqtt_publisher() + return True, "Config patched" + + def _get_sqlite_handler(self): + if not self.daemon_instance: + return None + repeater_handler = getattr(self.daemon_instance, "repeater_handler", None) + storage = getattr(repeater_handler, "storage", None) + return getattr(storage, "sqlite_handler", None) + + def _apply_transport_keys_sync( + self, + params: Dict[str, Any], + ) -> Tuple[bool, str, Optional[Dict[str, Any]]]: + if not isinstance(params, dict): + return False, "transport_keys_sync params must be an object", None + entries = params.get("transport_keys") + if not isinstance(entries, list): + return False, "transport_keys_sync payload must include a transport_keys list", None + sqlite_handler = self._get_sqlite_handler() + if sqlite_handler is None: + return False, "SQLite handler unavailable for transport key sync", None + try: + result = sqlite_handler.sync_transport_keys(entries) + except Exception as exc: + return False, f"Transport key sync failed: {exc}", None + payload_hash = params.get("payload_hash") + details: Dict[str, Any] = { + "applied_nodes": int(result.get("applied_nodes", 0)), + "generated_keys": int(result.get("generated_keys", 0)), + } + if isinstance(payload_hash, str) and payload_hash.strip(): + details["payload_hash"] = payload_hash + return True, f"Applied transport key sync ({details['applied_nodes']} nodes)", details + + def _apply_cert_renewal(self, response: Dict[str, Any]) -> Tuple[bool, str]: + client_cert = response.get("client_cert") + client_key = response.get("client_key") + ca_cert = response.get("ca_cert") + + if not all(isinstance(item, str) and item.strip() for item in (client_cert, client_key, ca_cert)): + return False, "Missing certificate payload values" + + cert_dir = Path(self.cert_store_dir) + cert_dir.mkdir(parents=True, exist_ok=True) + + client_cert_path = cert_dir / "glass-client.crt" + client_key_path = cert_dir / "glass-client.key" + ca_cert_path = cert_dir / "glass-ca.crt" + + client_cert_path.write_text(client_cert, encoding="utf-8") + client_key_path.write_text(client_key, encoding="utf-8") + ca_cert_path.write_text(ca_cert, encoding="utf-8") + os.chmod(client_key_path, 0o600) + + return self._apply_config_update( + { + "glass": { + "client_cert_path": str(client_cert_path), + "client_key_path": str(client_key_path), + "ca_cert_path": str(ca_cert_path), + } + }, + merge_mode="patch", + ) + + async def _get_pending_command_results(self) -> List[Dict[str, Any]]: + async with self._pending_lock: + return list(self._pending_command_results) + + async def _queue_command_result( + self, + command_id: str, + status: str, + message: str, + details: Optional[Dict[str, Any]] = None, + ) -> None: + completed_at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + result = { + "command_id": command_id, + "status": status, + "message": message[:1024] if message else "", + "completed_at": completed_at, + } + if details: + result["details"] = details + async with self._pending_lock: + self._pending_command_results.append(result) + + def publish_telemetry(self, record_type: str, record: Dict[str, Any]) -> None: + if not self.enabled or not self.mqtt_enabled or not self._mqtt_ready: + return + if not self._mqtt_client: + return + + node_name = self.config.get("repeater", {}).get("node_name", "unknown-repeater") + event_type = "event" + event_name: Optional[str] = record_type + if record_type in ("packet", "advert"): + event_type = record_type + event_name = None + + topic = self._mqtt_topic_for_record(node_name=node_name, record_type=record_type) + timestamp = self._to_rfc3339_timestamp(record.get("timestamp")) + payload = self._normalize_for_hash(record) + + envelope: Dict[str, Any] = { + "version": 1, + "type": event_type, + "topic": topic, + "node_name": node_name, + "timestamp": timestamp, + "payload": payload, + } + if event_type == "event" and event_name: + envelope["event_name"] = event_name + + try: + message = json.dumps(envelope, separators=(",", ":"), sort_keys=True, default=str) + self._mqtt_client.publish(topic, message, qos=0, retain=False) + except Exception as exc: + logger.debug("Failed publishing Glass telemetry MQTT message: %s", exc) + + def _mqtt_topic_for_record(self, *, node_name: str, record_type: str) -> str: + base = self.mqtt_base_topic.strip("/") or "glass" + if record_type in ("packet", "advert"): + return f"{base}/{node_name}/{record_type}" + return f"{base}/{node_name}/event/{record_type}" + + def _to_rfc3339_timestamp(self, value: Any) -> str: + if isinstance(value, (int, float)): + dt = datetime.fromtimestamp(float(value), timezone.utc) + elif isinstance(value, str): + normalized = value.strip() + if normalized.endswith("Z"): + return normalized + try: + dt = datetime.fromisoformat(normalized) + except ValueError: + dt = datetime.now(timezone.utc) + elif isinstance(value, datetime): + dt = value + else: + dt = datetime.now(timezone.utc) + + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + else: + dt = dt.astimezone(timezone.utc) + return dt.isoformat().replace("+00:00", "Z") + + def _init_mqtt_publisher(self) -> None: + if not self.mqtt_enabled: + self._close_mqtt_publisher() + return + if mqtt is None: + logger.warning("Glass MQTT telemetry publishing enabled but paho-mqtt is unavailable") + self._close_mqtt_publisher() + return + if self._mqtt_client is not None: + return + + try: + client = mqtt.Client() + if self.mqtt_username: + client.username_pw_set(self.mqtt_username, self.mqtt_password) + if self.mqtt_tls_enabled: + ca_certs = self._require_ssl_file(self.ca_cert_path, "ca_cert_path") if self.ca_cert_path else None + certfile = None + keyfile = None + if self.client_cert_path or self.client_key_path: + certfile = self._require_ssl_file(self.client_cert_path, "client_cert_path") + keyfile = self._require_ssl_file(self.client_key_path, "client_key_path") + cert_reqs = ssl.CERT_REQUIRED if self.verify_tls else ssl.CERT_NONE + client.tls_set( + ca_certs=ca_certs, + certfile=certfile, + keyfile=keyfile, + cert_reqs=cert_reqs, + tls_version=ssl.PROTOCOL_TLS_CLIENT, + ) + if not self.verify_tls: + client.tls_insecure_set(True) + client.on_connect = self._on_mqtt_connect + client.on_disconnect = self._on_mqtt_disconnect + client.connect_async(self.mqtt_broker_host, self.mqtt_broker_port, 60) + client.loop_start() + self._mqtt_client = client + self._mqtt_runtime_signature = self._current_mqtt_signature() + logger.info( + "Glass MQTT telemetry publisher started (%s:%s, base_topic=%s)", + self.mqtt_broker_host, + self.mqtt_broker_port, + self.mqtt_base_topic, + ) + except Exception as exc: + self._mqtt_client = None + self._mqtt_ready = False + self._mqtt_runtime_signature = None + logger.warning("Failed to start Glass MQTT telemetry publisher: %s", exc) + + def _close_mqtt_publisher(self) -> None: + client = self._mqtt_client + self._mqtt_client = None + self._mqtt_ready = False + self._mqtt_runtime_signature = None + if client is None: + return + try: + client.loop_stop() + client.disconnect() + except Exception as exc: + logger.debug("Error stopping Glass MQTT telemetry publisher: %s", exc) + + def _on_mqtt_connect(self, _client, _userdata, _flags, reason_code, _properties=None) -> None: + rc = getattr(reason_code, "value", reason_code) + if rc == 0: + self._mqtt_ready = True + logger.info("Glass MQTT telemetry publisher connected") + return + self._mqtt_ready = False + logger.warning("Glass MQTT telemetry publisher connect failed (code=%s)", rc) + + def _on_mqtt_disconnect(self, _client, _userdata, reason_code, _properties=None) -> None: + self._mqtt_ready = False + rc = getattr(reason_code, "value", reason_code) + if rc: + logger.warning("Glass MQTT telemetry publisher disconnected (code=%s)", rc) + + def _current_mqtt_signature( + self, + ) -> Tuple[str, int, str, bool, bool, Optional[str], Optional[str], Optional[str], Optional[str], Optional[str]]: + return ( + self.mqtt_broker_host, + self.mqtt_broker_port, + self.mqtt_base_topic, + self.mqtt_tls_enabled, + self.verify_tls, + self.ca_cert_path, + self.client_cert_path, + self.client_key_path, + self.mqtt_username, + self.mqtt_password, + ) + + def _sync_mqtt_publisher(self) -> None: + if not self.enabled or not self.mqtt_enabled: + self._close_mqtt_publisher() + return + if mqtt is None: + self._close_mqtt_publisher() + return + + signature = self._current_mqtt_signature() + if self._mqtt_client is None: + self._init_mqtt_publisher() + return + if self._mqtt_runtime_signature != signature: + self._close_mqtt_publisher() + self._init_mqtt_publisher() + + @staticmethod + def _deep_merge(target: Dict[str, Any], source: Dict[str, Any]) -> None: + for key, value in source.items(): + if ( + isinstance(value, dict) + and isinstance(target.get(key), dict) + ): + GlassHandler._deep_merge(target[key], value) + else: + target[key] = value + + @staticmethod + def _normalize_for_hash(value: Any) -> Any: + if isinstance(value, bytes): + return value.hex() + if isinstance(value, dict): + return {k: GlassHandler._normalize_for_hash(v) for k, v in value.items()} + if isinstance(value, list): + return [GlassHandler._normalize_for_hash(v) for v in value] + return value + + @staticmethod + def _compute_config_hash(config: dict) -> str: + normalized = GlassHandler._normalize_for_hash(config) + encoded = json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode("utf-8") + digest = hashlib.sha256(encoded).hexdigest() + return f"sha256:{digest}" + + @staticmethod + def _clamp_interval(interval_seconds: int) -> int: + if interval_seconds < 5: + return 5 + if interval_seconds > 3600: + return 3600 + return interval_seconds 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 8a42504..88c0f6a 100644 --- a/repeater/data_acquisition/letsmesh_handler.py +++ b/repeater/data_acquisition/letsmesh_handler.py @@ -1,13 +1,32 @@ +import base64 +import binascii import json import logging -import binascii -import base64 -import paho.mqtt.client as mqtt +import threading +from datetime import 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 -from .. import __version__ + +# Try to import datetime.UTC (Python 3.11+) otherwise fallback to timezone.utc +try: + from datetime import UTC +except Exception: + from datetime import timezone + UTC = timezone.utc + +from repeater 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") # -------------------------------------------------------------------- @@ -37,65 +56,54 @@ LETSMESH_BROKERS = [ # ==================================================================== -# MeshCore → MQTT Publisher with Ed25519 auth token +# Single Broker Connection Manager # ==================================================================== -class MeshCoreToMqttJwtPusher: +class _BrokerConnection: """ - Push-only MQTT publisher for Let's Mesh MQTT brokers. - Implements MeshCore-style Ed25519 token signing. - No modifications to crypto.py. + Manages a single MQTT broker connection with independent lifecycle. + Internal class - not exposed publicly. """ def __init__( self, - private_key: str, + broker: dict, + local_identity, public_key: str, - config: dict, - jwt_expiry_minutes: int = 10, - use_tls: bool = True, - stats_provider: Optional[Callable[[], dict]] = None, + iata_code: str, + jwt_expiry_minutes: int, + use_tls: bool, + email: str, + owner: str, + broker_index: int = 0, + on_connect_callback: Optional[Callable] = None, + on_disconnect_callback: Optional[Callable] = None, ): - # Extract values from config - from ..config import get_node_info - - node_info = get_node_info(config) - - iata_code = node_info["iata_code"] - broker_index = node_info["broker_index"] - self.email = node_info.get("email", "") - self.owner = node_info.get("owner", "") - status_interval = node_info["status_interval"] - node_name = node_info["node_name"] - radio_config = node_info["radio_config"] - - if broker_index >= len(LETSMESH_BROKERS): - raise ValueError(f"Invalid broker_index {broker_index}") - - self.broker = LETSMESH_BROKERS[broker_index] - self.private_key_hex = private_key + self.broker = broker + self.local_identity = local_identity self.public_key = public_key.upper() self.iata_code = iata_code self.jwt_expiry_minutes = jwt_expiry_minutes + self.broker_index = broker_index self.use_tls = use_tls - self.status_interval = status_interval - self.app_version = __version__ - self.node_name = node_name - self.radio_config = radio_config - self.stats_provider = stats_provider - self._status_task = None - self._running = False + self.email = email + self.owner = owner + self._on_connect_callback = on_connect_callback + self._on_disconnect_callback = on_disconnect_callback self._connect_time = None self._tls_verified = False - - # MQTT WebSocket client - self.client = mqtt.Client(client_id=f"meshcore_{self.public_key}", transport="websockets") + self._running = False + self._reconnect_attempts = 0 + self._reconnect_timer = None + self._max_reconnect_delay = 300 # 5 minutes max + self._jwt_refresh_timer = None + self._shutdown_requested = False + client_id = f"meshcore_{self.public_key}_{broker['host']}" + self.client = mqtt.Client(client_id=client_id, transport="websockets") self.client.on_connect = self._on_connect self.client.on_disconnect = self._on_disconnect - # ---------------------------------------------------------------- - # MeshCore-style Ed25519 token generator - # ---------------------------------------------------------------- def _generate_jwt(self) -> str: + """Generate MeshCore-style Ed25519 JWT token""" now = datetime.now(UTC) header = {"alg": "Ed25519", "typ": "JWT"} @@ -106,126 +114,427 @@ class MeshCoreToMqttJwtPusher: "iat": int(now.timestamp()), "exp": int((now + timedelta(minutes=self.jwt_expiry_minutes)).timestamp()), } - + # Only include email/owner for verified TLS connections if self.use_tls and self._tls_verified and (self.email or self.owner): payload["email"] = self.email payload["owner"] = self.owner - logging.debug("JWT includes email/owner (TLS verified)") else: payload["email"] = "" payload["owner"] = "" - if not self.use_tls: - logging.debug("JWT excludes email/owner (TLS disabled)") - elif not self._tls_verified: - logging.debug("JWT excludes email/owner (TLS not verified yet)") - else: - logging.debug("JWT excludes email/owner (email/owner not configured)") # Encode header and payload (compact JSON - no spaces) header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode()) payload_b64 = b64url(json.dumps(payload, separators=(",", ":")).encode()) signing_input = f"{header_b64}.{payload_b64}".encode() - seed32 = binascii.unhexlify(self.private_key_hex) - signer = SigningKey(seed32) - # Verify the public key matches what we expect - derived_public = binascii.hexlify(bytes(signer.verify_key)).decode() - if derived_public.upper() != self.public_key.upper(): - raise ValueError( - f"Public key mismatch! " f"Derived: {derived_public}, Expected: {self.public_key}" - ) + # Sign using LocalIdentity (supports both standard and firmware keys) + try: + signature = self.local_identity.sign(signing_input) + except Exception as e: + logger.error(f"JWT signing failed for {self.broker['name']}: {e}") + logger.error(f" - public_key: {self.public_key}") + logger.error(f" - signing_input length: {len(signing_input)}") + raise - # Sign the message - signature = signer.sign(signing_input).signature signature_hex = binascii.hexlify(signature).decode() token = f"{header_b64}.{payload_b64}.{signature_hex}" - logging.debug(f"Generated MeshCore token: {token[:10]}...{token[-10:]}") + logger.debug(f"JWT token generated for {self.broker['name']}: {token[:50]}...") + return token - # ---------------------------------------------------------------- - # MQTT setup - # ---------------------------------------------------------------- def _on_connect(self, client, userdata, flags, rc): + """MQTT connection callback""" if rc == 0: - logging.info(f"Connected to {self.broker['name']}") + logger.info(f"Connected to {self.broker['name']}") self._running = True - - # Publish initial status on connect - self.publish_status( - state="online", origin=self.node_name, radio_config=self.radio_config - ) - - # connected start heartbeat thread - if self.status_interval > 0 and not self._status_task: - import threading - self._status_task = threading.Thread(target=self._status_heartbeat_loop, daemon=True) - self._status_task.start() - logging.info(f"Started status heartbeat (interval: {self.status_interval}s)") + self._reconnect_attempts = 0 # Reset counter on success + self._schedule_jwt_refresh() # Schedule proactive JWT refresh + if self._on_connect_callback: + self._on_connect_callback(self.broker["name"]) else: - logging.error(f"Failed with code {rc}") + error_msg = get_mqtt_error_message(rc, is_disconnect=False) + logger.error(f"Failed to connect to {self.broker['name']}: {error_msg}") + self._schedule_reconnect() def _on_disconnect(self, client, userdata, rc): - logging.warning(f"Disconnected (rc={rc})") + """MQTT disconnection callback""" + was_running = self._running self._running = False - def _refresh_jwt_token(self): - """Refresh JWT token for MQTT authentication""" - token = self._generate_jwt() - username = f"v1_{self.public_key}" - self.client.username_pw_set(username=username, password=token) - self._connect_time = datetime.now(UTC) - logging.info("JWT token refreshed") + if self._shutdown_requested: + logger.info(f"Clean disconnect from {self.broker['name']}") + if self._on_disconnect_callback: + self._on_disconnect_callback(self.broker["name"]) + return + + if rc != 0: # Unexpected disconnect + error_msg = get_mqtt_error_message(rc, is_disconnect=True) + logger.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}") + if was_running: # Only reconnect if we were intentionally connected + self._schedule_reconnect(reason=error_msg) + else: + logger.info(f"Clean disconnect from {self.broker['name']}") + + if self._on_disconnect_callback: + self._on_disconnect_callback(self.broker["name"]) + + def _schedule_reconnect(self, reason: str = "connection lost"): + """Schedule reconnection with exponential backoff""" + if self._shutdown_requested: + return + + 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) + self._reconnect_attempts += 1 + + logger.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""" + if self._shutdown_requested: + return + + try: + logger.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 Exception: + pass + + self._set_jwt_credentials() + + # Reconnect and restart loop + self.client.connect(self.broker["host"], self.broker["port"], keepalive=60) + self.client.loop_start() + self._loop_running = True + except Exception as e: + logger.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: + token = self._generate_jwt() + username = f"v1_{self.public_key}" + self.client.username_pw_set(username=username, password=token) + self._connect_time = datetime.now(UTC) + logger.debug(f"JWT credentials set for {self.broker['name']}") + logger.debug(f"Using username: {username}") + logger.debug(f"Public key: {self.public_key[:16]}...{self.public_key[-16:]}") + except Exception as e: + logger.error(f"Failed to set JWT credentials for {self.broker['name']}: {e}") + raise - # ---------------------------------------------------------------- - # Connect using WebSockets + TLS + MeshCore token auth - # ---------------------------------------------------------------- def connect(self): + """Establish connection to broker""" + self._shutdown_requested = False + # Conditional TLS setup if self.use_tls: import ssl - # Enable TLS with certificate verification - self.client.tls_set( - cert_reqs=ssl.CERT_REQUIRED, - tls_version=ssl.PROTOCOL_TLS_CLIENT - ) - self.client.tls_insecure_set(False) # Enforce hostname verification - # Mark as verified - if connection fails, we won't connect anyway + + self.client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT) + self.client.tls_insecure_set(False) self._tls_verified = True - if self.email or self.owner: - logging.info("TLS enabled with certificate verification - email/owner will be included") protocol = "wss" else: protocol = "ws" - # Generate JWT token (will include email/owner if TLS verified) - token = self._generate_jwt() - username = f"v1_{self.public_key}" - self.client.username_pw_set(username=username, password=token) + # Set JWT credentials before CONNECT handshake + self._set_jwt_credentials() - logging.info( + logger.info( f"Connecting to {self.broker['name']} " f"({protocol}://{self.broker['host']}:{self.broker['port']}) ..." ) - # Must use raw hostname without wss:// self.client.connect(self.broker["host"], self.broker["port"], keepalive=60) self.client.loop_start() - self._connect_time = datetime.now(UTC) + self._loop_running = True def disconnect(self): + """Disconnect from broker""" + self._shutdown_requested = True self._running = False - # Publish offline status before disconnecting - self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config) - import time + self._loop_running = False - time.sleep(0.5) # Give time for the message to be sent + # Cancel any pending timers + if self._reconnect_timer: + self._reconnect_timer.cancel() + self._reconnect_timer = None + if self._jwt_refresh_timer: + self._jwt_refresh_timer.cancel() + self._jwt_refresh_timer = None self.client.loop_stop() self.client.disconnect() - logging.info("Disconnected") + logger.info(f"Disconnected from {self.broker['name']}") + + def publish(self, topic: str, payload: str, retain: bool = False): + """Publish message to broker""" + if self._running: + result = self.client.publish(topic, payload, retain=retain) + return result + return None + + 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() + + def should_reconnect_for_token_expiry(self) -> bool: + """Check if connection should be reconnected due to JWT expiry (at 80% of lifetime)""" + if not self._connect_time: + return False + elapsed = (datetime.now(UTC) - self._connect_time).total_seconds() + 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 + 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 + + logger.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)" + ) + 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 + + logger.info(f"JWT token expiring soon for {self.broker['name']}, refreshing...") + self._running = False + self._jwt_refresh_timer = None + + self._schedule_reconnect(reason="JWT token expiry") + self.client.disconnect() + + +# ==================================================================== +# MeshCore → MQTT Publisher with Ed25519 auth token +# ==================================================================== +class MeshCoreToMqttJwtPusher: + + def __init__( + self, + local_identity, + config: dict, + jwt_expiry_minutes: int = 10, + use_tls: bool = True, + stats_provider: Optional[Callable[[], dict]] = None, + ): + # 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 + + node_info = get_node_info(config) + + iata_code = node_info["iata_code"] + broker_index = node_info.get("broker_index") + self.email = node_info.get("email", "") + self.owner = node_info.get("owner", "") + status_interval = node_info["status_interval"] + node_name = node_info["node_name"] + radio_config = node_info["radio_config"] + + # Get additional brokers from config (optional) + letsmesh_config = config.get("letsmesh", {}) + additional_brokers = letsmesh_config.get("additional_brokers", []) + + # Determine which brokers to connect to + if broker_index == -2: + # Custom brokers only - no built-in brokers + self.brokers = [] + logger.info("Custom broker mode: using only user-defined brokers") + elif broker_index is None or broker_index == -1: + # Connect to all built-in brokers + additional ones + self.brokers = LETSMESH_BROKERS.copy() + logger.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]] + logger.info(f"Single broker mode: connecting to {self.brokers[0]['name']}") + + # Add additional brokers from config + if additional_brokers: + for broker_config in additional_brokers: + if all(k in broker_config for k in ["name", "host", "port", "audience"]): + self.brokers.append(broker_config) + logger.info(f"Added custom broker: {broker_config['name']}") + else: + logger.warning(f"Skipping invalid broker config: {broker_config}") + + # Validate that we have at least one broker + if not self.brokers: + raise ValueError( + "No brokers configured. Either set broker_index to a valid value " + "or provide additional_brokers in config." + ) + + self.local_identity = local_identity + self.public_key = public_key + self.iata_code = iata_code + self.jwt_expiry_minutes = jwt_expiry_minutes + self.use_tls = use_tls + self.status_interval = status_interval + self.app_version = __version__ + self.node_name = node_name + self.radio_config = radio_config + self.stats_provider = stats_provider + self._status_task = None + self._running = False + self._shutdown_requested = False + self._lock = threading.Lock() + self._connect_timers: List[threading.Timer] = [] + + # Create broker connections + self.connections: List[_BrokerConnection] = [] + for idx, broker in enumerate(self.brokers): + conn = _BrokerConnection( + broker=broker, + local_identity=self.local_identity, + public_key=self.public_key, + iata_code=self.iata_code, + jwt_expiry_minutes=self.jwt_expiry_minutes, + use_tls=self.use_tls, + email=self.email, + owner=self.owner, + broker_index=idx, + on_connect_callback=self._on_broker_connected, + on_disconnect_callback=self._on_broker_disconnected, + ) + self.connections.append(conn) + + logger.info(f"Initialized with {len(self.connections)} broker connection(s)") + + def _on_broker_connected(self, broker_name: str): + """Callback when a broker connects""" + if self._shutdown_requested: + return + + # Publish initial status on first connection + if not self._status_task and self.status_interval > 0: + self._running = True + self.publish_status( + state="online", origin=self.node_name, radio_config=self.radio_config + ) + # Start heartbeat thread + self._status_task = threading.Thread(target=self._status_heartbeat_loop, daemon=True) + self._status_task.start() + logger.info(f"Started status heartbeat (interval: {self.status_interval}s)") + + def _on_broker_disconnected(self, broker_name: str): + """Callback when a broker disconnects""" + # 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: + logger.warning("All broker connections lost with no pending reconnects") + elif all_down: + logger.info("All brokers temporarily disconnected, reconnects pending") + + def connect(self): + """Establish connections to all configured brokers""" + self._shutdown_requested = False + self._connect_timers = [] + + for idx, conn in enumerate(self.connections): + try: + if idx == 0: + # Connect first broker immediately + conn.connect() + else: + # Stagger additional brokers using background timers + delay = idx * 30 + logger.info(f"Staggering connection to {conn.broker['name']} by {delay}s") + timer = threading.Timer(delay, lambda c=conn: self._delayed_connect(c)) + timer.daemon = True + timer.start() + self._connect_timers.append(timer) + except Exception as e: + logger.error(f"Failed to connect to {conn.broker['name']}: {e}") + + def _delayed_connect(self, conn): + """Connect a broker after a delay (called by timer)""" + if self._shutdown_requested: + return + + try: + conn.connect() + except Exception as e: + logger.error(f"Failed to connect to {conn.broker['name']}: {e}") + + def disconnect(self): + """Disconnect from all brokers""" + self._shutdown_requested = True + + # Cancel any delayed connect timers first. + for timer in self._connect_timers: + try: + timer.cancel() + except Exception: + pass + self._connect_timers = [] + + # Stop the heartbeat loop + self._running = False + + # Publish offline status before disconnecting + try: + self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config) + except Exception: + pass + + # Disconnect all brokers + for conn in self.connections: + try: + conn.disconnect() + except Exception as e: + logger.error(f"Error disconnecting from {conn.broker['name']}: {e}") + + self._status_task = None + logger.info("Disconnected from all brokers") def _status_heartbeat_loop(self): """Background thread that publishes periodic status updates""" @@ -233,20 +542,15 @@ class MeshCoreToMqttJwtPusher: while self._running: try: - # Refresh JWT token before it expires (at 80% of expiry time) - if self._connect_time: - elapsed = (datetime.now(UTC) - self._connect_time).total_seconds() - expiry_seconds = self.jwt_expiry_minutes * 60 - if elapsed >= expiry_seconds * 0.8: - self._refresh_jwt_token() - + # Publish status (JWT refresh now handled by individual broker timers) self.publish_status( state="online", origin=self.node_name, radio_config=self.radio_config ) - logging.debug(f"Status heartbeat sent (next in {self.status_interval}s)") + logger.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}") + logger.error(f"Status heartbeat error: {e}") time.sleep(self.status_interval) # ---------------------------------------------------------------- @@ -307,9 +611,122 @@ class MeshCoreToMqttJwtPusher: return self.publish("status", status, retain=False) def publish(self, subtopic: str, payload: dict, retain: bool = False): + """Publish message to all connected brokers""" topic = self._topic(subtopic) message = json.dumps(payload) - result = self.client.publish(topic, message, retain=retain) - logging.debug(f"Published to {topic}: {message}") - return result + results = [] + with self._lock: + for conn in self.connections: + if conn.is_connected(): + result = conn.publish(topic, message, retain=retain) + results.append((conn.broker["name"], result)) + logger.debug(f"Published to {conn.broker['name']}/{topic}") + + if not results: + logger.warning(f"No active broker connections for publishing to {topic}") + + return results + + +# ==================================================================== +# 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 + """ + if HAS_REASON_CODES: + try: + # ReasonCode object has getName() method and value property + reason = ReasonCode(mqtt.CONNACK if not is_disconnect else mqtt.DISCONNECT, identifier=rc) + name = reason.getName() if hasattr(reason, 'getName') else str(reason) + return f"{name} (code {rc})" + except Exception as e: + # Log the exception for debugging + logger.debug(f"Could not decode reason code {rc}: {e}") + + # Fallback to manual mappings - Extended with MQTT v5 codes + connect_errors = { + 0: "Connection accepted", + 1: "Incorrect protocol version", + 2: "Invalid client identifier", + 3: "Server unavailable", + 4: "Bad username or password (JWT invalid)", + 5: "Not authorized (JWT signature/format invalid)", + # MQTT v5 codes + 128: "Unspecified error", + 129: "Malformed packet", + 130: "Protocol error", + 131: "Implementation specific error", + 132: "Unsupported protocol version", + 133: "Client identifier not valid", + 134: "Bad username or password", + 135: "Not authorized", + 136: "Server unavailable", + 137: "Server busy", + 138: "Banned", + 140: "Bad authentication method", + 144: "Topic name invalid", + 149: "Packet too large", + 151: "Quota exceeded", + 153: "Payload format invalid", + 154: "Retain not supported", + 155: "QoS not supported", + 156: "Use another server", + 157: "Server moved", + 159: "Connection rate exceeded", + } + + disconnect_errors = { + 0: "Normal disconnect", + 1: "Unacceptable protocol version", + 2: "Identifier rejected", + 3: "Server unavailable", + 4: "Bad username or password", + 5: "Not authorized", + 7: "Connection lost / network error", + 16: "Connection lost / protocol error", + 17: "Client timeout", + # MQTT v5 codes + 4: "Disconnect with Will message", + 128: "Unspecified error", + 129: "Malformed packet", + 130: "Protocol error", + 131: "Implementation specific error", + 135: "Not authorized", + 137: "Server busy", + 139: "Server shutting down", + 141: "Keep alive timeout", + 142: "Session taken over", + 143: "Topic filter invalid", + 144: "Topic name invalid", + 147: "Receive maximum exceeded", + 148: "Topic alias invalid", + 149: "Packet too large", + 150: "Message rate too high", + 151: "Quota exceeded", + 152: "Administrative action", + 153: "Payload format invalid", + 154: "Retain not supported", + 155: "QoS not supported", + 156: "Use another server", + 157: "Server moved", + 158: "Shared subscriptions not supported", + 159: "Connection rate exceeded", + 160: "Maximum connect time", + 161: "Subscription identifiers not supported", + 162: "Wildcard subscriptions not supported", + } + + 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 27fc5f3..1985d17 100644 --- a/repeater/data_acquisition/mqtt_handler.py +++ b/repeater/data_acquisition/mqtt_handler.py @@ -1,92 +1,936 @@ +import base64 +import binascii import json import logging -from typing import Dict, Any, Optional +import string +import threading +from datetime import datetime, timedelta +from typing import Callable, Dict, List, Optional +import paho.mqtt.client as mqtt +from nacl.signing import SigningKey + +# Try to import datetime.UTC (Python 3.11+) otherwise fallback to timezone.utc try: - import paho.mqtt.client as mqtt - MQTT_AVAILABLE = True -except ImportError: - MQTT_AVAILABLE = False + from datetime import UTC +except Exception: + from datetime import timezone + UTC = timezone.utc -from .storage_utils import PacketRecord +from repeater import __version__, config + +# 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("MQTTHandler") -class MQTTHandler: - def __init__(self, mqtt_config: dict, node_name: str = "unknown", node_id: str = "unknown"): - self.mqtt_config = mqtt_config +# -------------------------------------------------------------------- +# Helper: Base64URL without padding +# -------------------------------------------------------------------- +def b64url(x: bytes) -> str: + return base64.urlsafe_b64encode(x).rstrip(b"=").decode() + +LETSMESH_BROKERS = [ + { + "name": "Europe (LetsMesh v1)", + "host": "mqtt-eu-v1.letsmesh.net", + "port": 443, + "audience": "mqtt-eu-v1.letsmesh.net", + "use_jwt_auth": True, + "tls": { + "enabled": True, + "insecure": False, + }, + }, + { + "name": "US West (LetsMesh v1)", + "host": "mqtt-us-v1.letsmesh.net", + "port": 443, + "audience": "mqtt-us-v1.letsmesh.net", + "use_jwt_auth": True, + "tls": { + "enabled": True, + "insecure": False, + }, + }, +] + + +# ==================================================================== +# Single Broker Connection Manager +# ==================================================================== +class _BrokerConnection: + """ + Manages a single MQTT broker connection with independent lifecycle. + Internal class - not exposed publicly. + """ + + def __init__( + self, + broker: dict, + local_identity, + public_key: str, + iata_code: str, + jwt_expiry_minutes: int, + email: str, + owner: str, + broker_index: int, + node_name: str, + on_connect_callback: Optional[Callable] = None, + on_disconnect_callback: Optional[Callable] = None + ): + self.broker = broker + self.local_identity = local_identity + self.public_key = public_key.upper() + self.iata_code = iata_code + self.jwt_expiry_minutes = jwt_expiry_minutes + self.email = email + self.owner = owner self.node_name = node_name - self.node_id = node_id - self.client = None - self.available = MQTT_AVAILABLE - self._init_client() - - def _init_client(self): - if not self.available or not self.mqtt_config.get("enabled", False): - logger.info("MQTT disabled or not available") - return - - try: - self.client = mqtt.Client() - - username = self.mqtt_config.get("username") - password = self.mqtt_config.get("password") - if username: - self.client.username_pw_set(username, password) - - broker = self.mqtt_config.get("broker", "localhost") - port = self.mqtt_config.get("port", 1883) - - self.client.connect(broker, port, 60) - self.client.loop_start() - - logger.info(f"MQTT client connected to {broker}:{port}") - - except Exception as e: - logger.error(f"Failed to initialize MQTT: {e}") - self.client = None - - def publish(self, record: dict, record_type: str): - """ - Publish record to MQTT. - Packets MUST use PacketRecord format. Non-packet records use original format. + self.broker_index = broker_index + self._on_connect_callback = on_connect_callback + self._on_disconnect_callback = on_disconnect_callback + self._connect_time = None + self._running = False + self._reconnect_attempts = 0 + self._reconnect_timer = None + self._max_reconnect_delay = 300 # 5 minutes max + self._jwt_refresh_timer = None + self._shutdown_requested = False + self.transport = broker.get('transport', 'websockets') - Args: - record: The record dictionary to publish - record_type: Type of record (packet, advert, noise_floor, etc.) - """ - if not self.client: - return - - 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 - ) - if not packet_record: - logger.debug("Skipping MQTT publish: packet missing required data for PacketRecord") - return - - payload = packet_record.to_dict() - logger.debug("Publishing packet using PacketRecord format") + self.use_jwt_auth = broker.get('use_jwt_auth', False) + self.username = broker.get('username', None) + self.password = broker.get('password', None) + + self.format=broker.get("format", "letsmesh") + self.tls=broker.get("tls", { + "enabled": False, + "insecure": False, + }) + + client_id = f"meshcore_{self.public_key}_{broker['host']}_{self.format}" + self.client = mqtt.Client(client_id=client_id, transport=self.transport) + self.client.on_connect = self._on_connect + self.client.on_disconnect = self._on_disconnect + + # If None, will be use defaults depending on the format value + self.base_topic=broker.get("base_topic", None) + + self.enabled = broker.get("enabled", False) + self.retain_status = broker.get("retain_status", False) + + self._tls_verified = False + + if self.base_topic is None: + if self.format == "mqtt": + self.base_topic = f"meshcore/repeater/{self.node_name}" + elif self.format == "letsmesh": + self.base_topic = f"meshcore/{self.iata_code}/{self.public_key}" else: - payload = {k: v for k, v in record.items() if v is not None} + logger.warning(f"Unknown broker format '{self.format}' for {self.broker['name']}, using default base topic") + self.base_topic = f"meshcore/{self.iata_code}/{self.public_key}" + + from pymc_core.protocol.utils import PAYLOAD_TYPES + + disallowed_types = broker.get("disallowed_packet_types", []) + type_name_map = {name: code for code, name in PAYLOAD_TYPES.items()} + + self.disallowed_types = [type_name_map.get(name.upper(), None) for name in disallowed_types] + self.disallowed_types = [val for val in self.disallowed_types if val is not None] # Filter out invalid names + + + def _generate_jwt(self) -> str: + """Generate MeshCore-style Ed25519 JWT token""" + now = datetime.now(UTC) + + header = {"alg": "Ed25519", "typ": "JWT"} + + payload = { + "publicKey": self.public_key.upper(), + "aud": self.broker["audience"], + "iat": int(now.timestamp()), + "exp": int((now + timedelta(minutes=self.jwt_expiry_minutes)).timestamp()), + } + + if "audience" in self.broker: + payload["aud"] = self.broker["audience"] + + # Only include email/owner for verified TLS connections + if self.tls and self.tls.get("enabled", False) and self._tls_verified and (self.email or self.owner): + payload["email"] = self.email + payload["owner"] = self.owner + else: + payload["email"] = "" + payload["owner"] = "" + + # Encode header and payload (compact JSON - no spaces) + header_b64 = b64url(json.dumps(header, separators=(",", ":")).encode()) + 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) + except Exception as e: + logger.error(f"JWT signing failed for {self.broker['name']}: {e}") + logger.error(f" - public_key: {self.public_key}") + logger.error(f" - signing_input length: {len(signing_input)}") + raise + + signature_hex = binascii.hexlify(signature).decode() + token = f"{header_b64}.{payload_b64}.{signature_hex}" + + logger.debug(f"JWT token generated for {self.broker['name']}: {token[:50]}...") + + return token + + def _on_connect(self, client, userdata, flags, rc): + """MQTT connection callback""" + if rc == 0: + logger.info(f"Connected to {self.broker['name']}") + self._running = True + self._reconnect_attempts = 0 # Reset counter on success + if self.use_jwt_auth: + self._schedule_jwt_refresh() # Schedule proactive JWT refresh + if self._on_connect_callback: + self._on_connect_callback(self.broker["name"]) + else: + error_msg = get_mqtt_error_message(rc, is_disconnect=False) + logger.error(f"Failed to connect to {self.broker['name']}: {error_msg}") + self._schedule_reconnect() + + def _on_disconnect(self, client, userdata, rc): + """MQTT disconnection callback""" + was_running = self._running + self._running = False + + if self._shutdown_requested: + logger.info(f"Clean disconnect from {self.broker['name']}") + if self._on_disconnect_callback: + self._on_disconnect_callback(self.broker["name"]) + return + + if rc != 0: # Unexpected disconnect + error_msg = get_mqtt_error_message(rc, is_disconnect=True) + logger.warning(f"Disconnected from {self.broker['name']} (rc={rc}): {error_msg}") + if was_running: # Only reconnect if we were intentionally connected + self._schedule_reconnect(reason=error_msg) + else: + logger.info(f"Clean disconnect from {self.broker['name']}") + + if self._on_disconnect_callback: + self._on_disconnect_callback(self.broker["name"]) + + def _schedule_reconnect(self, reason: str = "connection lost"): + """Schedule reconnection with exponential backoff""" + if self._shutdown_requested: + return + + 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) + self._reconnect_attempts += 1 + + logger.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""" + if self._shutdown_requested: + return + + try: + logger.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_credentials() + + # Reconnect and restart loop + self.client.connect(self.broker["host"], self.broker["port"], keepalive=60) + self.client.loop_start() + self._loop_running = True + except Exception as e: + logger.error(f"Reconnection failed for {self.broker['name']}: {e}") + self._schedule_reconnect() # Try again later + + def _set_credentials(self): + """Set credentials before connecting (CONNECT handshake only)""" + try: + if self.use_jwt_auth: + logger.debug(f"Generating JWT credentials for {self.broker['name']}...") + token = self._generate_jwt() + username = f"v1_{self.public_key}" + self.client.username_pw_set(username=username, password=token) + logger.debug(f"Credentials set for {self.broker['name']}") + logger.debug(f"Using username: {username}") + logger.debug(f"Public key: {self.public_key[:16]}...{self.public_key[-16:]}") + elif self.username and self.password: + logger.info(f"Using provided credentials for {self.broker['name']} (username: {self.username})") + self.client.username_pw_set(username=self.username, password=self.password) + else: + logger.info(f"No credentials set for {self.broker['name']} (JWT auth disabled and no username/password provided)") - message = json.dumps(payload, default=str) - self.client.publish(topic, message, qos=0, retain=False) - logger.debug(f"Published to {topic}") + self._connect_time = datetime.now(UTC) except Exception as e: - logger.error(f"Failed to publish to MQTT: {e}") + logger.error(f"Failed to set JWT credentials for {self.broker['name']}: {e}") + raise - def close(self): - if self.client: - self.client.loop_stop() - self.client.disconnect() - logger.info("MQTT client disconnected") \ No newline at end of file + def connect(self): + """Establish connection to broker""" + self._shutdown_requested = False + + # Conditional TLS setup + if self.enabled == False: + logger.info(f"Connection to {self.broker['name']} is disabled in configuration") + return + + if self.transport == "websockets": + if self.tls and self.tls.get("enabled", True): + import ssl + + self.client.tls_set(cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS_CLIENT) + self.client.tls_insecure_set(self.tls.get("insecure", False)) + self._tls_verified = True + protocol = "wss" + else: + protocol = "ws" + elif self.transport == "tcp": + protocol = "mqtt" + else: + raise ValueError(f"Invalid transport '{self.transport}' for {self.broker['name']}") + + # Set JWT credentials before CONNECT handshake + self._set_credentials() + + logger.info( + f"Connecting to {self.broker['name']} " + f"({protocol}://{self.broker['host']}:{self.broker['port']}) ..." + ) + + self.client.connect(self.broker["host"], self.broker["port"], keepalive=60) + self.client.loop_start() + self._loop_running = True + + def disconnect(self): + """Disconnect from broker""" + self._shutdown_requested = False + self._running = False + self._loop_running = False + + # Cancel any pending timers + if self._reconnect_timer: + self._reconnect_timer.cancel() + self._reconnect_timer = None + if self._jwt_refresh_timer: + self._jwt_refresh_timer.cancel() + self._jwt_refresh_timer = None + + self.client.loop_stop() + self.client.disconnect() + logger.info(f"Disconnected from {self.broker['name']}") + + def publish(self, subtopic: str, payload: str, retain: bool = False, qos: int = 0): + """Publish message to broker""" + + # Legacy MQTT config uses singular "packet" topic, while LetsMesh uses "packets". Handle this for compatibility. + if self.format == "mqtt" and subtopic == "packets": + subtopic = "packet" + + if(subtopic == "status"): # Override the status topic retain and qos settings based on broker configuration + retain = self.retain_status + qos = 1 if self.retain_status else 0 + + logger.debug(f"Publishing to topic '{self.base_topic}/{subtopic}' with payload: [{payload}]. Running={self._running}. Retain={retain}, QoS={qos}") + if self._running: + result = self.client.publish(f"{self.base_topic}/{subtopic}", payload, retain=retain, qos=qos) + return result + else: + logger.warning(f"Cannot publish to {self.broker['name']} - not connected") + return None + + def is_enabled(self) -> bool: + """Check if connection is enabled""" + return self.enabled + + 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() + + def should_reconnect_for_token_expiry(self) -> bool: + """Check if connection should be reconnected due to JWT expiry (at 80% of lifetime)""" + if not self._connect_time: + return False + elapsed = (datetime.now(UTC) - self._connect_time).total_seconds() + 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 + 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 + + logger.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)" + ) + 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 + + logger.info(f"JWT token expiring soon for {self.broker['name']}, refreshing...") + self._running = False + self._jwt_refresh_timer = None + + self._schedule_reconnect(reason="JWT token expiry") + self.client.disconnect() + + +# ==================================================================== +# MeshCore → MQTT Publisher +# ==================================================================== +class MeshCoreToMqttPusher: + + def __init__( + self, + local_identity, + config: dict, + jwt_expiry_minutes: int = 10, + stats_provider: Optional[Callable[[], dict]] = None, + ): + # 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 + + node_info = get_node_info(config) + + self.iata_code = node_info["iata_code"] + self.email = node_info.get("email", "") + self.owner = node_info.get("owner", "") + self.status_interval = node_info["status_interval"] + self.node_name = node_info["node_name"] + self.local_identity = local_identity + self.public_key = public_key + self.jwt_expiry_minutes = jwt_expiry_minutes + self.app_version = __version__ + self.radio_config = node_info["radio_config"] + self.stats_provider = stats_provider + self._status_task = None + self._running = False + self._shutdown_requested = False + self._lock = threading.Lock() + self._connect_timers: List[threading.Timer] = [] + + # Initialize brokers list + mqtt_brokers_config = config.get("mqtt_brokers", {}) + letsmesh_config = config.get("letsmesh", {}) + mqtt_config = config.get("mqtt", {}) + + brokers = [] + if mqtt_brokers_config: + # Pull in brokers from mqtt_brokers config + brokers.extend(mqtt_brokers_config.get("brokers", [])) + + if letsmesh_config or mqtt_config: + logger.warning("Multiple MQTT broker configurations found (mqtt_brokers, letsmesh, mqtt). Only mqtt_brokers will be used") + + else: + if mqtt_config: + imported_mqtt_config = self.convert_mqtt_to_broker_config(mqtt_config) + brokers.append(imported_mqtt_config) + + if letsmesh_config: + imported_letsmesh_configs = self.convert_letsmesh_to_broker_config(letsmesh_config) + brokers.extend(imported_letsmesh_configs) + + self.brokers = [] + if brokers: + for broker_config in brokers: + if all(k in broker_config for k in ["name", "host", "port", "enabled"]): + self.brokers.append(broker_config) + logger.info(f"Added broker: {broker_config['name']}") + else: + logger.warning(f"Skipping invalid broker config: {broker_config}") + + + + # Create broker connections + self.connections: List[_BrokerConnection] = [] + for idx, broker in enumerate(self.brokers): + conn = _BrokerConnection( + broker=broker, + local_identity=self.local_identity, + public_key=self.public_key, + iata_code=self.iata_code, + jwt_expiry_minutes=self.jwt_expiry_minutes, + email=self.email, + owner=self.owner, + broker_index=idx, + node_name=self.node_name, + on_connect_callback=self._on_broker_connected, + on_disconnect_callback=self._on_broker_disconnected, + ) + self.connections.append(conn) + + logger.info(f"Initialized with {len(self.connections)} broker connection(s)") + + # Convert legacy configration to new one + if not mqtt_brokers_config: + logger.info("Storing mqtt_brokers config from legacy mqtt/letsmesh configuration") + mqtt_brokers_config = { + "iata_code": self.iata_code, + "status_interval": self.status_interval, + "owner": self.owner, + "email": self.email, + "brokers": brokers + } + + # Update the configuration with the new configuration + config["mqtt_brokers"] = mqtt_brokers_config + + def convert_mqtt_to_broker_config(self, mqtt_cfg: dict) -> dict: + """Convert legacy MQTT config format to internal broker config format""" + logger.info(f"Imported MQTT broker from 'mqtt' config: {mqtt_cfg['broker']}") + transport = "websockets" if mqtt_cfg.get("use_websockets", False) else "tcp" + return { + "enabled": mqtt_cfg.get("enabled", False), + "name": mqtt_cfg["broker"], + "host": mqtt_cfg["broker"], + "port": mqtt_cfg["port"], + "use_jwt_auth": False, # The legacy MQTT config does not support JWT auth, so we set this to False + "username": mqtt_cfg.get("username", None), + "password": mqtt_cfg.get("password", None), + "transport": transport, + "tls": mqtt_cfg.get("tls", None), + "format": "mqtt", + "base_topic": mqtt_cfg.get("base_topic", None), + } + + def convert_letsmesh_to_broker_config(self, letsmesh_cfg: dict) -> List[dict]: + """Convert LetsMesh config format to internal broker config format""" + + brokers = [] + + enabled = letsmesh_cfg.get("enabled", False) + + idx = letsmesh_cfg.get("broker_index", None) + if idx == 0 or idx == 1: + broker_info = LETSMESH_BROKERS[idx] + logger.info(f"Imported LetsMesh broker from 'letsmesh' config: {broker_info['name']}") + brokers.append({ + "enabled": enabled, + "name": broker_info["name"], + "host": broker_info["host"], + "port": broker_info["port"], + "audience": broker_info["audience"], + "use_jwt_auth": True, + "transport": "websockets", + "format": "letsmesh", + "base_topic": None, + "retain_status": False, + "tls": { + "enabled": True, + "insecure": False, + }, + }) + elif idx < 0: + if idx == -1: + brokers.extend({ + "enabled": enabled, + "name": broker_info["name"], + "host": broker_info["host"], + "port": broker_info["port"], + "audience": broker_info["audience"], + "use_jwt_auth": True, + "transport": "websockets", + "format": "letsmesh", + "base_topic": None, + "retain_status": False, + "tls": { + "enabled": True, + "insecure": False, + }, + } for broker_info in LETSMESH_BROKERS) + + additional = letsmesh_cfg.get("additional_brokers", []) + for add_broker in additional: + logger.info(f"Imported additional LetsMesh broker from 'letsmesh' config: {add_broker['name']}") + brokers.append({ + "enabled": enabled, + "name": add_broker["name"], + "host": add_broker["host"], + "port": add_broker["port"], + "audience": add_broker["audience"], + "use_jwt_auth": True, + "transport": "websockets", + "use_jwt_auth": add_broker.get("use_jwt_auth", True), + "transport": add_broker.get("transport", "websockets"), + "format": "letsmesh", + "base_topic": None, + "retain_status": False, + "tls": { + "enabled": add_broker.get("tls", {}).get("enabled", True), + "insecure": add_broker.get("tls", {}).get("insecure", False), + } + }) + + + return brokers + + def _on_broker_connected(self, broker_name: str): + """Callback when a broker connects""" + if self._shutdown_requested: + return + + # Publish initial status on first connection + if not self._status_task and self.status_interval > 0: + self._running = True + logger.info(f"Publishing initial status for {broker_name}...") + self.publish_status( + state="online", origin=self.node_name, radio_config=self.radio_config + ) + # Start heartbeat thread + self._status_task = threading.Thread(target=self._status_heartbeat_loop, daemon=True) + self._status_task.start() + logger.info(f"Started status heartbeat (interval: {self.status_interval}s)") + + def _on_broker_disconnected(self, broker_name: str): + """Callback when a broker disconnects""" + # 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: + logger.warning("All broker connections lost with no pending reconnects") + elif all_down: + logger.info("All brokers temporarily disconnected, reconnects pending") + + def connect(self): + """Establish connections to all configured brokers""" + self._shutdown_requested = False + self._connect_timers = [] + + for idx, conn in enumerate(self.connections): + try: + if idx == 0: + # Connect first broker immediately + conn.connect() + else: + # Stagger additional brokers using background timers + delay = idx * 30 + logger.info(f"Staggering connection to {conn.broker['name']} by {delay}s") + timer = threading.Timer(delay, lambda c=conn: self._delayed_connect(c)) + timer.daemon = True + timer.start() + self._connect_timers.append(timer) + except Exception as e: + logger.error(f"Failed to connect to {conn.broker['name']}: {e}") + + def _delayed_connect(self, conn): + """Connect a broker after a delay (called by timer)""" + if self._shutdown_requested: + return + + try: + conn.connect() + except Exception as e: + logger.error(f"Failed to connect to {conn.broker['name']}: {e}") + + def disconnect(self): + """Disconnect from all brokers""" + self._shutdown_requested = True + + # Cancel any delayed connect timers first. + for timer in self._connect_timers: + try: + timer.cancel() + except Exception: + pass + self._connect_timers = [] + + # Stop the heartbeat loop + self._running = False + + # Publish offline status before disconnecting + try: + self.publish_status(state="offline", origin=self.node_name, radio_config=self.radio_config) + except Exception: + pass + + # Disconnect all brokers + for conn in self.connections: + try: + conn.disconnect() + except Exception as e: + logger.error(f"Error disconnecting from {conn.broker['name']}: {e}") + + self._status_task = None + logger.info("Disconnected from all brokers") + + def _status_heartbeat_loop(self): + """Background thread that publishes periodic status updates""" + import time + + while self._running: + try: + # Publish status (JWT refresh now handled by individual broker timers) + self.publish_status( + state="online", origin=self.node_name, radio_config=self.radio_config + ) + logger.debug(f"Status heartbeat sent (next in {self.status_interval}s)") + + time.sleep(self.status_interval) + except Exception as e: + logger.error(f"Status heartbeat error: {e}") + time.sleep(self.status_interval) + + # ---------------------------------------------------------------- + # Packet helpers + # ---------------------------------------------------------------- + def _process_packet(self, pkt: dict) -> dict: + return {"timestamp": datetime.now(UTC).isoformat(), "origin_id": self.public_key, **pkt} + + def publish_packet(self, pkt: dict, subtopic="packets", retain=False): + return self.publish(subtopic, self._process_packet(pkt), retain) + + def publish_raw_data(self, raw_hex: str, subtopic="raw", retain=False): + pkt = {"type": "raw", "data": raw_hex, "bytes": len(raw_hex) // 2} + return self.publish_packet(pkt, subtopic, retain) + + def publish_status( + self, + state: str = "online", + location: Optional[dict] = None, + extra_stats: Optional[dict] = None, + origin: Optional[str] = None, + radio_config: Optional[str] = None, + ): + """ + Publish device status/heartbeat message + + Args: + state: Device state (online/offline) + location: Optional dict with latitude/longitude + extra_stats: Optional additional statistics to include + origin: Node name/description + radio_config: Radio configuration string (freq,bw,sf,cr) + """ + # Get live stats from provider if available + if self.stats_provider: + live_stats = self.stats_provider() + else: + live_stats = {"uptime_secs": 0, "packets_sent": 0, "packets_received": 0} + + status = { + "status": state, + "timestamp": datetime.now(UTC).isoformat(), + "origin": origin or self.node_name, + "origin_id": self.public_key, + "model": "PyMC-Repeater", + "firmware_version": self.app_version, + "radio": radio_config or self.radio_config, + "client_version": f"pyMC_repeater/{self.app_version}", + "stats": {**live_stats, "errors": 0, "queue_len": 0, **(extra_stats or {})}, + } + + if location: + status["location"] = location + + return self.publish("status", status, retain=True, qos=1) + + def publish(self, subtopic: str, payload: dict, retain: bool = False, qos: int = 0): + """Publish message to all connected brokers""" + message = json.dumps(payload) + + # _BrokerConnection now handles topic prefixing, so we only log the subtopic here + logger.debug(f"Publishing to topic '{subtopic}' with payload: {message}") + + packet_type = payload.get("type") + + results = [] + with self._lock: + for conn in self.connections: + if conn.enabled and conn.is_connected(): + if packet_type in conn.disallowed_types: + logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)") + continue + result = conn.publish(subtopic, message, retain=retain, qos=qos) + results.append((conn.broker["name"], result)) + logger.debug(f"Published to {conn.broker['name']} -- {subtopic}") + + if not results: + logger.warning(f"No active broker connections for publishing to {subtopic}") + + return results + + + def publish_mqtt(self, payload: dict, subtopic: str, retain: bool = False, qos: int = 0): + """Publish message to all connected brokers""" + message = json.dumps(payload) + + # _BrokerConnection now handles topic prefixing, so we only log the subtopic here + logger.debug(f"Publishing to topic '{subtopic}' with payload: {message}") + + results = [] + with self._lock: + for conn in self.connections: + if conn.enabled and conn.is_connected(): + if conn.format != "mqtt": + logger.debug(f"Skipped publishing to {conn.broker['name']} (wrong format)") + results.append((conn.broker["name"], None)) # Indicate skipped due to format mismatch + continue + result = conn.publish(subtopic, message, retain=retain, qos=qos) + results.append((conn.broker["name"], result)) + logger.debug(f"Published to {conn.broker['name']} -- {subtopic}") + + if not results: + logger.warning(f"No active broker connections for publishing to {subtopic}") + + return results + + +# ==================================================================== +# 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 + """ + # Fallback to manual mappings - Extended with MQTT v5 codes + connect_errors = { + 0: "Connection accepted", + 1: "Incorrect protocol version", + 2: "Invalid client identifier", + 3: "Server unavailable", + 4: "Bad username or password (JWT invalid)", + 5: "Not authorized (JWT signature/format invalid)", + # MQTT v5 codes + 128: "Unspecified error", + 129: "Malformed packet", + 130: "Protocol error", + 131: "Implementation specific error", + 132: "Unsupported protocol version", + 133: "Client identifier not valid", + 134: "Bad username or password", + 135: "Not authorized", + 136: "Server unavailable", + 137: "Server busy", + 138: "Banned", + 140: "Bad authentication method", + 144: "Topic name invalid", + 149: "Packet too large", + 151: "Quota exceeded", + 153: "Payload format invalid", + 154: "Retain not supported", + 155: "QoS not supported", + 156: "Use another server", + 157: "Server moved", + 159: "Connection rate exceeded", + } + + disconnect_errors = { + 0: "Normal disconnect", + 1: "Unacceptable protocol version", + 2: "Identifier rejected", + 3: "Server unavailable", + 4: "Bad username or password", + 5: "Not authorized", + 7: "Connection lost / network error", + 16: "Connection lost / protocol error", + 17: "Client timeout", + # MQTT v5 codes + 4: "Disconnect with Will message", + 128: "Unspecified error", + 129: "Malformed packet", + 130: "Protocol error", + 131: "Implementation specific error", + 135: "Not authorized", + 137: "Server busy", + 139: "Server shutting down", + 141: "Keep alive timeout", + 142: "Session taken over", + 143: "Topic filter invalid", + 144: "Topic name invalid", + 147: "Receive maximum exceeded", + 148: "Topic alias invalid", + 149: "Packet too large", + 150: "Message rate too high", + 151: "Quota exceeded", + 152: "Administrative action", + 153: "Payload format invalid", + 154: "Retain not supported", + 155: "QoS not supported", + 156: "Use another server", + 157: "Server moved", + 158: "Shared subscriptions not supported", + 159: "Connection rate exceeded", + 160: "Maximum connect time", + 161: "Subscription identifiers not supported", + 162: "Wildcard subscriptions not supported", + } + + if HAS_REASON_CODES: + try: + + reason = ReasonCode(mqtt.CONNACK if not is_disconnect else mqtt.DISCONNECT, identifier=rc) + name = reason.getName() if hasattr(reason, 'getName') else str(reason) + return f"{name} (code {rc})" + except Exception as e: + + _fallback = (disconnect_errors if is_disconnect else connect_errors).get(rc) + if _fallback is None: + logger.debug(f"Could not decode reason code {rc}: {e}") + + 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/rrdtool_handler.py b/repeater/data_acquisition/rrdtool_handler.py index fa0753c..ca075aa 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 @@ -18,22 +19,31 @@ class RRDToolHandler: self.rrd_path = self.storage_dir / "metrics.rrd" self.available = RRDTOOL_AVAILABLE self._init_rrd() + # Timestamp of the last successful rrdtool.update() call (unix seconds, + # aligned to the 60-second RRD step). Used to skip writes whose period + # has already been committed — no rrdtool.info() call needed. + self._last_rrd_update: int = 0 + # Read-side cache: rrdtool.fetch() returns 24 h of data and is a + # blocking disk read. Cache the result for 60 s — matching the RRD + # step size — so repeated dashboard refreshes don't hammer the SD card. + self._get_data_cache: tuple = (0.0, None) # (fetched_at, result) def _init_rrd(self): 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 +52,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,121 +69,157 @@ 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): + """Write packet metrics to RRD, throttled to once per 60-second step. + + RRD enforces a 60-second minimum step between updates. We track the + last written timestamp ourselves — no rrdtool.info() call needed, which + previously allocated thousands of Python objects per call. + """ 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)) - if timestamp <= last_update: - return - except Exception as e: - logger.debug(f"Failed to get RRD info for packet update: {e}") - + + # Skip if this packet falls in the same 60-second period we already wrote. + if timestamp <= self._last_rrd_update: + return + + # Build update string from cumulative counts 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))) - - basic_values = f"{timestamp}:{rx_total}:{tx_total}:{drop_total}:" \ - f"{record.get('rssi', 'U')}:{record.get('snr', 'U')}:" \ - f"{record.get('length', 'U')}:{record.get('score', 'U')}:" \ - 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) - + self._last_rrd_update = timestamp + 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 - + + # Serve from cache if result is still fresh. RRD step is 60 s, so + # anything newer than that is guaranteed to be identical to a live fetch. + # Only the default (full 24-hour, no explicit bounds) call is cached — + # explicit start/end requests always bypass the cache. + now = time.time() + use_cache = start_time is None and end_time is None + if use_cache: + cache_fetched_at, cache_result = self._get_data_cache + if now - cache_fetched_at < 60.0 and cache_result is not None: + return cache_result + try: if end_time is None: - end_time = int(time.time()) + end_time = int(now) 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 + + # Populate read cache for default (unconstrained) calls only. + if use_cache: + self._get_data_cache = (now, result) + return result - + except Exception as e: logger.error(f"Failed to get RRD data: {e}") return None @@ -183,65 +228,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 (MULTIPART)", + "type_11": "Control (CONTROL)", + "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 422067b..415abd0 100644 --- a/repeater/data_acquisition/sqlite_handler.py +++ b/repeater/data_acquisition/sqlite_handler.py @@ -1,11 +1,12 @@ +import base64 import json import logging -import sqlite3 -import time import secrets -import base64 +import sqlite3 +import threading +import time from pathlib import Path -from typing import Optional, Dict, Any, List +from typing import Any, Dict, List, Optional logger = logging.getLogger("SQLiteHandler") @@ -14,13 +15,66 @@ class SQLiteHandler: def __init__(self, storage_dir: Path): self.storage_dir = storage_dir self.sqlite_path = self.storage_dir / "repeater.db" + self._api_token_last_used_updates = {} + self._api_token_last_used_interval_sec = 300 + self._hot_cache_ttl_sec = 60 + self._packet_stats_cache = {} + self._neighbors_cache = {"timestamp": 0.0, "value": None} + # Thread-local storage for persistent SQLite connections. + # Opening a new connection on every DB call is expensive on SD-card + # storage: each sqlite3.connect() call triggers file-system operations + # and each subsequent PRAGMA runs as a round-trip. Thread-local keeps + # one long-lived connection per thread (typically one for the write + # executor and one for the event-loop / HTTP threads), eliminating + # repeated setup overhead while maintaining correct isolation. + self._local = threading.local() self._init_database() self._run_migrations() + def _connect(self) -> sqlite3.Connection: + """Return a persistent thread-local SQLite connection. + + The first call from a given thread opens the connection and configures + it once. Subsequent calls from the same thread return the cached + connection, avoiding per-call connection overhead and repeated PRAGMA + round-trips. + + WAL (Write-Ahead Logging) mode: + Default journal mode (DELETE) takes an exclusive lock for every write, + blocking all readers. WAL allows one writer and multiple readers to + operate concurrently — critical on SD-card storage where a single + write can take 5–20 ms. + + synchronous=NORMAL: + Default FULL flushes WAL frames to disk after every transaction. + NORMAL flushes only at WAL checkpoints — safe (no data loss on power + failure beyond the current transaction) and significantly faster on + SD cards, which have slow fsync. + + busy_timeout=5000: + Under concurrent access SQLite would immediately raise + 'database is locked'. 5 s of automatic retry eliminates transient + contention errors when the write executor and the HTTP thread + briefly compete for the WAL write lock. + """ + conn = getattr(self._local, "conn", None) + if conn is None: + conn = sqlite3.connect(str(self.sqlite_path)) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + conn.execute("PRAGMA busy_timeout=5000") + self._local.conn = conn + return conn + + def _invalidate_hot_caches(self) -> None: + self._packet_stats_cache.clear() + self._neighbors_cache = {"timestamp": 0.0, "value": None} + def _init_database(self): try: - with sqlite3.connect(self.sqlite_path) as conn: - conn.execute(""" + with self._connect() as conn: + conn.execute( + """ CREATE TABLE IF NOT EXISTS packets ( id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp REAL NOT NULL, @@ -46,9 +100,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, @@ -67,17 +123,31 @@ 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 crc_errors ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + timestamp REAL NOT NULL, + count INTEGER NOT NULL DEFAULT 1 + ) + """ + ) + + conn.execute( + """ CREATE TABLE IF NOT EXISTS transport_keys ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -89,68 +159,493 @@ class SQLiteHandler: updated_at REAL NOT NULL, FOREIGN KEY (parent_id) REFERENCES transport_keys(id) ) - """) - - conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_timestamp ON packets(timestamp)") + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + 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_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_crc_errors_timestamp ON crc_errors(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( + """ + CREATE TABLE IF NOT EXISTS room_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_hash TEXT NOT NULL, + author_pubkey TEXT NOT NULL, + post_timestamp REAL NOT NULL, + sender_timestamp REAL, + message_text TEXT NOT NULL, + txt_type INTEGER NOT NULL, + created_at REAL NOT NULL + ) + """ + ) + + conn.execute( + """ + CREATE TABLE IF NOT EXISTS room_client_sync ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + room_hash TEXT NOT NULL, + client_pubkey TEXT NOT NULL, + sync_since REAL NOT NULL DEFAULT 0, + pending_ack_crc INTEGER DEFAULT 0, + push_post_timestamp REAL DEFAULT 0, + ack_timeout_time REAL DEFAULT 0, + push_failures INTEGER DEFAULT 0, + last_activity REAL NOT NULL, + 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.commit() logger.info(f"SQLite database initialized: {self.sqlite_path}") - + except Exception as e: logger.error(f"Failed to initialize SQLite: {e}") def _run_migrations(self): """Run database migrations""" try: - with sqlite3.connect(self.sqlite_path) as conn: + with self._connect() 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,), + ).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" + ) + 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" + ) + 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()), + ) + 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,), + ).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( + """ + CREATE TABLE api_tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + 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()), + ) + 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, + 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_messages_hash_packet ON companion_messages(companion_hash, packet_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") + + # 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") + + # 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") + + # 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") + + # Migration 8: UNIQUE index on companion_messages for dedup by + # (companion_hash, packet_hash). Enables INSERT OR IGNORE + # deduplication in companion_push_message, replacing the + # Python-level SELECT + INSERT round-trip. + migration_name = "companion_messages_packet_hash_unique" + existing = conn.execute( + "SELECT migration_name FROM migrations WHERE migration_name = ?", + (migration_name,), + ).fetchone() + if not existing: + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS idx_companion_messages_dedup + ON companion_messages(companion_hash, packet_hash) + WHERE packet_hash IS NOT NULL + """ + ) + conn.execute( + "INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)", + (migration_name, time.time()), + ) + logger.info(f"Migration '{migration_name}' applied successfully") + + # Migration 9: Deduplicate adverts and enforce UNIQUE on pubkey. + # Without this index store_advert's ON CONFLICT clause cannot + # function and each advert inserts a new row instead of updating + # the existing one, causing unbounded table growth on busy meshes. + migration_name = "adverts_unique_pubkey" + existing = conn.execute( + "SELECT migration_name FROM migrations WHERE migration_name = ?", + (migration_name,), + ).fetchone() + if not existing: + # Keep only the most recently seen row per pubkey + conn.execute( + """ + DELETE FROM adverts WHERE id NOT IN ( + SELECT MAX(id) FROM adverts GROUP BY pubkey + ) + """ + ) + conn.execute("DROP INDEX IF EXISTS idx_adverts_pubkey") + conn.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_adverts_pubkey ON adverts(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: logger.error(f"Failed to run migrations: {e}") + # API Token methods + def create_api_token(self, name: str, token_hash: str) -> int: + """Create a new API token entry""" + try: + with self._connect() as conn: + cursor = conn.execute( + "INSERT INTO api_tokens (name, token_hash, created_at) VALUES (?, ?, ?)", + (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 self._connect() as conn: + cursor = conn.execute( + "SELECT id, name, created_at, last_used FROM api_tokens WHERE token_hash = ?", + (token_hash,), + ) + row = cursor.fetchone() + + if row: + token_id, name, created_at, _last_used = row + now = time.time() + + # Throttle last_used updates to reduce write-lock contention. + last_update = self._api_token_last_used_updates.get(token_id, 0.0) + if now - last_update >= self._api_token_last_used_interval_sec: + conn.execute( + "UPDATE api_tokens SET last_used = ? WHERE id = ?", (now, token_id) + ) + conn.commit() + self._api_token_last_used_updates[token_id] = now + + 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: + with self._connect() as conn: + cursor = conn.execute("DELETE FROM api_tokens WHERE id = ?", (token_id,)) + return cursor.rowcount > 0 + 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: + with self._connect() as conn: + 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]} + ) + return tokens + except Exception as e: + logger.error(f"Failed to list API tokens: {e}") + return [] + def store_packet(self, record: dict): try: - with sqlite3.connect(self.sqlite_path) as conn: + with self._connect() as conn: orig_path = record.get("original_path") fwd_path = record.get("forwarded_path") try: @@ -162,123 +657,220 @@ 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, - tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet - ) 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") - )) - + 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))), + ), + ) + self._invalidate_hot_caches() + except Exception as e: logger.error(f"Failed to store packet in SQLite: {e}") def store_advert(self, record: dict): try: - with sqlite3.connect(self.sqlite_path) as conn: + with self._connect() as conn: + conn.row_factory = sqlite3.Row existing = conn.execute( - "SELECT pubkey, first_seen, advert_count FROM adverts WHERE pubkey = ? ORDER BY last_seen DESC LIMIT 1", - (record.get("pubkey", ""),) + "SELECT pubkey, first_seen, advert_count, zero_hop, rssi, snr FROM adverts WHERE pubkey = ? ORDER BY last_seen DESC LIMIT 1", + (record.get("pubkey", ""),), ).fetchone() - + current_time = record.get("timestamp", time.time()) - + if existing: - conn.execute(""" - UPDATE adverts + # 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) + # - If both are multi-hop: signal measurements are not applicable + if incoming_zero_hop: + rssi_to_store = record.get("rssi") + snr_to_store = record.get("snr") + zero_hop_to_store = True + elif existing_zero_hop: + rssi_to_store = existing["rssi"] + snr_to_store = existing["snr"] + zero_hop_to_store = True + else: + rssi_to_store = None + snr_to_store = None + zero_hop_to_store = False + + 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, - record.get("rssi"), - record.get("snr"), - record.get("zero_hop", False), - 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), + ), + ) + + self._invalidate_hot_caches() + 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(""" + with self._connect() as conn: + 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: + def store_crc_errors(self, record: dict): + """Store a CRC error batch (delta count since last poll).""" + try: + with self._connect() as conn: + conn.execute(""" + INSERT INTO crc_errors (timestamp, count) + VALUES (?, ?) + """, ( + record.get("timestamp", time.time()), + record.get("count", 1) + )) + except Exception as e: + logger.error(f"Failed to store CRC errors in SQLite: {e}") + + def get_crc_error_count(self, hours: int = 24) -> int: + """Return total CRC errors within the given time window.""" try: cutoff = time.time() - (hours * 3600) - - with sqlite3.connect(self.sqlite_path) as conn: + with self._connect() as conn: + row = conn.execute( + "SELECT COALESCE(SUM(count), 0) FROM crc_errors WHERE timestamp > ?", + (cutoff,) + ).fetchone() + return row[0] if row else 0 + except Exception as e: + logger.error(f"Failed to get CRC error count: {e}") + return 0 + + def get_crc_error_history(self, hours: int = 24, limit: int = None) -> list: + """Return CRC error records within the given time window (chronological).""" + try: + cutoff = time.time() - (hours * 3600) + if limit is None: + limit = 1000 + with self._connect() as conn: conn.row_factory = sqlite3.Row - - stats = conn.execute(""" - SELECT + query = """ + SELECT timestamp, count + FROM crc_errors + WHERE timestamp > ? + ORDER BY timestamp DESC + LIMIT ? + """ + rows = conn.execute(query, (cutoff, int(limit))).fetchall() + return [{"timestamp": r["timestamp"], "count": r["count"]} for r in reversed(rows)] + except Exception as e: + logger.error(f"Failed to get CRC error history: {e}") + return [] + + def get_packet_stats(self, hours: int = 24) -> dict: + try: + now = time.time() + cached = self._packet_stats_cache.get(hours) + if cached and (now - cached["timestamp"]) < self._hot_cache_ttl_sec: + return cached["value"] + + cutoff = now - (hours * 3600) + + with self._connect() as conn: + conn.row_factory = sqlite3.Row + + 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, @@ -287,27 +879,35 @@ 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() - - return { + """, + (cutoff,), + ).fetchall() + + result = { "total_packets": stats["total_packets"], "transmitted_packets": stats["transmitted_packets"], "dropped_packets": stats["dropped_packets"], @@ -317,106 +917,218 @@ 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 + ], } - + + self._packet_stats_cache[hours] = {"timestamp": now, "value": result} + return result + except Exception as e: logger.error(f"Failed to get packet stats: {e}") return {} def get_recent_packets(self, limit: int = 100) -> list: try: - with sqlite3.connect(self.sqlite_path) as conn: + with self._connect() 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, - tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet - FROM packets + transport_codes, payload, payload_length, + tx_delay_ms, packet_hash, original_path, forwarded_path, + lbt_attempts, lbt_channel_busy + 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) -> 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: + with self._connect() 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, - tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet + transport_codes, payload, payload_length, + tx_delay_ms, packet_hash, original_path, forwarded_path, + lbt_attempts, 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 ?" + + 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 [] + def get_airtime_data( + self, + start_timestamp: Optional[float] = None, + end_timestamp: Optional[float] = None, + limit: int = 50000, + ) -> list: + """Lightweight query returning only columns needed for airtime charting.""" + try: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + where_clauses = [] + params: list = [] + 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) + query = "SELECT timestamp, length, payload_length, transmitted FROM packets" + if where_clauses: + query += " WHERE " + " AND ".join(where_clauses) + query += " ORDER BY timestamp DESC LIMIT ?" + params.append(limit) + return [dict(row) for row in conn.execute(query, params).fetchall()] + except Exception as e: + logger.error(f"Failed to get airtime data: {e}") + return [] + + def get_airtime_buckets( + self, + start_timestamp: float, + end_timestamp: float, + bucket_seconds: int = 60, + sf: int = 9, + bw_hz: int = 62500, + cr: int = 5, + preamble: int = 17, + ) -> list: + """Return pre-aggregated airtime buckets for chart rendering. + + Applies the Semtech LoRa airtime formula server-side and groups results + into time buckets, drastically reducing response size vs raw packet rows. + """ + import math + + bw_khz = bw_hz / 1000 + t_sym = (2**sf) / bw_khz # ms per symbol + t_preamble = (preamble + 4.25) * t_sym + de = 1 if sf >= 11 and bw_hz <= 125000 else 0 + + def _airtime_ms(length_bytes: int) -> float: + length_bytes = max(length_bytes or 32, 1) + numerator = max(8 * length_bytes - 4 * sf + 28 + 16, 0) # CRC=1, H=0 + denominator = 4 * (sf - 2 * de) + n_payload = 8 + math.ceil(numerator / denominator) * cr + return t_preamble + n_payload * t_sym + + try: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + rows = conn.execute( + "SELECT timestamp, length, transmitted FROM packets " + "WHERE timestamp >= ? AND timestamp <= ? ORDER BY timestamp ASC", + (start_timestamp, end_timestamp), + ).fetchall() + + buckets: dict = {} + rx_total = 0 + tx_total = 0 + for row in rows: + bucket_ts = int(row["timestamp"] / bucket_seconds) * bucket_seconds + ms = _airtime_ms(row["length"]) + if bucket_ts not in buckets: + buckets[bucket_ts] = {"timestamp": bucket_ts, "rx_ms": 0.0, "tx_ms": 0.0, "rx_count": 0, "tx_count": 0} + if row["transmitted"]: + buckets[bucket_ts]["tx_ms"] += ms + buckets[bucket_ts]["tx_count"] += 1 + tx_total += 1 + else: + buckets[bucket_ts]["rx_ms"] += ms + buckets[bucket_ts]["rx_count"] += 1 + rx_total += 1 + + return { + "buckets": sorted(buckets.values(), key=lambda x: x["timestamp"]), + "bucket_seconds": bucket_seconds, + "rx_total": rx_total, + "tx_total": tx_total, + } + except Exception as e: + logger.error(f"Failed to get airtime buckets: {e}") + return {"buckets": [], "bucket_seconds": bucket_seconds, "rx_total": 0, "tx_total": 0} + def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]: try: - with sqlite3.connect(self.sqlite_path) as conn: + with self._connect() 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, - tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet - FROM packets + 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 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 @@ -424,47 +1136,88 @@ 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)' + + # 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", } - - for packet_type in range(16): - count = conn.execute( - "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}') - if count > 0: + 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)", + 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 (MULTIPART)", + 11: "Control (CONTROL)", + 12: "Reserved Type 12", + 13: "Reserved Type 13", + 14: "Reserved Type 14", + 15: "Custom Packet (RAW_CUSTOM)", + } + + with self._connect() as conn: + conn.row_factory = sqlite3.Row + + type_rows = conn.execute( + """ + SELECT type, COUNT(*) as count + FROM packets + WHERE timestamp > ? + GROUP BY type + """, + (cutoff,), + ).fetchall() + + type_counts = {} + other_count = 0 + for row in type_rows: + pkt_type = int(row["type"]) + count = int(row["count"]) + if pkt_type <= 15: + type_name = packet_type_names.get(pkt_type, f"Type {pkt_type}") type_counts[type_name] = count - - other_count = conn.execute( - "SELECT COUNT(*) FROM packets WHERE type > 15 AND timestamp > ?", - (cutoff,) - ).fetchone()[0] + else: + other_count += count + 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"} @@ -473,65 +1226,75 @@ 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' - } - for route_type in range(4): - count = conn.execute( - "SELECT COUNT(*) FROM packets WHERE route = ? AND timestamp > ?", - (route_type, cutoff) - ).fetchone()[0] - - route_name = route_names.get(route_type, f'Route {route_type}') - if count > 0: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + + route_rows = conn.execute( + """ + SELECT route, COUNT(*) as count + FROM packets + WHERE timestamp > ? + GROUP BY route + """, + (cutoff,), + ).fetchall() + + route_counts = {} + route_names = {0: "Transport Flood", 1: "Flood", 2: "Direct", 3: "Transport Direct"} + other_count = 0 + + for row in route_rows: + route_type = int(row["route"]) + count = int(row["count"]) + if route_type <= 3: + route_name = route_names.get(route_type, f"Route {route_type}") 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,) - ).fetchone()[0] + else: + other_count += count + 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"} def get_neighbors(self) -> dict: try: - with sqlite3.connect(self.sqlite_path) as conn: + now = time.time() + cached = self._neighbors_cache.get("value") + cached_ts = float(self._neighbors_cache.get("timestamp", 0.0)) + if cached is not None and (now - cached_ts) < self._hot_cache_ttl_sec: + return cached + + with self._connect() 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 - FROM adverts a1 - WHERE last_seen = ( - SELECT MAX(last_seen) - FROM adverts a2 - WHERE a2.pubkey = a1.pubkey - ) + latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, zero_hop + FROM ( + SELECT + pubkey, node_name, is_repeater, route_type, contact_type, + latitude, longitude, first_seen, last_seen, rssi, snr, advert_count, zero_hop, + ROW_NUMBER() OVER (PARTITION BY pubkey ORDER BY last_seen DESC) AS rn + FROM adverts + ) latest + WHERE rn = 1 ORDER BY last_seen DESC - """).fetchall() - + """ + ).fetchall() + result = {} for row in neighbors: result[row["pubkey"]] = { @@ -546,31 +1309,44 @@ class SQLiteHandler: "rssi": row["rssi"], "snr": row["snr"], "advert_count": row["advert_count"], + "zero_hop": bool(row["zero_hop"]), } - + + self._neighbors_cache = {"timestamp": now, "value": result} return result - + except Exception as e: logger.error(f"Failed to get neighbors: {e}") return {} - def get_noise_floor_history(self, hours: int = 24) -> list: + 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: + + if limit is None: + limit = 1000 + + with self._connect() as conn: conn.row_factory = sqlite3.Row - - measurements = conn.execute(""" + + query = """ SELECT timestamp, noise_floor_dbm - FROM noise_floor + FROM noise_floor WHERE timestamp > ? - ORDER BY timestamp ASC - """, (cutoff,)).fetchall() - - return [{"timestamp": row["timestamp"], "noise_floor_dbm": row["noise_floor_dbm"]} - for row in measurements] - + ORDER BY timestamp DESC + LIMIT ? + """ + + measurements = conn.execute(query, (cutoff, int(limit))).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) + ] + + return result + except Exception as e: logger.error(f"Failed to get noise floor history: {e}") return [] @@ -578,113 +1354,241 @@ 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: + + with self._connect() 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 {} + def get_table_stats(self) -> dict: + """Get row counts, date ranges, and storage info for all tables.""" + try: + db_size = self.sqlite_path.stat().st_size if self.sqlite_path.exists() else 0 + + tables_with_timestamp = { + "packets": "timestamp", + "adverts": "timestamp", + "noise_floor": "timestamp", + "crc_errors": "timestamp", + "room_messages": "created_at", + "companion_messages": "created_at", + } + tables_without_timestamp = [ + "transport_keys", + "api_tokens", + "room_client_sync", + "companion_contacts", + "companion_channels", + "companion_prefs", + "migrations", + ] + + table_info = [] + with self._connect() as conn: + # Get actual tables present in the database + existing = { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } + + for table, ts_col in tables_with_timestamp.items(): + if table not in existing: + continue + row = conn.execute( + f"SELECT COUNT(*), MIN({ts_col}), MAX({ts_col}) FROM {table}" # noqa: S608 + ).fetchone() + count, oldest, newest = row[0], row[1], row[2] + table_info.append( + { + "name": table, + "row_count": count, + "oldest_timestamp": oldest, + "newest_timestamp": newest, + "has_timestamp": True, + } + ) + + for table in tables_without_timestamp: + if table not in existing: + continue + count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] # noqa: S608 + table_info.append( + { + "name": table, + "row_count": count, + "has_timestamp": False, + } + ) + + return {"database_size_bytes": db_size, "tables": table_info} + + except Exception as e: + logger.error(f"Failed to get table stats: {e}") + return {"database_size_bytes": 0, "tables": []} + + def purge_table(self, table_name: str) -> int: + """Delete all rows from a specific table. Returns rows deleted.""" + # Hardcoded allowlist — never allow arbitrary table names + PURGEABLE = { + "packets", + "adverts", + "noise_floor", + "crc_errors", + "room_messages", + "room_client_sync", + "companion_contacts", + "companion_channels", + "companion_messages", + "companion_prefs", + } + if table_name not in PURGEABLE: + raise ValueError(f"Table '{table_name}' cannot be purged") + + try: + with self._connect() as conn: + result = conn.execute(f"DELETE FROM {table_name}") # noqa: S608 + conn.commit() + logger.info(f"Purged {result.rowcount} rows from {table_name}") + return result.rowcount + except Exception as e: + logger.error(f"Failed to purge table {table_name}: {e}") + raise + + def vacuum(self): + """Reclaim disk space after purging tables.""" + try: + with self._connect() as conn: + conn.execute("VACUUM") + logger.info("Database vacuumed successfully") + except Exception as e: + logger.error(f"Failed to vacuum database: {e}") + raise + def cleanup_old_data(self, days: int = 7): try: cutoff = time.time() - (days * 24 * 3600) - - with sqlite3.connect(self.sqlite_path) as conn: + + with self._connect() 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 - + + result = conn.execute("DELETE FROM crc_errors WHERE timestamp < ?", (cutoff,)) + crc_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") - + + if packets_deleted > 0 or adverts_deleted > 0 or noise_deleted > 0 or crc_deleted > 0: + logger.info( + f"Cleaned up {packets_deleted} old packets, {adverts_deleted} old adverts, {noise_deleted} old noise measurements, {crc_deleted} old CRC error records" + ) + except Exception as e: logger.error(f"Failed to cleanup old data: {e}") def get_cumulative_counts(self) -> dict: try: - 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] - type_counts[f"type_{i}"] = count - - 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] - + with self._connect() as conn: + conn.row_factory = sqlite3.Row + + type_rows = conn.execute( + "SELECT type, COUNT(*) as count FROM packets GROUP BY type" + ).fetchall() + + type_counts = {f"type_{i}": 0 for i in range(16)} + type_counts["type_other"] = 0 + for row in type_rows: + pkt_type = int(row["type"]) + count = int(row["count"]) + if pkt_type <= 15: + type_counts[f"type_{pkt_type}"] = count + else: + type_counts["type_other"] += count + + totals = conn.execute( + """ + SELECT + COUNT(*) AS rx_total, + SUM(CASE WHEN transmitted = 1 THEN 1 ELSE 0 END) AS tx_total, + SUM(CASE WHEN transmitted = 0 THEN 1 ELSE 0 END) AS drop_total + FROM packets + """ + ).fetchone() + return { - "rx_total": rx_total, - "tx_total": tx_total, - "drop_total": drop_total, - "type_counts": type_counts + "rx_total": int(totals["rx_total"] or 0), + "tx_total": int(totals["tx_total"] or 0), + "drop_total": int(totals["drop_total"] or 0), + "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: + if limit is None: + limit = 500 + + with self._connect() 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 = { @@ -703,12 +1607,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 [] @@ -716,50 +1620,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(""" + with self._connect() as conn: + 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}") @@ -767,37 +1691,45 @@ class SQLiteHandler: def get_transport_keys(self) -> List[dict]: try: - with sqlite3.connect(self.sqlite_path) as conn: + with self._connect() 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 [] def get_transport_key_by_id(self, key_id: int) -> Optional[dict]: try: - with sqlite3.connect(self.sqlite_path) as conn: + with self._connect() 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"], @@ -807,18 +1739,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) @@ -834,19 +1774,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""" + + with self._connect() as conn: + 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}") @@ -854,18 +1797,772 @@ class SQLiteHandler: def delete_transport_key(self, key_id: int) -> bool: try: - with sqlite3.connect(self.sqlite_path) as conn: + with self._connect() as conn: cursor = conn.execute("DELETE FROM transport_keys WHERE id = ?", (key_id,)) return cursor.rowcount > 0 except Exception as e: logger.error(f"Failed to delete transport key: {e}") return False + def sync_transport_keys(self, entries: List[Dict[str, Any]]) -> Dict[str, int]: + """ + Replace transport key tree from a canonical Glass payload. + + Args: + entries: Flat list of nodes with fields: + - node_id: unique stable id in payload + - name: key/group display name + - flood_policy: 'allow' | 'deny' + - transport_key: optional explicit key material + - parent_node_id: optional parent node reference + + Returns: + Dict containing applied node count and generated key count. + """ + if not isinstance(entries, list): + raise ValueError("transport_keys payload must be a list") + + normalized: Dict[str, Dict[str, Any]] = {} + used_names: set[str] = set() + for raw in entries: + if not isinstance(raw, dict): + raise ValueError("Each transport key entry must be an object") + node_id = str(raw.get("node_id", "")).strip() + name = str(raw.get("name", "")).strip() + flood_policy = str(raw.get("flood_policy", "")).strip().lower() + parent_node_id = raw.get("parent_node_id") + transport_key = raw.get("transport_key") + if not node_id: + raise ValueError("transport key entry is missing node_id") + if node_id in normalized: + raise ValueError(f"Duplicate node_id in payload: {node_id}") + if not name: + raise ValueError(f"transport key entry '{node_id}' is missing name") + if name in used_names: + raise ValueError(f"Duplicate transport key name in payload: {name}") + if flood_policy not in {"allow", "deny"}: + raise ValueError(f"Invalid flood_policy for '{name}': {flood_policy}") + if transport_key is not None and not isinstance(transport_key, str): + raise ValueError(f"transport_key for '{name}' must be a string or null") + normalized[node_id] = { + "node_id": node_id, + "name": name, + "flood_policy": flood_policy, + "parent_node_id": str(parent_node_id).strip() if parent_node_id else None, + "transport_key": transport_key.strip() if isinstance(transport_key, str) else None, + } + used_names.add(name) + + for node in normalized.values(): + parent_node_id = node.get("parent_node_id") + if parent_node_id and parent_node_id not in normalized: + raise ValueError( + f"Parent node '{parent_node_id}' does not exist for '{node['node_id']}'" + ) + + ordered: List[Dict[str, Any]] = [] + pending = dict(normalized) + resolved_ids: set[str] = set() + while pending: + progressed = False + for node_id, node in list(pending.items()): + parent_node_id = node.get("parent_node_id") + if parent_node_id and parent_node_id not in resolved_ids: + continue + ordered.append(node) + resolved_ids.add(node_id) + pending.pop(node_id) + progressed = True + if not progressed: + raise ValueError("Cycle detected in transport key tree payload") + + generated_keys = 0 + now = time.time() + with self._connect() as conn: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("DELETE FROM transport_keys") + db_ids: Dict[str, int] = {} + for node in ordered: + transport_key = node.get("transport_key") + if not transport_key: + transport_key = self.generate_transport_key(node["name"]) + generated_keys += 1 + parent_id = ( + db_ids.get(node["parent_node_id"]) + if node.get("parent_node_id") + else None + ) + cursor = conn.execute( + """ + INSERT INTO transport_keys ( + name, + flood_policy, + transport_key, + parent_id, + last_used, + created_at, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + node["name"], + node["flood_policy"], + transport_key, + parent_id, + None, + now, + now, + ), + ) + db_ids[node["node_id"]] = int(cursor.lastrowid) + conn.commit() + + return {"applied_nodes": len(ordered), "generated_keys": generated_keys} + def delete_advert(self, advert_id: int) -> bool: try: - with sqlite3.connect(self.sqlite_path) as conn: + with self._connect() as conn: cursor = conn.execute("DELETE FROM adverts WHERE id = ?", (advert_id,)) return cursor.rowcount > 0 except Exception as e: logger.error(f"Failed to delete advert: {e}") - return False \ No newline at end of file + return False + + # ------------------------------------------------------------------ + # 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]: + """Insert a new room message and return its ID.""" + try: + with self._connect() as conn: + 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(), + ), + ) + 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]: + """Get messages for a room that client hasn't synced yet.""" + try: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute( + """ + SELECT * FROM room_messages + WHERE room_hash = ? + AND post_timestamp > ? + AND author_pubkey != ? + ORDER BY post_timestamp ASC + 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}") + return [] + + def upsert_client_sync(self, room_hash: str, client_pubkey: str, **kwargs) -> bool: + """Insert or update client sync state using single upsert operation.""" + try: + with self._connect() as conn: + now = time.time() + kwargs["updated_at"] = now + + # Set defaults for insert path + 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", now) + + columns = ["room_hash", "client_pubkey"] + list(kwargs.keys()) + placeholders = ["?"] * len(columns) + values = [room_hash, client_pubkey] + list(kwargs.values()) + + # Use INSERT OR REPLACE for single atomic upsert + conn.execute( + f""" + INSERT OR REPLACE INTO room_client_sync ({', '.join(columns)}) + VALUES ({', '.join(placeholders)}) + """, + values, + ) + conn.commit() + return True + except Exception as e: + logger.error(f"Failed to upsert client sync: {e}") + return False + + def get_client_sync(self, room_hash: str, client_pubkey: str) -> Optional[Dict]: + """Get client sync state.""" + try: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute( + """ + SELECT * FROM room_client_sync + WHERE room_hash = ? AND client_pubkey = ? + """, + (room_hash, client_pubkey), + ) + row = cursor.fetchone() + return dict(row) if row else None + except Exception as e: + logger.error(f"Failed to get client sync: {e}") + return None + + def get_all_room_clients(self, room_hash: str) -> List[Dict]: + """Get all clients for a room.""" + try: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute( + """ + SELECT * FROM room_client_sync + WHERE room_hash = ? + ORDER BY last_activity DESC + """, + (room_hash,), + ) + return [dict(row) for row in cursor.fetchall()] + except Exception as e: + logger.error(f"Failed to get room clients: {e}") + return [] + + def get_room_message_count(self, room_hash: str) -> int: + """Get total number of messages in a room.""" + try: + with self._connect() as conn: + cursor = conn.execute( + """ + SELECT COUNT(*) FROM room_messages WHERE room_hash = ? + """, + (room_hash,), + ) + return cursor.fetchone()[0] + except Exception as e: + logger.error(f"Failed to get room message count: {e}") + return 0 + + def get_room_messages(self, room_hash: str, limit: int = 50, offset: int = 0) -> List[Dict]: + """Get messages from a room with pagination.""" + try: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute( + """ + SELECT * FROM room_messages + WHERE room_hash = ? + ORDER BY post_timestamp DESC + 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]: + """Get messages posted after a specific timestamp.""" + try: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + cursor = conn.execute( + """ + SELECT * FROM room_messages + WHERE room_hash = ? AND post_timestamp > ? + ORDER BY post_timestamp DESC + 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}") + return [] + + def get_unsynced_count(self, room_hash: str, client_pubkey: str, sync_since: float) -> int: + """Get count of unsynced messages for a client. + + Note: a duplicate definition of this method existed earlier in the file + with the same signature but reversed parameter-binding order in the SQL. + Python silently uses the last definition; the first was dead code. + The dead definition has been removed. + """ + try: + with self._connect() as conn: + cursor = conn.execute( + """ + SELECT COUNT(*) FROM room_messages + WHERE room_hash = ? + AND author_pubkey != ? + AND post_timestamp > ? + """, + (room_hash, client_pubkey, sync_since), + ) + return cursor.fetchone()[0] + except Exception as e: + logger.error(f"Failed to get unsynced count: {e}") + return 0 + + def delete_room_message(self, room_hash: str, message_id: int) -> bool: + """Delete a specific message by ID.""" + try: + with self._connect() as conn: + cursor = conn.execute( + """ + DELETE FROM room_messages + WHERE room_hash = ? AND id = ? + """, + (room_hash, message_id), + ) + return cursor.rowcount > 0 + except Exception as e: + logger.error(f"Failed to delete message: {e}") + return False + + def clear_room_messages(self, room_hash: str) -> int: + """Clear all messages from a room.""" + try: + with self._connect() as conn: + cursor = conn.execute( + """ + DELETE FROM room_messages WHERE room_hash = ? + """, + (room_hash,), + ) + return cursor.rowcount + except Exception as e: + logger.error(f"Failed to clear room messages: {e}") + return 0 + + def cleanup_old_messages(self, room_hash: str, keep_count: int = 32) -> int: + """Keep only the most recent N messages per room.""" + try: + with self._connect() as conn: + # First check if cleanup is needed + cursor = conn.execute( + """ + SELECT COUNT(*) FROM room_messages WHERE 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( + """ + DELETE FROM room_messages + WHERE room_hash = ? + AND id NOT IN ( + SELECT id FROM room_messages + WHERE room_hash = ? + ORDER BY post_timestamp DESC + LIMIT ? + ) + """, + (room_hash, room_hash, keep_count), + ) + return cursor.rowcount + 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 self._connect() 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 using batch insert.""" + try: + with self._connect() as conn: + conn.execute( + "DELETE FROM companion_contacts WHERE companion_hash = ?", (companion_hash,) + ) + now = time.time() + # Batch insert all contacts at once instead of loop-based inserts + rows = [ + ( + 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, + ) + for c in contacts + ] + if rows: + conn.executemany( + """ + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + rows, + ) + conn.commit() + return True + except Exception as e: + 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 self._connect() 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_import_repeater_contacts( + self, + companion_hash: str, + contact_types: Optional[List[str]] = None, + hours: Optional[int] = None, + limit: Optional[int] = None, + ) -> int: + """Import repeater adverts into a companion's contact store (one-time seed). + + Results are ordered by last_seen DESC so the most recent contacts are + imported first. Optional hours filters to adverts seen within the last N hours; + optional limit caps how many contacts are imported. + """ + type_map = {"companion": 1, "repeater": 2, "room_server": 3, "sensor": 4} + try: + with self._connect() as conn: + conn.row_factory = sqlite3.Row + query = ( + "SELECT pubkey, node_name, contact_type, latitude, longitude, last_seen " + "FROM adverts WHERE pubkey IS NOT NULL" + ) + params: list = [] + if contact_types: + placeholders = ",".join("?" * len(contact_types)) + query += f" AND contact_type IN ({placeholders})" + params.extend(contact_types) + if hours is not None: + cutoff = time.time() - (hours * 3600) + query += " AND last_seen >= ?" + params.append(cutoff) + query += " ORDER BY last_seen DESC" + if limit is not None: + query += " LIMIT ?" + params.append(limit) + rows = conn.execute(query, params).fetchall() + + # Batch insert all contacts at once instead of loop-based upserts + now = time.time() + contact_rows = [] + for row in rows: + raw_type = row["contact_type"] or "" + normalized_type = raw_type.lower().replace(" ", "_").strip() + adv_type = type_map.get(normalized_type, 0) + contact_rows.append( + ( + companion_hash, + bytes.fromhex(row["pubkey"]), + row["node_name"] or "", + adv_type, + 0, # flags + -1, # out_path_len + b"", # out_path + int(row["last_seen"] or 0), # last_advert_timestamp + int(row["last_seen"] or 0), # lastmod + row["latitude"] or 0.0, # gps_lat + row["longitude"] or 0.0, # gps_lon + 0, # sync_since + now, # updated_at + ) + ) + + if contact_rows: + with self._connect() as conn: + conn.executemany( + """ + 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 + """, + contact_rows, + ) + conn.commit() + return len(contact_rows) + except Exception as e: + logger.error(f"Failed to import repeater contacts: {e}") + return 0 + + 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 self._connect() 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) + key = str(companion_hash) if companion_hash is not None else "" + with self._connect() 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 + """, + (key, 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: + with self._connect() 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 using batch insert.""" + try: + with self._connect() as conn: + conn.execute( + "DELETE FROM companion_channels WHERE companion_hash = ?", (companion_hash,) + ) + now = time.time() + # Batch insert all channels at once instead of loop-based inserts + rows = [ + ( + companion_hash, + ch.get("channel_idx", 0), + ch.get("name", ""), + ch.get("secret", b""), + now, + ) + for ch in channels + ] + if rows: + conn.executemany( + """ + INSERT INTO companion_channels + (companion_hash, channel_idx, name, secret, updated_at) + VALUES (?, ?, ?, ?, ?) + """, + rows, + ) + 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 self._connect() 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. + + Deduplicates by (companion_hash, packet_hash) using INSERT OR IGNORE + backed by the UNIQUE index added in migration 8. This replaces the + previous SELECT + INSERT round-trip (two statements, two SD-card reads) + with a single atomic statement. + + Returns True if inserted, False if the message was a 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 self._connect() as conn: + cursor = conn.execute( + """ + INSERT OR IGNORE 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(), + ), + ) + conn.commit() + return cursor.rowcount > 0 + 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 self._connect() 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/data_acquisition/storage_collector.py b/repeater/data_acquisition/storage_collector.py index 4f8bb7b..ec92406 100644 --- a/repeater/data_acquisition/storage_collector.py +++ b/repeater/data_acquisition/storage_collector.py @@ -1,17 +1,16 @@ +import asyncio import json 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 .mqtt_handler import MeshCoreToMqttPusher from .rrdtool_handler import RRDToolHandler -from .mqtt_handler import MQTTHandler -from .letsmesh_handler import MeshCoreToMqttJwtPusher +from .sqlite_handler import SQLiteHandler from .storage_utils import PacketRecord - logger = logging.getLogger("StorageCollector") @@ -19,130 +18,296 @@ 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")) - self.storage_dir.mkdir(parents=True, exist_ok=True) + self.glass_publish_callback = None + self._pending_tasks = set() - node_name = config.get("repeater", {}).get("node_name", "unknown") - node_id = local_identity.get_public_key().hex() if local_identity else "unknown" + storage_dir_cfg = ( + config.get("storage", {}).get("storage_dir") + or config.get("storage_dir") + or "/var/lib/pymc_repeater" + ) + self.storage_dir = Path(storage_dir_cfg) + self.storage_dir.mkdir(parents=True, exist_ok=True) self.sqlite_handler = SQLiteHandler(self.storage_dir) self.rrd_handler = RRDToolHandler(self.storage_dir) - self.mqtt_handler = MQTTHandler(config.get("mqtt", {}), node_name, node_id) - # Initialize LetsMesh handler if configured - self.letsmesh_handler = None - if config.get("letsmesh", {}).get("enabled", False) and local_identity: + # Initialize MQTT handler if configured + self.mqtt_handler = None + if (config.get("mqtt_brokers", {}) or config.get("letsmesh", {}) or config.get("mqtt", {})) and local_identity: try: - # Get keys from local_identity (signing_key.encode() is the private key seed) - private_key_hex = local_identity.signing_key.encode().hex() - public_key_hex = local_identity.get_public_key().hex() - - self.letsmesh_handler = MeshCoreToMqttJwtPusher( - private_key=private_key_hex, - public_key=public_key_hex, + # Pass local_identity directly (supports both standard and firmware keys) + self.mqtt_handler = MeshCoreToMqttPusher( + local_identity=local_identity, config=config, stats_provider=self._get_live_stats, ) - self.letsmesh_handler.connect() - - # Get disallowed packet types from config - from ..config import get_node_info - - node_info = get_node_info(config) - self.disallowed_packet_types = set(node_info["disallowed_packet_types"]) + self.mqtt_handler.connect() + public_key_hex = local_identity.get_public_key().hex() logger.info( - f"LetsMesh handler initialized with public key: {public_key_hex[:16]}..." + f"MQTT handler initialized with public key: {public_key_hex[:16]}..." ) - if self.disallowed_packet_types: - logger.info(f"Disallowed packet types: {sorted(self.disallowed_packet_types)}") - else: - logger.info("All packet types allowed") except Exception as e: - logger.error(f"Failed to initialize LetsMesh handler: {e}") - self.letsmesh_handler = None - self.disallowed_packet_types = set() - else: - self.disallowed_packet_types = set() - + logger.error(f"Failed to initialize MQTT handler: {e}") + self.mqtt_handler = None + # 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 + self.websocket_has_connected_clients = lambda: False + self._last_ws_stats_broadcast: float = 0.0 + self._ws_stats_broadcast_interval_sec: float = 5.0 + try: + from .websocket_handler import ( + broadcast_packet, + broadcast_stats, + has_connected_clients, + ) + + self.websocket_broadcast_packet = broadcast_packet + self.websocket_broadcast_stats = broadcast_stats + self.websocket_has_connected_clients = has_connected_clients + self.websocket_available = True + logger.info("WebSocket handler initialized for real-time updates") + except ImportError: + logger.debug("WebSocket handler not available") + + def _track_task(self, task: asyncio.Task): + """Track background task for lifecycle management and error handling.""" + self._pending_tasks.add(task) + + def on_done(t: asyncio.Task): + self._pending_tasks.discard(t) + try: + t.result() + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"Background task error: {e}", exc_info=True) + + task.add_done_callback(on_done) + + def _schedule_background(self, coro_factory, *args, sync_fallback=None): + """Schedule a coroutine if a loop exists; otherwise run sync fallback.""" + try: + loop = asyncio.get_running_loop() + except RuntimeError: + if sync_fallback is not None: + sync_fallback(*args) + return + + task = loop.create_task(coro_factory(*args)) + self._track_task(task) + def _get_live_stats(self) -> dict: """Get live stats from RepeaterHandler""" if not self.repeater_handler: - return {"uptime_secs": 0, "packets_sent": 0, "packets_received": 0} + return { + "uptime_secs": 0, + "packets_sent": 0, + "packets_received": 0, + "errors": 0, + "queue_len": 0, + } uptime_secs = int(time.time() - self.repeater_handler.start_time) - return { + + # 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") + 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, "packets_received": self.repeater_handler.rx_count, + "errors": 0, + "queue_len": 0, # N/A for Python repeater } - def record_packet(self, packet_record: dict, skip_letsmesh_if_invalid: bool = True): - """Record packet to storage and publish to MQTT/LetsMesh - + # 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_mqtt_if_invalid: bool = True): + """Record packet to storage and publish to MQTT + Args: packet_record: Dictionary containing packet information - skip_letsmesh_if_invalid: If True, don't publish packets with drop_reason to LetsMesh + skip_mqtt_if_invalid: If True, don't publish packets with drop_reason to mqtt """ logger.debug( f"Recording packet: type={packet_record.get('type')}, " f"transmitted={packet_record.get('transmitted')}" ) - # Store to local databases and publish to local MQTT + # HOT PATH: Store to local databases only (fast, non-blocking) self.sqlite_handler.store_packet(packet_record) 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") - # 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')}") - else: - self._publish_to_letsmesh(packet_record) + # DEFERRED: Publish to network sinks and WebSocket in background tasks + # This prevents network latency from blocking packet processing + self._schedule_background( + self._deferred_publish, + packet_record, + skip_mqtt_if_invalid, + sync_fallback=self._publish_packet_sync, + ) - def _publish_to_letsmesh(self, packet_record: dict): - """Publish packet to LetsMesh broker if enabled and allowed""" - if not self.letsmesh_handler: + async def _deferred_publish(self, packet_record: dict, skip_mqtt: bool): + """Deferred background task for all network publishing operations.""" + try: + self._publish_packet_sync(packet_record, skip_mqtt) + except Exception as e: + logger.error(f"Deferred publish failed: {e}", exc_info=True) + + def _publish_packet_sync(self, packet_record: dict, skip_mqtt: bool): + """Publish packet updates synchronously (used when no asyncio loop is active).""" + self._publish_to_glass(packet_record, "packet") + + if self.websocket_available: + try: + self.websocket_broadcast_packet(packet_record) + if self.websocket_has_connected_clients(): + now_mono = time.monotonic() + if ( + now_mono - self._last_ws_stats_broadcast + >= self._ws_stats_broadcast_interval_sec + ): + self._last_ws_stats_broadcast = now_mono + 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}, + } + ) + except Exception as e: + logger.debug(f"WebSocket broadcast failed: {e}") + + + self._publish_packet_to_mqtt(packet_record) + + def _publish_packet_to_mqtt(self, packet_record: dict): + """Publish packet to mqtt broker if enabled and allowed""" + if not self.mqtt_handler: return try: packet_type = packet_record.get("type") if packet_type is None: - logger.error("Cannot publish to LetsMesh: packet_record missing 'type' field") - return - - if packet_type in self.disallowed_packet_types: - logger.debug(f"Skipped publishing packet type 0x{packet_type:02X} (disallowed)") + logger.error("Cannot publish to mqtt: packet_record missing 'type' field") return node_name = self.config.get("repeater", {}).get("node_name", "Unknown") packet = PacketRecord.from_packet_record( - packet_record, origin=node_name, origin_id=self.letsmesh_handler.public_key + packet_record, origin=node_name, origin_id=self.mqtt_handler.public_key ) if packet: - self.letsmesh_handler.publish_packet(packet.to_dict()) - logger.debug(f"Published packet type 0x{packet_type:02X} to LetsMesh") + self.mqtt_handler.publish_packet(packet.to_dict()) + logger.debug(f"Published packet type 0x{packet_type:02X} to mqtt") else: - logger.debug("Skipped LetsMesh publish: packet missing raw_packet data") + logger.debug("Skipped mqtt publish: packet missing raw_packet data") except Exception as e: - logger.error(f"Failed to publish packet to LetsMesh: {e}", exc_info=True) + logger.error(f"Failed to publish packet to mqtt: {e}", exc_info=True) def record_advert(self, advert_record: dict): + """Record advert to storage and defer network publishing to background tasks.""" self.sqlite_handler.store_advert(advert_record) - self.mqtt_handler.publish(advert_record, "advert") + self._schedule_background( + self._deferred_publish_advert, + advert_record, + sync_fallback=self._publish_advert_sync, + ) + + async def _deferred_publish_advert(self, advert_record: dict): + """Deferred background task for advert publishing.""" + try: + self._publish_advert_sync(advert_record) + except Exception as e: + logger.error(f"Deferred advert publish failed: {e}", exc_info=True) + + def _publish_advert_sync(self, advert_record: dict): + if self.mqtt_handler: + self.mqtt_handler.publish_mqtt(advert_record, "advert") + self._publish_to_glass(advert_record, "advert") def record_noise_floor(self, noise_floor_dbm: float): + """Record noise floor to storage and defer network publishing to background tasks.""" noise_record = {"timestamp": time.time(), "noise_floor_dbm": noise_floor_dbm} self.sqlite_handler.store_noise_floor(noise_record) - self.mqtt_handler.publish(noise_record, "noise_floor") + self._schedule_background( + self._deferred_publish_noise_floor, + noise_record, + sync_fallback=self._publish_noise_floor_sync, + ) + + async def _deferred_publish_noise_floor(self, noise_record: dict): + """Deferred background task for noise floor publishing.""" + try: + self._publish_noise_floor_sync(noise_record) + except Exception as e: + logger.error(f"Deferred noise floor publish failed: {e}", exc_info=True) + + def _publish_noise_floor_sync(self, noise_record: dict): + if self.mqtt_handler: + self.mqtt_handler.publish_mqtt(noise_record, "noise_floor") + self._publish_to_glass(noise_record, "noise_floor") + + def record_crc_errors(self, count: int): + """Record a batch of CRC errors detected since last poll and defer publishing.""" + crc_record = {"timestamp": time.time(), "count": count} + self.sqlite_handler.store_crc_errors(crc_record) + self._schedule_background( + self._deferred_publish_crc_errors, + crc_record, + sync_fallback=self._publish_crc_errors_sync, + ) + + async def _deferred_publish_crc_errors(self, crc_record: dict): + """Deferred background task for CRC error publishing.""" + try: + self._publish_crc_errors_sync(crc_record) + except Exception as e: + logger.error(f"Deferred CRC errors publish failed: {e}", exc_info=True) + + def _publish_crc_errors_sync(self, crc_record: dict): + if self.mqtt_handler: + self.mqtt_handler.publish_mqtt(crc_record, "crc_errors") + self._publish_to_glass(crc_record, "crc_errors") + + def get_crc_error_count(self, hours: int = 24) -> int: + return self.sqlite_handler.get_crc_error_count(hours) + + def get_crc_error_history(self, hours: int = 24, limit: int = None) -> list: + return self.sqlite_handler.get_crc_error_history(hours, limit) def get_packet_stats(self, hours: int = 24) -> dict: return self.sqlite_handler.get_packet_stats(hours) @@ -157,9 +322,32 @@ class StorageCollector: start_timestamp: Optional[float] = None, end_timestamp: Optional[float] = None, limit: int = 1000, + offset: int = 0, ) -> list: return self.sqlite_handler.get_filtered_packets( - packet_type, route, start_timestamp, end_timestamp, limit + packet_type, route, start_timestamp, end_timestamp, limit, offset + ) + + def get_airtime_data( + self, + start_timestamp: Optional[float] = None, + end_timestamp: Optional[float] = None, + limit: int = 50000, + ) -> list: + return self.sqlite_handler.get_airtime_data(start_timestamp, end_timestamp, limit) + + def get_airtime_buckets( + self, + start_timestamp: float, + end_timestamp: float, + bucket_seconds: int = 60, + sf: int = 9, + bw_hz: int = 62500, + cr: int = 5, + preamble: int = 17, + ) -> dict: + return self.sqlite_handler.get_airtime_buckets( + start_timestamp, end_timestamp, bucket_seconds, sf, bw_hz, cr, preamble ) def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]: @@ -187,23 +375,61 @@ 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,), + ).fetchone() + return result[0] if result else None + except Exception as e: + logger.debug(f"Could not lookup node name for {pubkey[:8] if pubkey else 'None'}: {e}") + return None + def cleanup_old_data(self, days: int = 7): self.sqlite_handler.cleanup_old_data(days) - def get_noise_floor_history(self, hours: int = 24) -> list: - return self.sqlite_handler.get_noise_floor_history(hours) + def get_noise_floor_history(self, hours: int = 24, limit: int = None) -> list: + return self.sqlite_handler.get_noise_floor_history(hours, limit) def get_noise_floor_stats(self, hours: int = 24) -> dict: return self.sqlite_handler.get_noise_floor_stats(hours) def close(self): - self.mqtt_handler.close() - if self.letsmesh_handler: + # Cancel all pending background tasks + for task in self._pending_tasks: + if not task.done(): + task.cancel() + + if self.mqtt_handler: try: - self.letsmesh_handler.disconnect() - logger.info("LetsMesh handler disconnected") + self.mqtt_handler.disconnect() + logger.info("MQTT handler disconnected") except Exception as e: - logger.error(f"Error disconnecting LetsMesh handler: {e}") + logger.error(f"Error disconnecting MQTT handler: {e}") + + def set_glass_publisher(self, publish_callback): + self.glass_publish_callback = publish_callback + + def _publish_to_glass(self, record: dict, record_type: str): + if not self.glass_publish_callback: + return + try: + self.glass_publish_callback(record_type, record) + except Exception as e: + logger.debug(f"Failed to publish telemetry to Glass MQTT: {e}") def create_transport_key( self, diff --git a/repeater/data_acquisition/storage_utils.py b/repeater/data_acquisition/storage_utils.py index bd930a5..f0d0751 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 @@ -10,7 +10,7 @@ class PacketRecord: """ Data class for packet record format. Converts internal packet_record format to standardized publish format. - Reusable across MQTT, LetsMesh, and other handlers. + Reusable across MQTT and other handlers. """ origin: str diff --git a/repeater/data_acquisition/websocket_handler.py b/repeater/data_acquisition/websocket_handler.py new file mode 100644 index 0000000..bf88b66 --- /dev/null +++ b/repeater/data_acquisition/websocket_handler.py @@ -0,0 +1,168 @@ +""" +WebSocket handler for real-time packet updates - simple ws4py implementation +""" + +import json +import logging +import threading +import time +from urllib.parse import parse_qs + +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) + +# Global set of connected clients +_connected_clients = set() + +# Heartbeat configuration +PING_INTERVAL = 30 # seconds +_heartbeat_thread = None +_heartbeat_running = False + + +class PacketWebSocket(WebSocket): + + def opened(self): + """Called when a WebSocket connection is established""" + # Authenticate using JWT provided as query parameter (token=) + jwt_handler = cherrypy.config.get("jwt_handler") + + # Get query string from environ + qs = "" + if hasattr(self, "environ"): + qs = self.environ.get("QUERY_STRING", "") + + params = parse_qs(qs) + token = params.get("token", [None])[0] + client_id = params.get("client_id", [None])[0] + + if not jwt_handler: + logger.warning("WebSocket connection rejected: no JWT handler configured") + self.close(code=1011, reason="server configuration error") + return + + if not token: + logger.warning("WebSocket connection rejected: missing token") + self.close(code=1008, reason="unauthorized") + return + + try: + payload = jwt_handler.verify_jwt(token) + if not payload: + logger.warning("WebSocket connection rejected: invalid token") + self.close(code=1008, reason="unauthorized") + return + except Exception as e: + logger.warning(f"WebSocket auth error: {e}") + self.close(code=1008, reason="unauthorized") + return + + if client_id and payload.get("client_id") and payload.get("client_id") != client_id: + logger.warning("WebSocket connection rejected: client_id mismatch") + self.close(code=1008, reason="unauthorized") + return + + # 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)}" + ) + + 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)}" + ) + + def received_message(self, message): + """Handle messages from client""" + try: + data = json.loads(str(message)) + if data.get("type") == "ping": + self.send(json.dumps({"type": "pong"})) + elif data.get("type") == "pong": + # Client responded to our ping + pass + except Exception: + pass + + +def broadcast_packet(packet_data: dict): + + if not _connected_clients: + return + + message = json.dumps({"type": "packet", "data": packet_data}) + + for client in list(_connected_clients): + try: + client.send(message) + except Exception as e: + logger.error(f"WebSocket send error: {e}") + _connected_clients.discard(client) + + +def broadcast_stats(stats_data: dict): + + if not _connected_clients: + return + + message = json.dumps({"type": "stats", "data": stats_data}) + + for client in list(_connected_clients): + try: + client.send(message) + except Exception as e: + logger.error(f"WebSocket send error: {e}") + _connected_clients.discard(client) + + +def has_connected_clients() -> bool: + """Return True when at least one authenticated websocket client is connected.""" + return bool(_connected_clients) + + +def _heartbeat_loop(): + """Background thread to send periodic pings to all connected clients""" + global _heartbeat_running + + while _heartbeat_running: + time.sleep(PING_INTERVAL) + + if not _connected_clients: + continue + + ping_message = json.dumps({"type": "ping"}) + + for client in list(_connected_clients): + try: + client.send(ping_message) + except Exception as e: + logger.debug(f"Heartbeat ping failed: {e}") + _connected_clients.discard(client) + + +def init_websocket(): + """Initialize WebSocket plugin and start heartbeat""" + global _heartbeat_thread, _heartbeat_running + + WebSocketPlugin(cherrypy.engine).subscribe() + cherrypy.tools.websocket = WebSocketTool() + + # Start heartbeat thread + if not _heartbeat_running: + _heartbeat_running = True + _heartbeat_thread = threading.Thread(target=_heartbeat_loop, daemon=True) + _heartbeat_thread.start() + logger.info(f"WebSocket initialized with {PING_INTERVAL}s heartbeat") + else: + logger.info("WebSocket initialized") diff --git a/repeater/engine.py b/repeater/engine.py index 6d95c78..c7c2bf3 100644 --- a/repeater/engine.py +++ b/repeater/engine.py @@ -1,8 +1,10 @@ import asyncio +import copy import logging +import random import struct import time -from collections import OrderedDict +from collections import OrderedDict, deque from typing import Optional, Tuple from pymc_core.node.handlers.base import BaseHandler @@ -10,14 +12,17 @@ from pymc_core.protocol import Packet from pymc_core.protocol.constants import ( MAX_PATH_SIZE, PAYLOAD_TYPE_ADVERT, + PAYLOAD_TYPE_ANON_REQ, + PAYLOAD_TYPE_TRACE, PH_ROUTE_MASK, + PH_TYPE_MASK, + 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 +from pymc_core.protocol.packet_utils import PacketHeaderUtils, PacketTimingUtils, PathUtils from repeater.airtime import AirtimeManager from repeater.data_acquisition import StorageCollector @@ -26,6 +31,20 @@ logger = logging.getLogger("RepeaterHandler") NOISE_FLOOR_INTERVAL = 30.0 # seconds +LOOP_DETECT_OFF = "off" +LOOP_DETECT_MINIMAL = "minimal" +LOOP_DETECT_MODERATE = "moderate" +LOOP_DETECT_STRICT = "strict" + +# Thresholds for 1-byte path hashes loop detection. +# Count how many times our own hash already exists in the incoming FLOOD path. +# If occurrences >= threshold, treat as loop and drop. +LOOP_DETECT_MAX_COUNTERS = { + LOOP_DETECT_MINIMAL: 4, + LOOP_DETECT_MODERATE: 2, + LOOP_DETECT_STRICT: 1, +} + class RepeaterHandler(BaseHandler): @@ -34,24 +53,32 @@ 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() - self.cache_ttl = config.get("repeater", {}).get("cache_ttl", 60) + self.cache_ttl = max( + 300, config.get("repeater", {}).get("cache_ttl", 3600) + ) # Min 5 min, default 1 hour self.max_cache_size = 1000 + self.max_duplicates_per_packet = 20 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) self.use_score_for_tx = config.get("repeater", {}).get("use_score_for_tx", False) self.score_threshold = config.get("repeater", {}).get("score_threshold", 0.3) + self.max_flood_hops = config.get("repeater", {}).get("max_flood_hops", 64) self.send_advert_interval_hours = config.get("repeater", {}).get( "send_advert_interval_hours", 10 ) self.last_advert_time = time.time() + self.loop_detect_mode = self._normalize_loop_detect_mode( + config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF) + ) radio = dispatcher.radio if dispatcher else None if radio: @@ -74,9 +101,17 @@ class RepeaterHandler(BaseHandler): self.rx_count = 0 self.forwarded_count = 0 self.dropped_count = 0 - self.recent_packets = [] self.max_recent_packets = 50 + self.recent_packets = deque(maxlen=self.max_recent_packets) + self._recent_hash_index = {} self.start_time = time.time() + # Flood/direct and duplicate counters (for GET_STATUS / firmware RepeaterStats) + self.recv_flood_count = 0 + self.recv_direct_count = 0 + self.sent_flood_count = 0 + self.sent_direct_count = 0 + self.flood_dup_count = 0 + self.direct_dup_count = 0 # Storage collector for persistent packet logging try: @@ -90,91 +125,211 @@ class RepeaterHandler(BaseHandler): # Initialize background timer tracking self.last_noise_measurement = time.time() + self.last_cache_cleanup = time.time() + self.last_db_cleanup = time.time() self.noise_floor_interval = NOISE_FLOOR_INTERVAL # 30 seconds self._background_task = None + self._cached_noise_floor = None + self._last_crc_error_count = 0 # Track radio counter for delta persistence # Cache transport keys for efficient lookup self._transport_keys_cache = None self._transport_keys_cache_time = 0 self._transport_keys_cache_ttl = 60 # Cache for 60 seconds - + + # Serialise all radio TX calls. + # + # Background: since the queue loop dispatches each packet as an + # asyncio.create_task, multiple _route_packet coroutines can have their + # TX delay timers running concurrently — which is the intended behaviour + # (firmware nodes do the same with a hardware timer). However, the + # LoRa radio is half-duplex: it can only transmit one packet at a time. + # Without serialisation, two tasks whose delay timers expire near- + # simultaneously both call dispatcher.send_packet, interleaving SPI/serial + # commands to the radio and both passing the LBT check before either has + # actually transmitted. + # + # _tx_lock is acquired after each delay sleep and held for the entire + # send_packet call. Delays still run concurrently; only the radio + # access is serialised. This also eliminates the TOCTOU gap in duty-cycle + # enforcement — see schedule_retransmit / delayed_send for details. + self._tx_lock = asyncio.Lock() + 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 = {} - self.rx_count += 1 + # Only count as receive when packet came from the radio (not locally injected) + if not local_transmission: + self.rx_count += 1 + route_type = packet.header & PH_ROUTE_MASK + if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD): + self.recv_flood_count += 1 + elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT): + self.recv_direct_count += 1 + try: + rx_airtime_ms = self.airtime_mgr.calculate_airtime(packet.get_raw_length()) + self.airtime_mgr.record_rx(rx_airtime_ms) + except Exception: + pass - # Check if we're in monitor mode (receive only, no forwarding) + route_type = packet.header & PH_ROUTE_MASK + pkt_hash_full = packet.calculate_packet_hash().hex().upper() + + # TX mode: forward (repeat on), monitor (no repeat, tenants can TX), no_tx (all TX off) mode = self.config.get("repeater", {}).get("mode", "forward") - monitor_mode = mode == "monitor" + if mode not in ("forward", "monitor", "no_tx"): + mode = "forward" + allow_forward = mode == "forward" + allow_local_tx = mode != "no_tx" - logger.debug( - f"RX packet: header=0x{packet.header:02x}, payload_len={len(packet.payload or b'')}, " - f"path_len={len(packet.path) if packet.path else 0}, " - f"rssi={metadata.get('rssi', 'N/A')}, snr={metadata.get('snr', 'N/A')}, mode={mode}" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + f"RX packet: header=0x{packet.header:02x}, payload_len={len(packet.payload or b'')}, " + f"path_len={len(packet.path) if packet.path else 0}, " + f"rssi={metadata.get('rssi', 'N/A')}, snr={metadata.get('snr', 'N/A')}, mode={mode}" + ) + + # clone the packet to avoid modifying the original + processed_packet = copy.deepcopy(packet) snr = metadata.get("snr", 0.0) rssi = metadata.get("rssi", 0) transmitted = False tx_delay_ms = 0.0 drop_reason = None + lbt_attempts = 0 + lbt_backoff_delays_ms = None + lbt_channel_busy = False - 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 = None if (monitor_mode or local_transmission) else self.process_packet(packet, snr) - forwarded_path = None - - # For local transmissions, create a direct transmission result - if local_transmission and not monitor_mode: + # Process for forwarding (skip if repeat disabled or if this is a local transmission). + # Pass pkt_hash_full so flood_forward / direct_forward don't recompute SHA-256. + result = ( + None + if (not allow_forward or local_transmission) + else self.process_packet(processed_packet, snr, packet_hash=pkt_hash_full) + ) + forwarded_path_hashes = None + + # For local transmissions, create a direct transmission result (if local TX allowed) + if local_transmission and allow_local_tx: # Mark local packet as seen to prevent duplicate processing when received back - self.mark_seen(packet) + self.mark_seen(packet, packet_hash=pkt_hash_full) # 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 [] - logger.debug(f"Local transmission: calculated delay {delay:.3f}s") - + forwarded_path_hashes = packet.get_path_hashes_hex() + if logger.isEnabledFor(logging.DEBUG): + 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 - packet_bytes = ( - fwd_pkt.write_to() if hasattr(fwd_pkt, "write_to") else fwd_pkt.payload or b"" - ) - airtime_ms = PacketTimingUtils.estimate_airtime_ms(len(packet_bytes), self.radio_config) + airtime_ms = self.airtime_mgr.calculate_airtime(fwd_pkt.get_raw_length()) 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 - await self.schedule_retransmit(fwd_pkt, delay, airtime_ms) + 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) + 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, {total_lbt_delay:.0f}ms delay, " + f"backoffs={lbt_backoff_delays_ms}" + ) else: self.dropped_count += 1 - # Determine drop reason from process_packet result - if monitor_mode: - drop_reason = "Monitor mode" + # Determine drop reason + if local_transmission and not allow_local_tx: + drop_reason = "No TX mode" + elif not allow_forward: + drop_reason = "Repeat disabled" else: # Check if packet has a specific drop reason set by handlers - drop_reason = packet.drop_reason or self._get_drop_reason(packet) - logger.debug(f"Packet not forwarded: {drop_reason}") + drop_reason = processed_packet.drop_reason or self._get_drop_reason( + processed_packet, packet_hash=pkt_hash_full + ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"Packet not forwarded: {drop_reason}") # Extract packet type and route from header if not hasattr(packet, "header") or packet.header is None: @@ -185,116 +340,83 @@ class RepeaterHandler(BaseHandler): header_info = PacketHeaderUtils.parse_header(packet.header) payload_type = header_info["payload_type"] route_type = header_info["route_type"] - logger.debug( - f"Packet header=0x{packet.header:02x}, type={payload_type}, route={route_type}" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + f"Packet header=0x{packet.header:02x}, type={payload_type}, route={route_type}" + ) # Check if this is a duplicate - pkt_hash = packet.calculate_packet_hash().hex().upper() - is_dupe = pkt_hash in self.seen_packets and not transmitted + is_dupe = pkt_hash_full in self.seen_packets and not transmitted - # Set drop reason for duplicates + # Set drop reason for duplicates and count flood vs direct dups if is_dupe and drop_reason is None: drop_reason = "Duplicate" + if is_dupe: + if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD): + self.flood_dup_count += 1 + elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT): + self.direct_dup_count += 1 - 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) + "]" - - src_hash = None - dst_hash = None - - # Payload types with dest_hash and src_hash as first 2 bytes - if payload_type in [0x00, 0x01, 0x02, 0x08]: - if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 2: - dst_hash = f"{packet.payload[0]:02X}" - src_hash = f"{packet.payload[1]:02X}" - - # ADVERT packets have source identifier as first byte - elif payload_type == PAYLOAD_TYPE_ADVERT: - if hasattr(packet, "payload") and packet.payload and len(packet.payload) >= 1: - src_hash = f"{packet.payload[0]:02X}" + path_hash = self._path_hash_display(display_hashes) + src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type) # Record packet for charts - packet_record = { - "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 - ), - "type": payload_type, - "route": route_type, - "length": len(packet.payload or b""), - "rssi": rssi, - "snr": snr, - "score": self.calculate_packet_score( - snr, len(packet.payload or b""), self.radio_config["spreading_factor"] - ), - "tx_delay_ms": tx_delay_ms, - "transmitted": transmitted, - "is_duplicate": is_dupe, - "packet_hash": pkt_hash[:16], - "drop_reason": drop_reason, - "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 - ), - "raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None, - } + packet_record = self._build_packet_record( + packet, + payload_type, + route_type, + rssi, + snr, + original_path_hashes, + path_hash_size, + path_hash, + src_hash, + dst_hash, + transmitted=transmitted, + drop_reason=drop_reason, + is_duplicate=is_dupe, + forwarded_path=forwarded_path_hashes, + tx_delay_ms=tx_delay_ms, + lbt_attempts=lbt_attempts, + lbt_backoff_delays_ms=lbt_backoff_delays_ms, + lbt_channel_busy=lbt_channel_busy, + packet_hash=pkt_hash_full, + ) # Store packet record to persistent storage - # Skip LetsMesh only for invalid packets (not duplicates or operational drops) + # Skip mqtt only for invalid packets (not duplicates or operational drops) if self.storage: try: - # Only skip LetsMesh for actual invalid/bad packets + # Only skip mqtt for actual invalid/bad packets invalid_reasons = ["Invalid advert packet", "Empty payload", "Path too long"] - skip_letsmesh = drop_reason in invalid_reasons if drop_reason else False - self.storage.record_packet(packet_record, skip_letsmesh_if_invalid=skip_letsmesh) + skip_mqtt = drop_reason in invalid_reasons if drop_reason else False + self.storage.record_packet(packet_record, skip_mqtt_if_invalid=skip_mqtt) except Exception as e: logger.error(f"Failed to store packet record: {e}") # If this is a duplicate, try to attach it to the original packet if is_dupe and len(self.recent_packets) > 0: - # Find the original packet with same hash - for idx in range(len(self.recent_packets) - 1, -1, -1): - prev_pkt = self.recent_packets[idx] - if prev_pkt.get("packet_hash") == packet_record["packet_hash"]: - # Add duplicate to original packet's duplicate list - if "duplicates" not in prev_pkt: - prev_pkt["duplicates"] = [] + prev_pkt = self._recent_hash_index.get(packet_record["packet_hash"]) + if prev_pkt is not None: + # Add duplicate to original packet's duplicate list + if "duplicates" not in prev_pkt: + prev_pkt["duplicates"] = [] + if len(prev_pkt["duplicates"]) < self.max_duplicates_per_packet: prev_pkt["duplicates"].append(packet_record) - # Don't add duplicate to main list, just track in original - break + # Don't add duplicate to main list, just track in original else: # Original not found, add as regular packet - self.recent_packets.append(packet_record) + self._append_recent_packet(packet_record) else: # Not a duplicate or first occurrence - self.recent_packets.append(packet_record) - - if len(self.recent_packets) > self.max_recent_packets: - self.recent_packets.pop(0) + self._append_recent_packet(packet_record) def log_trace_record(self, packet_record: dict) -> None: """Manually log a packet trace record (used by external callers)""" - self.recent_packets.append(packet_record) + self._append_recent_packet(packet_record) self.rx_count += 1 if packet_record.get("transmitted", False): @@ -309,8 +431,102 @@ class RepeaterHandler(BaseHandler): except Exception as e: logger.error(f"Failed to store packet record: {e}") - if len(self.recent_packets) > self.max_recent_packets: - self.recent_packets.pop(0) + def record_packet_only(self, packet: Packet, metadata: dict) -> None: + """Record a packet for UI/storage without running forwarding or duplicate logic. + + Used by the packet router for injection-only types (ANON_REQ, ACK, PATH, etc.) + so they still appear in the web UI. + + TRACE packets are excluded: TraceHelper.log_trace_record stores the real trace path; + packet.path on TRACE holds SNR bytes, not routing hashes. + """ + if not self.storage: + return + rssi = metadata.get("rssi", 0) + snr = metadata.get("snr", 0.0) + if not hasattr(packet, "header") or packet.header is None: + logger.debug("record_packet_only: packet missing header, skipping") + return + header_info = PacketHeaderUtils.parse_header(packet.header) + payload_type = header_info["payload_type"] + route_type = header_info["route_type"] + if payload_type == PAYLOAD_TYPE_TRACE: + return + original_path_hashes = packet.get_path_hashes_hex() + path_hash_size = packet.get_path_hash_size() + path_hash = self._path_hash_display(original_path_hashes) + src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type) + packet_record = self._build_packet_record( + packet, + payload_type, + route_type, + rssi, + snr, + original_path_hashes, + path_hash_size, + path_hash, + src_hash, + dst_hash, + packet_hash=packet.calculate_packet_hash().hex().upper(), + ) + try: + self.storage.record_packet(packet_record, skip_mqtt_if_invalid=False) + except Exception as e: + logger.error(f"Failed to store packet record (record_packet_only): {e}") + return + self._append_recent_packet(packet_record) + + def record_duplicate(self, packet: Packet, rssi: int = 0, snr: float = 0.0) -> None: + """Record a known-duplicate packet for UI/storage visibility without forwarding. + + Called by the raw_packet_subscriber path so that path variants blocked + by the Dispatcher's payload-based dedup still appear in the UI. + """ + self.rx_count += 1 + route_type = packet.header & PH_ROUTE_MASK + if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD): + self.recv_flood_count += 1 + self.flood_dup_count += 1 + elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT): + self.recv_direct_count += 1 + self.direct_dup_count += 1 + + header_info = PacketHeaderUtils.parse_header(packet.header) + payload_type = header_info["payload_type"] + route_type_parsed = header_info["route_type"] + + original_path_hashes = packet.get_path_hashes_hex() + path_hash_size = packet.get_path_hash_size() + path_hash = self._path_hash_display(original_path_hashes) + src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type) + + packet_record = self._build_packet_record( + packet, payload_type, route_type_parsed, rssi, snr, + original_path_hashes, path_hash_size, path_hash, + src_hash, dst_hash, + transmitted=False, + drop_reason="Duplicate", + is_duplicate=True, + packet_hash=packet.calculate_packet_hash().hex().upper(), + ) + + if self.storage: + try: + self.storage.record_packet(packet_record, skip_mqtt_if_invalid=False) + except Exception as e: + logger.error(f"Failed to store duplicate record: {e}") + + # Group under original in recent_packets + if len(self.recent_packets) > 0: + prev_pkt = self._recent_hash_index.get(packet_record["packet_hash"]) + if prev_pkt is not None: + if "duplicates" not in prev_pkt: + prev_pkt["duplicates"] = [] + prev_pkt["duplicates"].append(packet_record) + else: + self._append_recent_packet(packet_record) + else: + self._append_recent_packet(packet_record) def cleanup_cache(self): @@ -319,9 +535,111 @@ class RepeaterHandler(BaseHandler): for k in expired: del self.seen_packets[k] - def _get_drop_reason(self, packet: Packet) -> str: + def _path_hash_display(self, display_hashes) -> Optional[str]: + """Build path hash string for packet record from path hashes list.""" + if not display_hashes: + return None + display = display_hashes[:8] + if len(display_hashes) > 8: + display = list(display) + ["..."] + return "[" + ", ".join(display) + "]" - if self.is_duplicate(packet): + def _packet_record_src_dst( + self, packet: Packet, payload_type: int + ) -> Tuple[Optional[str], Optional[str]]: + """Return (src_hash, dst_hash) for packet_record from packet and payload_type.""" + src_hash = None + dst_hash = None + payload = getattr(packet, "payload", None) + if payload_type in [0x00, 0x01, 0x02, 0x08]: + if payload and len(payload) >= 2: + dst_hash = f"{payload[0]:02X}" + src_hash = f"{payload[1]:02X}" + elif payload_type == PAYLOAD_TYPE_ADVERT: + if payload and len(payload) >= 1: + src_hash = f"{payload[0]:02X}" + elif payload_type == PAYLOAD_TYPE_ANON_REQ: + if payload and len(payload) >= 1: + dst_hash = f"{payload[0]:02X}" + return (src_hash, dst_hash) + + def _build_packet_record( + self, + packet: Packet, + payload_type: int, + route_type: int, + rssi: int, + snr: float, + original_path_hashes, + path_hash_size: int, + path_hash: Optional[str], + src_hash: Optional[str], + dst_hash: Optional[str], + *, + transmitted: bool = False, + drop_reason: Optional[str] = None, + is_duplicate: bool = False, + forwarded_path=None, + tx_delay_ms: float = 0.0, + lbt_attempts: int = 0, + lbt_backoff_delays_ms=None, + lbt_channel_busy: bool = False, + packet_hash: Optional[str] = None, + ) -> dict: + """Build a single packet_record dict for storage and recent_packets.""" + pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper() + payload = getattr(packet, "payload", None) + payload_len = len(payload or b"") + return { + "timestamp": time.time(), + "header": ( + f"0x{packet.header:02X}" + if hasattr(packet, "header") and packet.header is not None + else None + ), + "payload": payload.hex() if payload else None, + "payload_length": len(payload) if payload else 0, + "type": payload_type, + "route": route_type, + "length": payload_len, + "rssi": rssi, + "snr": snr, + "score": self.calculate_packet_score( + snr, payload_len, self.radio_config["spreading_factor"] + ), + "tx_delay_ms": tx_delay_ms, + "transmitted": transmitted, + "is_duplicate": is_duplicate, + "packet_hash": pkt_hash[:16], + "drop_reason": drop_reason, + "path_hash": path_hash, + "src_hash": src_hash, + "dst_hash": dst_hash, + "original_path": original_path_hashes or None, + "forwarded_path": forwarded_path, + "path_hash_size": path_hash_size, + "raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None, + "lbt_attempts": lbt_attempts, + "lbt_backoff_delays_ms": lbt_backoff_delays_ms, + "lbt_channel_busy": lbt_channel_busy, + } + + def _append_recent_packet(self, packet_record: dict) -> None: + """Append packet to bounded recent list and keep hash index aligned.""" + if len(self.recent_packets) >= self.max_recent_packets: + oldest = self.recent_packets.popleft() + oldest_hash = oldest.get("packet_hash") if isinstance(oldest, dict) else None + if oldest_hash and self._recent_hash_index.get(oldest_hash) is oldest: + del self._recent_hash_index[oldest_hash] + + self.recent_packets.append(packet_record) + pkt_hash = packet_record.get("packet_hash") if isinstance(packet_record, dict) else None + if pkt_hash: + self._recent_hash_index[pkt_hash] = packet_record + + def _get_drop_reason(self, packet: Packet, packet_hash: Optional[str] = None) -> str: + + if self.is_duplicate(packet, packet_hash=packet_hash): return "Duplicate" if not packet or not packet.payload: @@ -333,31 +651,40 @@ class RepeaterHandler(BaseHandler): route_type = packet.header & PH_ROUTE_MASK if route_type == ROUTE_TYPE_FLOOD: - # Check if global flood policy blocked it - global_flood_allow = self.config.get("mesh", {}).get("global_flood_allow", True) - if not global_flood_allow: - return "Global flood policy disabled" + # Check if unscoped flood policy blocked it + unscoped_flood_allow = self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True)) + if not unscoped_flood_allow: + return "Unscoped 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 return "Unknown" - def is_duplicate(self, packet: Packet) -> bool: + def is_duplicate(self, packet: Packet, packet_hash: Optional[str] = None) -> bool: + """Return True if this packet has already been seen. - pkt_hash = packet.calculate_packet_hash().hex().upper() - if pkt_hash in self.seen_packets: - return True - return False + Accepts an optional pre-computed packet_hash to avoid a redundant SHA-256 + when the caller (e.g. __call__ → process_packet → flood/direct_forward) + has already calculated the hash. Falls back to computing it if not provided. - def mark_seen(self, packet: Packet): + INVARIANT: this method is synchronous with no await points. The caller + (process_packet / __call__) relies on is_duplicate + mark_seen being + effectively atomic within the asyncio event loop. Do NOT add any await + here without revisiting that invariant. + """ + pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper() + return pkt_hash in self.seen_packets - pkt_hash = packet.calculate_packet_hash().hex().upper() + def mark_seen(self, packet: Packet, packet_hash: Optional[str] = None): + + pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper() self.seen_packets[pkt_hash] = time.time() if len(self.seen_packets) > self.max_cache_size: @@ -369,10 +696,41 @@ 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, "" + def _normalize_loop_detect_mode(self, mode) -> str: + if isinstance(mode, str): + normalized = mode.strip().lower() + if normalized in { + LOOP_DETECT_OFF, + LOOP_DETECT_MINIMAL, + LOOP_DETECT_MODERATE, + LOOP_DETECT_STRICT, + }: + return normalized + return LOOP_DETECT_OFF + + def _get_loop_detect_mode(self) -> str: + return self.loop_detect_mode + + def _is_flood_looped(self, packet: Packet, mode: Optional[str] = None) -> bool: + mode = mode or self._get_loop_detect_mode() + if mode == LOOP_DETECT_OFF: + return False + + max_counter = LOOP_DETECT_MAX_COUNTERS.get(mode) + if max_counter is None: + return False + + path = packet.path or bytearray() + local_count = sum(1 for hop in path if hop == self.local_hash) + return local_count >= max_counter + def _check_transport_codes(self, packet: Packet) -> Tuple[bool, str]: if not self.storage: @@ -381,11 +739,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 @@ -398,14 +758,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") @@ -414,47 +776,62 @@ 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}'") - + if logger.isEnabledFor(logging.DEBUG): + 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}'") + if logger.isEnabledFor(logging.DEBUG): + 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)") + if logger.isEnabledFor(logging.DEBUG): + 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}" - def flood_forward(self, packet: Packet) -> Optional[Packet]: + def flood_forward(self, packet: Packet, packet_hash: Optional[str] = None) -> Optional[Packet]: + """Forward a FLOOD packet, appending our hash to the path. + INVARIANT: purely synchronous — no await points. The is_duplicate + + mark_seen pair is atomic within the asyncio event loop. Do NOT add any + await here without revisiting that invariant in __call__ / process_packet. + """ # Validate valid, reason = self.validate_packet(packet) if not valid: @@ -467,23 +844,29 @@ class RepeaterHandler(BaseHandler): if not packet.drop_reason: packet.drop_reason = "Marked do not retransmit" return None - - # Check global flood policy - global_flood_allow = self.config.get("mesh", {}).get("global_flood_allow", True) - if not global_flood_allow: - route_type = packet.header & PH_ROUTE_MASK - if route_type == ROUTE_TYPE_FLOOD or route_type == ROUTE_TYPE_TRANSPORT_FLOOD: - - allowed, check_reason = self._check_transport_codes(packet) - if not allowed: - packet.drop_reason = check_reason - return None - else: - packet.drop_reason = "Global flood policy disabled" + + # Check unscoped flood policy + unscoped_flood_allow = self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True)) + route_type = packet.header & PH_ROUTE_MASK + if route_type == ROUTE_TYPE_FLOOD: + if not unscoped_flood_allow: + packet.drop_reason = "Unscoped flood policy disabled" return None - # Suppress duplicates - if self.is_duplicate(packet): + #Check transport scopes flood policy + if route_type == ROUTE_TYPE_TRANSPORT_FLOOD: + allowed, check_reason = self._check_transport_codes(packet) + if not allowed: + packet.drop_reason = "Transport code not allowed to flood" + return None + + mode = self._get_loop_detect_mode() + if self._is_flood_looped(packet, mode): + packet.drop_reason = f"FLOOD loop detected ({mode})" + return None + + # Suppress duplicates — pass pre-computed hash to avoid a second SHA-256. + if self.is_duplicate(packet, packet_hash=packet_hash): packet.drop_reason = "Duplicate" return None @@ -492,28 +875,73 @@ 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() - self.mark_seen(packet) + if self.max_flood_hops > 0 and hop_count >= self.max_flood_hops: + packet.drop_reason = f"Max flood hops limit reached ({hop_count}/{self.max_flood_hops})" + return None + + # path_len encodes hop count in 6 bits (0-63); adding ourselves must not exceed 63 + if hop_count >= 63: + packet.drop_reason = "Path hop count at maximum (63), cannot append" + return None + + # 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 + + self.mark_seen(packet, packet_hash=packet_hash) + + # 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) return packet - def direct_forward(self, packet: Packet) -> Optional[Packet]: + def direct_forward(self, packet: Packet, packet_hash: Optional[str] = None) -> Optional[Packet]: + """Forward a DIRECT packet, removing the first hop from the path. + + INVARIANT: purely synchronous — no await points. The is_duplicate + + mark_seen pair is atomic within the asyncio event loop. Do NOT add any + await here without revisiting that invariant in __call__ / process_packet. + """ + # Validate packet (empty payload, oversized path, etc.) + valid, reason = self.validate_packet(packet) + if not valid: + packet.drop_reason = reason + return None + + # Check if packet is marked do-not-retransmit + if packet.is_marked_do_not_retransmit(): + if not packet.drop_reason: + packet.drop_reason = "Marked do not retransmit" + return None + + 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 - original_path = list(packet.path) - packet.path = bytearray(packet.path[1:]) - packet.path_len = len(packet.path) + # Suppress duplicates — pass pre-computed hash to avoid a second SHA-256. + if self.is_duplicate(packet, packet_hash=packet_hash): + packet.drop_reason = "Duplicate" + return None + + self.mark_seen(packet, packet_hash=packet_hash) + + # 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) return packet @@ -545,10 +973,8 @@ class RepeaterHandler(BaseHandler): def _calculate_tx_delay(self, packet: Packet, snr: float = 0.0) -> float: - import random - - packet_len = len(packet.payload) if packet.payload else 0 - airtime_ms = PacketTimingUtils.estimate_airtime_ms(packet_len, self.radio_config) + packet_len = packet.get_raw_length() + airtime_ms = self.airtime_mgr.calculate_airtime(packet_len) route_type = packet.header & PH_ROUTE_MASK @@ -576,34 +1002,47 @@ class RepeaterHandler(BaseHandler): # score 0.0 → multiplier 1.0 (100% of original) score_multiplier = max(0.2, 1.0 - score) delay_s = delay_s * score_multiplier - logger.debug( - f"Congestion detected (delay >= 50ms), score={score:.2f}, " - f"delay multiplier={score_multiplier:.2f}" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + f"Congestion detected (delay >= 50ms), score={score:.2f}, " + f"delay multiplier={score_multiplier:.2f}" + ) # Cap at 5 seconds maximum delay_s = min(delay_s, 5.0) - logger.debug( - f"Route={'FLOOD' if route_type == ROUTE_TYPE_FLOOD else 'DIRECT'}, " - f"len={packet_len}B, airtime={airtime_ms:.1f}ms, delay={delay_s:.3f}s" - ) + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + f"Route={'FLOOD' if route_type == ROUTE_TYPE_FLOOD else 'DIRECT'}, " + f"len={packet_len}B, airtime={airtime_ms:.1f}ms, delay={delay_s:.3f}s" + ) return delay_s - def process_packet(self, packet: Packet, snr: float = 0.0) -> Optional[Tuple[Packet, float]]: + def process_packet( + self, + packet: Packet, + snr: float = 0.0, + packet_hash: Optional[str] = None, + ) -> Optional[Tuple[Packet, float]]: + """Route a received packet to flood_forward or direct_forward. + packet_hash is the pre-computed SHA-256 hex string from __call__. + Passing it here avoids recomputing the hash in flood_forward / + direct_forward / is_duplicate / mark_seen — reducing SHA-256 calls + from 3 per forwarded packet to 1. + """ route_type = packet.header & PH_ROUTE_MASK if route_type == ROUTE_TYPE_FLOOD or route_type == ROUTE_TYPE_TRANSPORT_FLOOD: - fwd_pkt = self.flood_forward(packet) + fwd_pkt = self.flood_forward(packet, packet_hash=packet_hash) if fwd_pkt is None: return None delay = self._calculate_tx_delay(fwd_pkt, snr) return fwd_pkt, delay elif route_type == ROUTE_TYPE_DIRECT or route_type == ROUTE_TYPE_TRANSPORT_DIRECT: - fwd_pkt = self.direct_forward(packet) + fwd_pkt = self.direct_forward(packet, packet_hash=packet_hash) if fwd_pkt is None: return None delay = self._calculate_tx_delay(fwd_pkt, snr) @@ -613,23 +1052,84 @@ 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): + 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 = len(fwd_pkt.payload) - logger.info( - f"Retransmitted packet ({packet_size} bytes, {airtime_ms:.1f}ms airtime)" - ) - except Exception as e: - logger.error(f"Retransmit failed: {e}") - asyncio.create_task(delayed_send()) + # Each attempt gets its own lock acquisition so the 1-second retry + # backoff (local_transmission only) happens OUTSIDE the lock. + # Holding _tx_lock across asyncio.sleep(1.0) would block every other + # queued TX task for the full backoff period. + # + # Loop runs once for relayed packets, twice for local_transmission: + # attempt 0 — initial try (no pre-sleep) + # attempt 1 — retry after 1s backoff outside the lock + for attempt in range(2 if local_transmission else 1): + if attempt > 0: + # Back-off OUTSIDE the lock — other tasks can transmit here. + logger.info("Retrying local TX in 1s (lock released during backoff)...") + await asyncio.sleep(1.0) + + async with self._tx_lock: + # ── Authoritative duty-cycle gate ────────────────────────── + # The upfront can_transmit() call in __call__ is advisory: it + # avoids scheduling packets obviously over budget, but cannot + # prevent a race between tasks whose delay timers expire nearly + # simultaneously. Both pass the advisory check before either + # records airtime, then both attempt to transmit. + # + # Inside _tx_lock only one task runs at a time. The check and + # record_tx() are effectively atomic — no TOCTOU window. + # Re-checked every attempt because airtime state may change + # while we wait for the lock or sleep through backoff. + if airtime_ms > 0: + can_tx_now, _ = self.airtime_mgr.can_transmit(airtime_ms) + if not can_tx_now: + logger.warning( + "Packet dropped at TX time: duty-cycle exceeded " + "(airtime=%.1fms)", airtime_ms, + ) + return + + try: + await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False) + self._record_packet_sent(fwd_pkt) + 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: + logger.error(f"Retransmit failed (attempt {attempt + 1}): {e}") + if local_transmission and attempt == 0: + pass # release lock, outer loop sleeps, then retries + else: + raise + + return asyncio.create_task(delayed_send()) + + def _record_packet_sent(self, packet: Packet) -> None: + """Record a packet send for flood/direct stats (forwarded and originated).""" + route = getattr(packet, "header", 0) & PH_ROUTE_MASK + if route in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD): + self.sent_flood_count += 1 + elif route in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT): + self.sent_direct_count += 1 def get_noise_floor(self) -> Optional[float]: try: @@ -641,6 +1141,10 @@ class RepeaterHandler(BaseHandler): logger.debug(f"Failed to get noise floor: {e}") return None + def get_cached_noise_floor(self) -> Optional[float]: + """Return the last asynchronously-sampled noise floor value.""" + return self._cached_noise_floor + def get_stats(self) -> dict: uptime_seconds = time.time() - self.start_time @@ -659,44 +1163,64 @@ class RepeaterHandler(BaseHandler): rx_per_hour = len(packets_last_hour) forwarded_per_hour = sum(1 for p in packets_last_hour if p.get("transmitted", False)) - # Get current noise floor from radio - noise_floor_dbm = self.get_noise_floor() + # Use cached value sampled by the background timer to avoid serial I/O on stats requests. + noise_floor_dbm = self.get_cached_noise_floor() + + # Get CRC error count from radio hardware + radio = self.dispatcher.radio if self.dispatcher else None + crc_error_count = getattr(radio, "crc_error_count", 0) if radio else 0 # Get neighbors from database neighbors = self.storage.get_neighbors() if self.storage else {} + # Format local_hash respecting path_hash_mode + phm = self.config.get("mesh", {}).get("path_hash_mode", 0) + _bc = {0: 1, 1: 2, 2: 3}.get(phm, 1) + _hc = _bc * 2 + _val = int.from_bytes(bytes(self.local_hash_bytes[:_bc]), "big") + local_hash_str = f"0x{_val:0{_hc}x}" + stats = { - "local_hash": f"0x{self.local_hash:02x}", + "local_hash": local_hash_str, "duplicate_cache_size": len(self.seen_packets), "cache_ttl": self.cache_ttl, "rx_count": self.rx_count, "forwarded_count": self.forwarded_count, "dropped_count": self.dropped_count, + "recv_flood_count": self.recv_flood_count, + "recv_direct_count": self.recv_direct_count, + "sent_flood_count": self.sent_flood_count, + "sent_direct_count": self.sent_direct_count, + "flood_dup_count": self.flood_dup_count, + "direct_dup_count": self.direct_dup_count, "rx_per_hour": rx_per_hour, "forwarded_per_hour": forwarded_per_hour, - "recent_packets": self.recent_packets, + "recent_packets": list(self.recent_packets), "neighbors": neighbors, "uptime_seconds": uptime_seconds, "noise_floor_dbm": noise_floor_dbm, + "crc_error_count": crc_error_count, # Add configuration data "config": { "node_name": repeater_config.get("node_name", "Unknown"), "repeater": { "mode": repeater_config.get("mode", "forward"), - "use_score_for_tx": self.use_score_for_tx, - "score_threshold": self.score_threshold, - "send_advert_interval_hours": self.send_advert_interval_hours, + "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 + ), "latitude": repeater_config.get("latitude", 0.0), "longitude": repeater_config.get("longitude", 0.0), + "max_flood_hops": repeater_config.get("max_flood_hops", 64), + "advert_interval_minutes": repeater_config.get("advert_interval_minutes", 120), + "advert_rate_limit": repeater_config.get("advert_rate_limit", {}), + "advert_penalty_box": repeater_config.get("advert_penalty_box", {}), + "advert_adaptive": repeater_config.get("advert_adaptive", {}), }, - "radio": { - "frequency": self.radio_config.get("frequency", 0), - "tx_power": self.radio_config.get("tx_power", 0), - "bandwidth": self.radio_config.get("bandwidth", 0), - "spreading_factor": self.radio_config.get("spreading_factor", 0), - "coding_rate": self.radio_config.get("coding_rate", 0), - "preamble_length": self.radio_config.get("preamble_length", 0), - }, + "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), @@ -704,7 +1228,15 @@ class RepeaterHandler(BaseHandler): "delays": { "tx_delay_factor": delays_config.get("tx_delay_factor", 1.0), "direct_tx_delay_factor": delays_config.get("direct_tx_delay_factor", 0.5), + "rx_delay_base": delays_config.get("rx_delay_base", 0.0), }, + "web": self.config.get("web", {}), # Include web configuration + "mesh": { + "loop_detect": self.config.get("mesh", {}).get("loop_detect", "off"), + "unscoped_flood_allow": self.config.get("mesh", {}).get("unscoped_flood_allow", self.config.get("mesh", {}).get("global_flood_allow", True)), + "path_hash_mode": self.config.get("mesh", {}).get("path_hash_mode", 0), + }, + "mqtt_brokers": self.config.get("mqtt_brokers", {}), }, "public_key": None, } @@ -725,6 +1257,7 @@ class RepeaterHandler(BaseHandler): # Check noise floor recording (every 30 seconds) if current_time - self.last_noise_measurement >= self.noise_floor_interval: await self._record_noise_floor_async() + await self._record_crc_errors_async() self.last_noise_measurement = current_time # Check advert sending (every N hours) @@ -734,6 +1267,27 @@ class RepeaterHandler(BaseHandler): await self._send_periodic_advert_async() self.last_advert_time = current_time + # Prune expired entries from duplicate detection cache (every 60s) + if current_time - self.last_cache_cleanup >= 60.0: + self.cleanup_cache() + self.last_cache_cleanup = current_time + + # Prune old SQLite data (check every 6 hours) + if current_time - self.last_db_cleanup >= 21600: + if self.storage: + try: + retention_days = ( + self.config + .get("storage", {}) + .get("retention", {}) + .get("sqlite_cleanup_days", 31) + ) + self.storage.cleanup_old_data(days=retention_days) + logger.info("Cleaned up SQLite data older than %d days", retention_days) + except Exception as e: + logger.warning(f"SQLite cleanup failed: {e}") + self.last_db_cleanup = current_time + # Sleep for 5 seconds before next check await asyncio.sleep(5.0) @@ -751,8 +1305,12 @@ 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._cached_noise_floor = noise_floor self.storage.record_noise_floor(noise_floor) logger.debug(f"Recorded noise floor: {noise_floor} dBm") else: @@ -760,6 +1318,22 @@ class RepeaterHandler(BaseHandler): except Exception as e: logger.error(f"Error recording noise floor: {e}") + async def _record_crc_errors_async(self): + """Persist CRC error delta from the radio hardware counter.""" + if not self.storage: + return + + try: + radio = self.dispatcher.radio if self.dispatcher else None + current = getattr(radio, "crc_error_count", 0) if radio else 0 + delta = current - self._last_crc_error_count + if delta > 0: + self.storage.record_crc_errors(delta) + logger.debug(f"Recorded {delta} CRC errors (total: {current})") + self._last_crc_error_count = current + except Exception as e: + logger.error(f"Error recording CRC errors: {e}") + async def _send_periodic_advert_async(self): logger.info( f"Periodic advert timer triggered (interval: {self.send_advert_interval_hours}h)" @@ -776,6 +1350,33 @@ class RepeaterHandler(BaseHandler): except Exception as e: logger.error(f"Error sending periodic advert: {e}") + def reload_runtime_config(self): + """Reload runtime configuration from self.config (called after live config updates).""" + 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 + ) + + # Refresh repeater settings + repeater_config = self.config.get("repeater", {}) + self.use_score_for_tx = repeater_config.get("use_score_for_tx", False) + self.score_threshold = repeater_config.get("score_threshold", 0.3) + self.send_advert_interval_hours = repeater_config.get("send_advert_interval_hours", 10) + self.cache_ttl = repeater_config.get("cache_ttl", 60) + self.max_flood_hops = repeater_config.get("max_flood_hops", 64) + self.loop_detect_mode = self._normalize_loop_detect_mode( + self.config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF) + ) + + # Note: Radio config changes require restart as they affect hardware + # Note: Airtime manager has its own config reference that gets updated + + logger.info("Runtime configuration reloaded successfully") + except Exception as e: + logger.error(f"Error reloading runtime config: {e}") + def cleanup(self): if self._background_task and not self._background_task.done(): self._background_task.cancel() diff --git a/repeater/handler_helpers/__init__.py b/repeater/handler_helpers/__init__.py index 08f5104..3518ca2 100644 --- a/repeater/handler_helpers/__init__.py +++ b/repeater/handler_helpers/__init__.py @@ -1,7 +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 .path import PathHelper +from .protocol_request import ProtocolRequestHelper +from .text import TextHelper +from .trace import TraceHelper -__all__ = ["TraceHelper", "DiscoveryHelper", "AdvertHelper"] +__all__ = [ + "TraceHelper", + "DiscoveryHelper", + "AdvertHelper", + "LoginHelper", + "TextHelper", + "PathHelper", + "ProtocolRequestHelper", +] diff --git a/repeater/handler_helpers/acl.py b/repeater/handler_helpers/acl.py new file mode 100644 index 0000000..3351999 --- /dev/null +++ b/repeater/handler_helpers/acl.py @@ -0,0 +1,179 @@ +import logging +import time +from typing import Dict, Optional + +from pymc_core.protocol import Identity +from pymc_core.protocol.constants import PUB_KEY_SIZE + +logger = logging.getLogger("ACL") + +PERM_ACL_GUEST = 0x01 +PERM_ACL_ADMIN = 0x02 +PERM_ACL_READ_WRITE = 0x01 +PERM_ACL_ROLE_MASK = 0x03 + + +class ClientInfo: + """Represents an authenticated client in the access control list.""" + + def __init__(self, identity: Identity, permissions: int = 0): + self.id = identity + self.permissions = permissions + self.shared_secret = b"" + self.last_timestamp = 0 + self.last_activity = 0 + self.last_login_success = 0 + self.out_path_len = -1 + self.out_path = bytearray() + self.sync_since = 0 # For room servers - timestamp of last synced message + + def is_admin(self) -> bool: + return (self.permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_ADMIN + + def is_guest(self) -> bool: + return (self.permissions & PERM_ACL_ROLE_MASK) == PERM_ACL_GUEST + + +class ACL: + + def __init__( + self, + max_clients: int = 50, + admin_password: str = "admin123", + guest_password: str = "guest123", + allow_read_only: bool = True, + ): + self.max_clients = max_clients + self.admin_password = admin_password + self.guest_password = guest_password + self.allow_read_only = allow_read_only + self.clients: Dict[bytes, ClientInfo] = {} + + def authenticate_client( + self, + client_identity: Identity, + shared_secret: bytes, + password: str, + timestamp: int, + sync_since: int = None, + target_identity_hash: int = None, + target_identity_name: str = None, + target_identity_config: dict = None, + ) -> tuple[bool, int]: + + target_identity_config = target_identity_config or {} + + # Check for identity-specific passwords (required for room servers) + identity_settings = target_identity_config.get("settings", {}) + + # Determine if this is a room server by checking the type field + identity_type = target_identity_config.get("type", "") + is_room_server = identity_type == "room_server" + + # Log sync_since if provided (room server format) + if sync_since is not None: + logger.debug(f"Client sync_since timestamp: {sync_since}") + + if is_room_server: + # Room servers use passwords from their settings section only + # 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." + ) + return False, 0 + else: + # Repeater uses global passwords from its own security section + admin_pwd = self.admin_password + guest_pwd = self.guest_password + logger.debug( + 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})" + ) + + pub_key = client_identity.get_public_key()[:PUB_KEY_SIZE] + + if not password: + client = self.clients.get(pub_key) + if client is None: + if self.allow_read_only: + logger.info("Blank password, allowing read-only guest access") + return True, PERM_ACL_GUEST + else: + logger.info("Blank password, sender not in ACL and read-only disabled") + return False, 0 + logger.info(f"ACL-based login for {pub_key[:6].hex()}...") + return True, client.permissions + + 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)" + ) + if admin_pwd and password == admin_pwd: + permissions = PERM_ACL_ADMIN + logger.info(f"Admin password validated for '{target_identity_name or 'unknown'}'") + elif guest_pwd and password == guest_pwd: + permissions = PERM_ACL_READ_WRITE + logger.info(f"Guest password validated for '{target_identity_name or 'unknown'}'") + else: + logger.info(f"Invalid password for '{target_identity_name or 'unknown'}'") + return False, 0 + + client = self.clients.get(pub_key) + if client is None: + if len(self.clients) >= self.max_clients: + logger.warning("ACL full, cannot add client") + return False, 0 + + client = ClientInfo(client_identity, 0) + self.clients[pub_key] = client + logger.info(f"Added new client {pub_key[:6].hex()}...") + + if timestamp <= client.last_timestamp: + logger.warning( + f"Possible replay attack! timestamp={timestamp}, last={client.last_timestamp}" + ) + return False, 0 + + client.last_timestamp = timestamp + client.last_activity = int(time.time()) + client.last_login_success = int(time.time()) + client.permissions &= ~PERM_ACL_ROLE_MASK + client.permissions |= permissions + client.shared_secret = shared_secret + + # Store sync_since for room server clients + if sync_since is not None: + client.sync_since = sync_since + logger.debug(f"Stored sync_since={sync_since} for client") + + logger.info(f"Login success! Permissions: {'ADMIN' if client.is_admin() else 'GUEST'}") + return True, client.permissions + + def get_client(self, pub_key: bytes) -> Optional[ClientInfo]: + return self.clients.get(pub_key[:PUB_KEY_SIZE]) + + def get_num_clients(self) -> int: + return len(self.clients) + + def get_all_clients(self): + return list(self.clients.values()) + + def remove_client(self, pub_key: bytes) -> bool: + key = pub_key[:PUB_KEY_SIZE] + if key in self.clients: + del self.clients[key] + return True + return False diff --git a/repeater/handler_helpers/advert.py b/repeater/handler_helpers/advert.py index 1ab47b6..e95f5e0 100644 --- a/repeater/handler_helpers/advert.py +++ b/repeater/handler_helpers/advert.py @@ -2,20 +2,43 @@ Advertisement packet handling helper for pyMC Repeater. This module processes advertisement packets for neighbor tracking and discovery. +Includes adaptive rate limiting based on mesh activity. """ +import asyncio import logging import time +import itertools +from collections import OrderedDict, deque +from enum import Enum +from typing import Dict, Optional, Tuple from pymc_core.node.handlers.advert import AdvertHandler logger = logging.getLogger("AdvertHelper") +class MeshActivityTier(Enum): + """Mesh activity levels for adaptive rate limiting.""" + QUIET = "quiet" + NORMAL = "normal" + BUSY = "busy" + CONGESTED = "congested" + + +# Tier multipliers for rate limit scaling +TIER_MULTIPLIERS = { + MeshActivityTier.QUIET: 0.0, # No rate limiting + MeshActivityTier.NORMAL: 0.5, # Light limiting + MeshActivityTier.BUSY: 1.0, # Standard limiting + MeshActivityTier.CONGESTED: 2.0, # Aggressive limiting +} + + class AdvertHelper: """Helper class for processing advertisement packets in the repeater.""" - def __init__(self, local_identity, storage, log_fn=None): + def __init__(self, local_identity, storage, config=None, log_fn=None): """ Initialize the advert helper. @@ -26,6 +49,7 @@ class AdvertHelper: """ self.local_identity = local_identity self.storage = storage + self.config = config or {} # Create AdvertHandler internally as a parsing utility self.advert_handler = AdvertHandler(log_fn=log_fn or logger.info) @@ -33,6 +57,467 @@ class AdvertHelper: # Cache for tracking known neighbors (avoid repeated database queries) self._known_neighbors = set() + repeater_cfg = self.config.get("repeater", {}) + + # --- Adaptive mode config --- + adaptive_cfg = repeater_cfg.get("advert_adaptive", {}) + self._adaptive_enabled = bool(adaptive_cfg.get("enabled", True)) + self._ewma_alpha = max(0.01, min(1.0, float(adaptive_cfg.get("ewma_alpha", 0.1)))) + self._tier_hysteresis_seconds = max(0.0, float(adaptive_cfg.get("hysteresis_seconds", 300.0))) + + # Tier thresholds (packets per minute) + thresholds = adaptive_cfg.get("thresholds", {}) + self._threshold_normal = float(thresholds.get("normal", 1.0)) + self._threshold_busy = float(thresholds.get("busy", 5.0)) + self._threshold_congested = float(thresholds.get("congested", 15.0)) + + # --- Base rate limit config (scaled by tier) --- + rate_cfg = repeater_cfg.get("advert_rate_limit", {}) + self._rate_limit_enabled = bool(rate_cfg.get("enabled", True)) + self._base_bucket_capacity = max(1.0, float(rate_cfg.get("bucket_capacity", 2))) + self._base_refill_tokens = max(0.1, float(rate_cfg.get("refill_tokens", 1.0))) + self._base_refill_interval = max(1.0, float(rate_cfg.get("refill_interval_seconds", 36000.0))) + self._base_min_interval = max(0.0, float(rate_cfg.get("min_interval_seconds", 3600.0))) + + # --- Penalty box config --- + penalty_cfg = repeater_cfg.get("advert_penalty_box", {}) + self._penalty_enabled = bool(penalty_cfg.get("enabled", True)) + self._penalty_violation_threshold = max(1, int(penalty_cfg.get("violation_threshold", 2))) + self._penalty_decay_seconds = max(1.0, float(penalty_cfg.get("violation_decay_seconds", 43200.0))) + self._penalty_base_seconds = max(1.0, float(penalty_cfg.get("base_penalty_seconds", 21600.0))) + self._penalty_multiplier = max(1.0, float(penalty_cfg.get("penalty_multiplier", 2.0))) + self._penalty_max_seconds = max( + self._penalty_base_seconds, + float(penalty_cfg.get("max_penalty_seconds", 86400.0)), + ) + + # --- Advert dedupe config --- + dedupe_cfg = repeater_cfg.get("advert_dedupe", {}) + self._advert_dedupe_ttl_seconds = max(1.0, float(dedupe_cfg.get("ttl_seconds", 120.0))) + self._advert_dedupe_max_hashes = max(100, int(dedupe_cfg.get("max_hashes", 10000))) + + # --- Per-pubkey state --- + self._bucket_state: Dict[str, dict] = {} + self._penalty_until: Dict[str, float] = {} + self._violation_state: Dict[str, dict] = {} + self._recent_advert_hashes: OrderedDict[str, float] = OrderedDict() + + # --- Adaptive metrics state --- + self._adverts_ewma = 0.0 # EWMA of adverts per minute + self._packets_ewma = 0.0 # EWMA of total packets per minute + self._duplicates_ewma = 0.0 # EWMA of duplicate ratio + self._last_metrics_update = time.time() + self._metrics_window_seconds = 60.0 + self._adverts_in_window = 0 + self._packets_in_window = 0 + self._duplicates_in_window = 0 + + # Current activity tier with hysteresis + self._current_tier = MeshActivityTier.NORMAL + self._tier_since = time.time() + self._pending_tier: Optional[MeshActivityTier] = None + self._pending_tier_since = 0.0 + + # Stats counters + self._stats_adverts_allowed = 0 + self._stats_adverts_dropped = 0 + self._stats_advert_duplicates = 0 + self._stats_tier_changes = 0 + + # Recent drops tracking — bounded deque so append is O(1) and the + # oldest entry is evicted automatically (no pop(0) O(n) shift needed). + self._recent_drops: deque = deque(maxlen=20) + + # Memory management + self._last_cleanup = time.time() + self._cleanup_interval_seconds = 3600.0 # Clean up every hour + self._bucket_state_retention_seconds = 604800.0 # Keep inactive pubkeys for 7 days + self._max_tracked_pubkeys = 10000 # Hard limit on tracked pubkeys + + logger.info( + f"Advert limiter: adaptive={self._adaptive_enabled}, " + f"rate_limit={self._rate_limit_enabled}, " + f"bucket={self._base_bucket_capacity:.1f}, " + f"penalty={self._penalty_enabled}, " + f"dedupe=True" + ) + + # ------------------------------------------------------------------------- + # Memory management + # ------------------------------------------------------------------------- + + def _cleanup_old_state(self, now: float) -> None: + """Clean up old/expired entries to prevent unbounded memory growth.""" + while self._recent_advert_hashes: + oldest_hash, expires_at = next(iter(self._recent_advert_hashes.items())) + if expires_at > now: + break + self._recent_advert_hashes.pop(oldest_hash, None) + + while len(self._recent_advert_hashes) > self._advert_dedupe_max_hashes: + self._recent_advert_hashes.popitem(last=False) + + + expired_penalties = [pk for pk, until in self._penalty_until.items() if until < now] + for pk in expired_penalties: + del self._penalty_until[pk] + + + inactive_pubkeys = [ + pk for pk, state in self._bucket_state.items() + if now - state.get("last_seen", 0) > self._bucket_state_retention_seconds + ] + for pk in inactive_pubkeys: + del self._bucket_state[pk] + if pk in self._violation_state: + del self._violation_state[pk] + + # 3. Decay old violations based on decay time + for pk, vstate in list(self._violation_state.items()): + last_violation = vstate.get("last_violation", 0) + if now - last_violation > self._penalty_decay_seconds: + # Reset violation count after decay period + vstate["count"] = 0 + + if len(self._bucket_state) > self._max_tracked_pubkeys: + # Sort by last_seen and remove oldest 10% + sorted_pubkeys = sorted( + self._bucket_state.items(), + key=lambda x: x[1].get("last_seen", 0) + ) + to_remove = int(len(sorted_pubkeys) * 0.1) + for pk, _ in sorted_pubkeys[:to_remove]: + del self._bucket_state[pk] + if pk in self._violation_state: + del self._violation_state[pk] + if pk in self._penalty_until: + del self._penalty_until[pk] + + # 5. Limit known neighbors set to prevent unbounded growth + if len(self._known_neighbors) > 1000: + # itertools.islice avoids materialising the full list first (O(n) → O(k)) + self._known_neighbors = set(itertools.islice(self._known_neighbors, 500)) + + if expired_penalties or inactive_pubkeys: + logger.debug( + f"Cleaned up {len(expired_penalties)} expired penalties, " + f"{len(inactive_pubkeys)} inactive pubkeys. " + f"Tracking: {len(self._bucket_state)} buckets, " + f"{len(self._penalty_until)} penalties, " + f"{len(self._known_neighbors)} neighbors, " + f"{len(self._recent_advert_hashes)} advert hashes" + ) + + def _dedupe_advert_packet_hash(self, packet, now: float) -> bool: + """Return True when advert packet hash was already seen recently.""" + try: + pkt_hash = packet.calculate_packet_hash().hex().upper() + except Exception: + return False + + expires_at = self._recent_advert_hashes.get(pkt_hash) + if expires_at and expires_at > now: + # Move to end so hot hashes remain least likely to be evicted + self._recent_advert_hashes.move_to_end(pkt_hash) + return True + + # Track first-seen (or expired hash re-seen) + self._recent_advert_hashes[pkt_hash] = now + self._advert_dedupe_ttl_seconds + self._recent_advert_hashes.move_to_end(pkt_hash) + + # Opportunistic cleanup to keep memory bounded between scheduled cleanup runs + while len(self._recent_advert_hashes) > self._advert_dedupe_max_hashes: + self._recent_advert_hashes.popitem(last=False) + + return False + + # ------------------------------------------------------------------------- + # Adaptive tier calculation + # ------------------------------------------------------------------------- + + def _update_metrics_window(self, now: float, is_advert: bool = True, is_duplicate: bool = False) -> None: + """Update rolling metrics window and EWMA.""" + elapsed = now - self._last_metrics_update + + if elapsed >= self._metrics_window_seconds: + # Calculate rates for window + adverts_per_min = (self._adverts_in_window / elapsed) * 60.0 + packets_per_min = (self._packets_in_window / elapsed) * 60.0 + dup_ratio = ( + self._duplicates_in_window / max(1, self._packets_in_window) + ) + + # Update EWMA + alpha = self._ewma_alpha + self._adverts_ewma = alpha * adverts_per_min + (1 - alpha) * self._adverts_ewma + self._packets_ewma = alpha * packets_per_min + (1 - alpha) * self._packets_ewma + self._duplicates_ewma = alpha * dup_ratio + (1 - alpha) * self._duplicates_ewma + + # Reset window + self._adverts_in_window = 0 + self._packets_in_window = 0 + self._duplicates_in_window = 0 + self._last_metrics_update = now + + # Periodic cleanup + if now - self._last_cleanup >= self._cleanup_interval_seconds: + self._cleanup_old_state(now) + self._last_cleanup = now + + # Count this event + if is_advert: + self._adverts_in_window += 1 + self._packets_in_window += 1 + if is_duplicate: + self._duplicates_in_window += 1 + + def _calculate_target_tier(self) -> MeshActivityTier: + """Determine target tier based on current EWMA metrics.""" + # Combined activity score (adverts + packets weighted) + activity = self._adverts_ewma + (self._packets_ewma * 0.1) + + if activity >= self._threshold_congested: + return MeshActivityTier.CONGESTED + elif activity >= self._threshold_busy: + return MeshActivityTier.BUSY + elif activity >= self._threshold_normal: + return MeshActivityTier.NORMAL + else: + return MeshActivityTier.QUIET + + def _update_tier(self, now: float) -> None: + """Update current tier with hysteresis to prevent flapping.""" + if not self._adaptive_enabled: + return + + target = self._calculate_target_tier() + + if target == self._current_tier: + # Stable, clear pending + self._pending_tier = None + return + + if self._pending_tier != target: + # New pending tier + self._pending_tier = target + self._pending_tier_since = now + return + + # Check hysteresis + if (now - self._pending_tier_since) >= self._tier_hysteresis_seconds: + old_tier = self._current_tier + self._current_tier = target + self._tier_since = now + self._pending_tier = None + self._stats_tier_changes += 1 + logger.info(f"Mesh activity tier changed: {old_tier.value} → {target.value}") + + def get_current_tier(self) -> MeshActivityTier: + """Get current mesh activity tier.""" + return self._current_tier + + def _get_effective_limits(self) -> Tuple[float, float, float, float]: + """Get effective rate limits scaled by current tier.""" + if not self._adaptive_enabled: + return ( + self._base_bucket_capacity, + self._base_refill_tokens, + self._base_refill_interval, + self._base_min_interval, + ) + + multiplier = TIER_MULTIPLIERS.get(self._current_tier, 1.0) + + if multiplier == 0.0: + # QUIET mode: effectively disable rate limiting + return (100.0, 100.0, 1.0, 0.0) + + # Scale intervals UP (stricter) as multiplier increases + return ( + self._base_bucket_capacity, + self._base_refill_tokens, + self._base_refill_interval * multiplier, + self._base_min_interval * multiplier, + ) + + def _refill_tokens_if_needed(self, pubkey: str, now: float) -> dict: + """Refill token bucket using effective (tier-scaled) limits.""" + bucket_cap, refill_tokens, refill_interval, _ = self._get_effective_limits() + + state = self._bucket_state.get(pubkey) + if state is None: + state = { + "tokens": bucket_cap, + "last_refill": now, + "last_seen": 0.0, + } + self._bucket_state[pubkey] = state + return state + + elapsed = now - state["last_refill"] + if elapsed <= 0: + return state + + refill_steps = elapsed / refill_interval + if refill_steps > 0: + state["tokens"] = min( + bucket_cap, + state["tokens"] + (refill_steps * refill_tokens), + ) + state["last_refill"] = now + return state + + def _record_violation_and_maybe_penalize(self, pubkey: str, now: float) -> None: + if not self._penalty_enabled: + return + + state = self._violation_state.get(pubkey) + if state is None: + state = {"count": 0, "last_violation": 0.0} + self._violation_state[pubkey] = state + + if (now - state["last_violation"]) > self._penalty_decay_seconds: + state["count"] = 0 + + state["count"] += 1 + state["last_violation"] = now + + if state["count"] < self._penalty_violation_threshold: + return + + level = state["count"] - self._penalty_violation_threshold + penalty_seconds = min( + self._penalty_max_seconds, + self._penalty_base_seconds * (self._penalty_multiplier**level), + ) + new_until = now + penalty_seconds + old_until = self._penalty_until.get(pubkey, 0.0) + + if new_until > old_until: + self._penalty_until[pubkey] = new_until + logger.warning( + f"Advert penalty activated for {pubkey[:16]}... " + f"({penalty_seconds:.1f}s, violations={state['count']})" + ) + + def _allow_advert(self, pubkey: str, now: float) -> Tuple[bool, str]: + """Check if advert is allowed using adaptive tier-scaled limits.""" + # Update metrics and tier + self._update_metrics_window(now, is_advert=True) + self._update_tier(now) + + if not self._rate_limit_enabled: + self._stats_adverts_allowed += 1 + return True, "" + + # QUIET tier bypasses rate limiting + if self._adaptive_enabled and self._current_tier == MeshActivityTier.QUIET: + self._stats_adverts_allowed += 1 + return True, "" + + penalty_until = self._penalty_until.get(pubkey, 0.0) + if now < penalty_until: + remaining = penalty_until - now + self._stats_adverts_dropped += 1 + return False, f"advert penalty box active ({remaining:.1f}s remaining)" + + state = self._refill_tokens_if_needed(pubkey, now) + _, _, _, min_interval = self._get_effective_limits() + + last_seen = float(state.get("last_seen", 0.0)) + if min_interval > 0 and last_seen > 0: + since_last = now - last_seen + if since_last < min_interval: + self._record_violation_and_maybe_penalize(pubkey, now) + self._stats_adverts_dropped += 1 + return ( + False, + f"advert min-interval hit ({since_last:.2f}s < {min_interval:.2f}s)", + ) + + if state["tokens"] < 1.0: + self._record_violation_and_maybe_penalize(pubkey, now) + self._stats_adverts_dropped += 1 + return False, "advert rate limit exceeded" + + state["tokens"] -= 1.0 + state["last_seen"] = now + self._stats_adverts_allowed += 1 + return True, "" + + def record_packet_seen(self, is_duplicate: bool = False) -> None: + """Record a packet seen for metrics (called by router for non-advert packets).""" + now = time.time() + self._update_metrics_window(now, is_advert=False, is_duplicate=is_duplicate) + + def get_rate_limit_stats(self) -> dict: + """Get comprehensive rate limiting and adaptive tier statistics.""" + now = time.time() + bucket_cap, refill_tokens, refill_interval, min_interval = self._get_effective_limits() + + # Active penalties + active_penalties = { + pk[:16]: round(until - now, 1) + for pk, until in self._penalty_until.items() + if until > now + } + + # Per-pubkey bucket states + bucket_summary = {} + for pk, state in self._bucket_state.items(): + bucket_summary[pk[:16]] = { + "tokens": round(state["tokens"], 2), + "last_seen_ago": round(now - state["last_seen"], 1) if state["last_seen"] > 0 else None, + } + + return { + "adaptive": { + "enabled": self._adaptive_enabled, + "current_tier": self._current_tier.value, + "tier_since": round(now - self._tier_since, 1), + "pending_tier": self._pending_tier.value if self._pending_tier else None, + "tier_changes": self._stats_tier_changes, + }, + "metrics": { + "adverts_per_min_ewma": round(self._adverts_ewma, 2), + "packets_per_min_ewma": round(self._packets_ewma, 2), + "duplicate_ratio_ewma": round(self._duplicates_ewma, 3), + }, + "effective_limits": { + "bucket_capacity": bucket_cap, + "refill_tokens": refill_tokens, + "refill_interval_seconds": round(refill_interval, 1), + "min_interval_seconds": round(min_interval, 1), + }, + "stats": { + "adverts_allowed": self._stats_adverts_allowed, + "adverts_dropped": self._stats_adverts_dropped, + "adverts_duplicate_reheard": self._stats_advert_duplicates, + "drop_rate": round( + self._stats_adverts_dropped / max(1, self._stats_adverts_allowed + self._stats_adverts_dropped), + 3, + ), + }, + "dedupe": { + "enabled": True, + "ttl_seconds": self._advert_dedupe_ttl_seconds, + "tracked_hashes": len(self._recent_advert_hashes), + "max_hashes": self._advert_dedupe_max_hashes, + }, + "active_penalties": active_penalties, + "tracked_pubkeys": len(self._bucket_state), + "bucket_states": bucket_summary, + "recent_drops": [ + { + "pubkey": drop["pubkey"], + "name": drop["name"], + "reason": drop["reason"], + "seconds_ago": round(now - drop["timestamp"], 1) + } + for drop in reversed(self._recent_drops) # Most recent first + ], + } + async def process_advert_packet(self, packet, rssi: int, snr: float) -> None: """ Process an incoming advertisement packet. @@ -63,6 +548,45 @@ class AdvertHelper: pubkey = advert_data["public_key"] node_name = advert_data["name"] contact_type = advert_data["contact_type"] + + now = time.time() + + # Re-heard duplicates should be measured but not consume limiter tokens. + if self._dedupe_advert_packet_hash(packet, now): + self._stats_advert_duplicates += 1 + self._update_metrics_window(now, is_advert=False, is_duplicate=True) + logger.debug( + "Duplicate advert re-heard from '%s' (%s...), skipping limiter/storage", + node_name, + pubkey[:16], + ) + return + + # Per-pubkey rate limiting (token bucket + penalty box) + allowed, reason = self._allow_advert(pubkey, now) + if not allowed: + logger.warning(f"Dropping advert from '{node_name}' ({pubkey[:16]}...): {reason}") + packet.mark_do_not_retransmit() + packet.drop_reason = reason + + # Track recent drop (deduplicate by pubkey) + pubkey_short = pubkey[:16] + + # Remove any existing entry for this pubkey, then append the + # updated record. Rebuilding as a deque preserves maxlen so + # the oldest entry is evicted automatically — no pop(0) needed. + self._recent_drops = deque( + (d for d in self._recent_drops if d["pubkey"] != pubkey_short), + maxlen=20, + ) + self._recent_drops.append({ + "pubkey": pubkey_short, + "name": node_name, + "reason": reason, + "timestamp": now + }) + + return # Skip our own adverts if self.local_identity: @@ -70,16 +594,22 @@ 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() + + # Check if this is a new neighbor (run DB read in thread to avoid blocking event loop) + current_time = now 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: @@ -88,6 +618,11 @@ class AdvertHelper: 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, @@ -101,15 +636,68 @@ class AdvertHelper: "rssi": rssi, "snr": snr, "is_new_neighbor": is_new_neighbor, - "zero_hop": route_type in [0x02, 0x03], # True for direct routes (no intermediate hops) + "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}") except Exception as e: logger.error(f"Error processing advert packet: {e}", exc_info=True) + + def reload_config(self) -> None: + """Reload rate limiting configuration from self.config (called after live config updates).""" + try: + repeater_cfg = self.config.get("repeater", {}) + + # Adaptive mode config + adaptive_cfg = repeater_cfg.get("advert_adaptive", {}) + self._adaptive_enabled = bool(adaptive_cfg.get("enabled", True)) + self._ewma_alpha = max(0.01, min(1.0, float(adaptive_cfg.get("ewma_alpha", 0.1)))) + self._tier_hysteresis_seconds = max(0.0, float(adaptive_cfg.get("hysteresis_seconds", 300.0))) + + thresholds = adaptive_cfg.get("thresholds", {}) + self._threshold_normal = float(thresholds.get("normal", 1.0)) + self._threshold_busy = float(thresholds.get("busy", 5.0)) + self._threshold_congested = float(thresholds.get("congested", 15.0)) + + # Base rate limit config + rate_cfg = repeater_cfg.get("advert_rate_limit", {}) + self._rate_limit_enabled = bool(rate_cfg.get("enabled", True)) + self._base_bucket_capacity = max(1.0, float(rate_cfg.get("bucket_capacity", 2))) + self._base_refill_tokens = max(0.1, float(rate_cfg.get("refill_tokens", 1.0))) + self._base_refill_interval = max(1.0, float(rate_cfg.get("refill_interval_seconds", 36000.0))) + self._base_min_interval = max(0.0, float(rate_cfg.get("min_interval_seconds", 3600.0))) + + # Penalty box config + penalty_cfg = repeater_cfg.get("advert_penalty_box", {}) + self._penalty_enabled = bool(penalty_cfg.get("enabled", True)) + self._penalty_violation_threshold = max(1, int(penalty_cfg.get("violation_threshold", 2))) + self._penalty_decay_seconds = max(1.0, float(penalty_cfg.get("violation_decay_seconds", 43200.0))) + self._penalty_base_seconds = max(1.0, float(penalty_cfg.get("base_penalty_seconds", 21600.0))) + self._penalty_multiplier = max(1.0, float(penalty_cfg.get("penalty_multiplier", 2.0))) + self._penalty_max_seconds = max( + self._penalty_base_seconds, + float(penalty_cfg.get("max_penalty_seconds", 86400.0)), + ) + + # Advert dedupe config + dedupe_cfg = repeater_cfg.get("advert_dedupe", {}) + self._advert_dedupe_ttl_seconds = max(1.0, float(dedupe_cfg.get("ttl_seconds", 120.0))) + self._advert_dedupe_max_hashes = max(100, int(dedupe_cfg.get("max_hashes", 10000))) + + logger.info( + f"Advert limiter config reloaded: adaptive={self._adaptive_enabled}, " + f"rate_limit={self._rate_limit_enabled}, bucket={self._base_bucket_capacity:.1f}, " + f"dedupe=True" + ) + except Exception as e: + logger.error(f"Error reloading advert limiter config: {e}") diff --git a/repeater/handler_helpers/discovery.py b/repeater/handler_helpers/discovery.py index 8174bbc..8153747 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") @@ -21,6 +22,7 @@ class DiscoveryHelper: packet_injector=None, node_type: int = 2, log_fn=None, + debug_log_fn=None, ): """ Initialize the discovery helper. @@ -30,18 +32,38 @@ 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, + ) + self._pending_tasks = set() # Set up the request callback self.control_handler.set_request_callback(self._on_discovery_request) logger.debug("Discovery handler initialized") + def _track_task(self, task: asyncio.Task) -> None: + self._pending_tasks.add(task) + + def _on_done(done_task: asyncio.Task) -> None: + self._pending_tasks.discard(done_task) + try: + done_task.result() + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"Background discovery task failed: {e}", exc_info=True) + + task.add_done_callback(_on_done) + def _on_discovery_request(self, request_data: dict) -> None: """ Handle incoming discovery request. @@ -108,7 +130,8 @@ class DiscoveryHelper: # Send response via router injection if self.packet_injector: - asyncio.create_task(self._send_packet_async(response_packet, tag)) + task = asyncio.create_task(self._send_packet_async(response_packet, tag)) + self._track_task(task) else: logger.warning("No packet injector available - discovery response not sent") diff --git a/repeater/handler_helpers/login.py b/repeater/handler_helpers/login.py new file mode 100644 index 0000000..1561dec --- /dev/null +++ b/repeater/handler_helpers/login.py @@ -0,0 +1,191 @@ +""" +Login/ANON_REQ packet handling helper for pyMC Repeater. + +This module processes login requests and manages authentication for all identities. +""" + +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") + + +class LoginHelper: + def __init__(self, identity_manager, packet_injector=None, log_fn=None): + + self.identity_manager = identity_manager + self.packet_injector = packet_injector + self.log_fn = log_fn or logger.info + + self.handlers = {} + self.acls = {} # Per-identity ACLs keyed by hash_byte + self._pending_tasks = set() + + def _track_task(self, task: asyncio.Task) -> None: + self._pending_tasks.add(task) + + def _on_done(done_task: asyncio.Task) -> None: + self._pending_tasks.discard(done_task) + try: + done_task.result() + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"Background login task failed: {e}", exc_info=True) + + task.add_done_callback(_on_done) + + 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 + from repeater.handler_helpers.acl import ACL + + # Get security config for this identity + if identity_type == "room_server": + # Room servers use passwords from their settings section only + settings = config.get("settings", {}) + + # Empty strings ('') are treated as "not set" by using 'or None' + admin_password = settings.get("admin_password") or None + guest_password = settings.get("guest_password") or None + + # Validate room servers have passwords configured + if not admin_password and not guest_password: + logger.error( + f"Room server '{name}' MUST have admin_password or guest_password configured. " + f"Add them to 'settings' section. Skipping registration." + ) + return + + # Use configured passwords from settings + final_security = { + "max_clients": settings.get("max_clients", 50), + "admin_password": admin_password, + "guest_password": guest_password, + "allow_read_only": settings.get("allow_read_only", True), + } + else: + # Repeater uses security from repeater.security in config + security = config.get("repeater", {}).get("security", {}) + final_security = { + "max_clients": security.get("max_clients", 10), + "admin_password": security.get("admin_password", "admin123"), + "guest_password": security.get("guest_password", "guest123"), + "allow_read_only": security.get("allow_read_only", True), + } + logger.debug( + f"Repeater security config: admin_pw={'SET' if final_security['admin_password'] else 'NONE'}, " + f"guest_pw={'SET' if final_security['guest_password'] else 'NONE'}, " + f"max_clients={final_security['max_clients']}" + ) + + # Create ACL for this identity + identity_acl = ACL( + max_clients=final_security["max_clients"], + admin_password=final_security["admin_password"], + guest_password=final_security["guest_password"], + allow_read_only=final_security["allow_read_only"], + ) + + 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 + ): + return identity_acl.authenticate_client( + client_identity=client_identity, + shared_secret=shared_secret, + password=password, + timestamp=timestamp, + sync_since=sync_since, + target_identity_hash=hash_byte, + target_identity_name=name, + target_identity_config=config, + ) + + handler = LoginServerHandler( + local_identity=identity, + log_fn=self.log_fn, + authenticate_callback=auth_callback_with_context, + is_room_server=(identity_type == "room_server"), + ) + + 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: + if len(packet.payload) < 1: + return False + + dest_hash = packet.payload[0] + + handler = self.handlers.get(dest_hash) + if handler: + logger.debug(f"Routing login to identity: hash=0x{dest_hash:02X}") + await handler(packet) + packet.mark_do_not_retransmit() + return True + else: + # 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: + logger.error(f"Error processing login packet: {e}") + return False + + def _send_packet_with_delay(self, packet, delay_ms: int): + + if self.packet_injector: + task = asyncio.create_task(self._delayed_send(packet, delay_ms)) + self._track_task(task) + else: + logger.error("No packet injector configured, cannot send login response") + + async def _delayed_send(self, packet, delay_ms: int): + + await asyncio.sleep(delay_ms / 1000.0) + try: + await self.packet_injector(packet, wait_for_ack=False) + logger.debug(f"Sent login response after {delay_ms}ms delay") + except Exception as e: + logger.error(f"Error sending login response: {e}") + + def get_acl_dict(self): + """Return dictionary of ACLs keyed by identity hash.""" + return self.acls + + def get_acl_for_identity(self, hash_byte: int): + """Get ACL for a specific identity.""" + return self.acls.get(hash_byte) + + def list_authenticated_clients(self, hash_byte: int = None): + """List authenticated clients for a specific identity or all identities.""" + if hash_byte is not None: + acl = self.acls.get(hash_byte) + return acl.get_all_clients() if acl else [] + + # Return clients from all ACLs + all_clients = [] + for acl in self.acls.values(): + all_clients.extend(acl.get_all_clients()) + return all_clients diff --git a/repeater/handler_helpers/mesh_cli.py b/repeater/handler_helpers/mesh_cli.py new file mode 100644 index 0000000..f56f1f6 --- /dev/null +++ b/repeater/handler_helpers/mesh_cli.py @@ -0,0 +1,769 @@ +import logging +import time +from pathlib import Path +from typing import Any, Callable, Dict, Optional + +import yaml + +logger = logging.getLogger(__name__) + + +class MeshCLI: + + def __init__( + 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, + ): + + self.config_path = Path(config_path) + self.config = config + self.config_manager = config_manager + self.identity_type = identity_type + self.enable_regions = enable_regions + self.send_advert_callback = send_advert_callback + self.identity = identity + self.storage_handler = storage_handler + + # Store event loop reference for thread-safe scheduling + import asyncio + try: + self._event_loop = asyncio.get_running_loop() + except RuntimeError: + self._event_loop = None + + # Get repeater config shortcut + 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] == "|": + 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: + + # Help + if command == "help" or command.startswith("help "): + return self._cmd_help(command) + + # System commands + elif command == "reboot": + return self._cmd_reboot() + elif command == "advert": + return self._cmd_advert() + elif command.startswith("clock"): + return self._cmd_clock(command) + elif command.startswith("time "): + return self._cmd_time(command) + elif command == "start ota": + return "Error: OTA not supported in Python repeater" + elif command.startswith("password "): + return self._cmd_password(command) + elif command == "clear stats": + 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" + + # ==================== Help Command ==================== + + def _cmd_help(self, command: str) -> str: + """Show available commands or detailed help for a specific command.""" + parts = command.split(None, 1) + if len(parts) == 2: + return self._help_detail(parts[1]) + + lines = [ + "=== pyMC CLI Commands ===", + "", + "System:", + " reboot Restart the repeater service", + " advert Send self advertisement", + " clock Show current UTC time", + " clock sync Sync clock (no-op, uses system time)", + " ver Show version info", + " password Change admin password", + " clear stats Clear statistics", + "", + "Get:", + " get name Node name", + " get radio Radio params (freq,bw,sf,cr)", + " get freq Frequency (MHz)", + " get tx TX power", + " get af Airtime factor", + " get repeat Repeat mode (on/off)", + " get lat / get lon GPS coordinates", + " get role Identity role", + " get guest.password Guest password", + " get allow.read.only Read-only access setting", + " get advert.interval Advert interval (minutes)", + " get flood.advert.interval Flood advert interval (hours)", + " get flood.max Max flood hops", + " get rxdelay RX delay base", + " get txdelay TX delay factor", + " get direct.txdelay Direct TX delay factor", + " get multi.acks Multi-ack count", + " get int.thresh Interference threshold", + " get agc.reset.interval AGC reset interval", + "", + "Set: (use 'help set' for details)", + " set ", + "", + "Other:", + " neighbors List neighbors", + " neighbor.remove Remove neighbor by pubkey", + " tempradio ", + " setperm Set ACL permissions", + " log start|stop|erase Logging control", + ] + if self.enable_regions: + lines.append(" region ... Region commands") + lines += ["", "Type 'help ' for details on a specific command."] + return "\n".join(lines) + + def _help_detail(self, topic: str) -> str: + """Return detailed help for a specific command topic.""" + topic = topic.strip() + details = { + "set": ( + "Set commands \u2014 set :\n" + " set name Set node name\n" + " set radio Set radio (restart required)\n" + " set freq Set frequency (restart required)\n" + " set tx Set TX power\n" + " set af Airtime factor\n" + " set repeat on|off Enable/disable repeating\n" + " set lat Latitude\n" + " set lon Longitude\n" + " set guest.password Guest password\n" + " set allow.read.only on|off Read-only access\n" + " set advert.interval 60-240 minutes\n" + " set flood.advert.interval
3-48 hours\n" + " set flood.max Max flood hops (max 64)\n" + " set rxdelay RX delay base (>=0)\n" + " set txdelay TX delay factor (>=0)\n" + " set direct.txdelay Direct TX delay (>=0)\n" + " set multi.acks Multi-ack count\n" + " set int.thresh Interference threshold\n" + " set agc.reset.interval AGC reset (rounded to x4)" + ), + "get": "Get commands \u2014 type 'help' to see all 'get' parameters.", + "reboot": "Restart the repeater service via systemd.", + "advert": "Trigger a self-advertisement flood packet.", + "clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).", + "ver": "Show repeater version and identity type.", + "password": "password \u2014 Change the admin password.", + "tempradio": ( + "tempradio \n" + " Apply temporary radio parameters that revert after timeout.\n" + " freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8" + ), + "neighbors": "List known neighbor nodes from the routing table.", + "setperm": "setperm \u2014 Set ACL permissions for a node.", + "log": "log start|stop|erase \u2014 Control logging.", + } + return details.get(topic, f"No detailed help for '{topic}'. Type 'help' for command list.") + + # ==================== 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() + + if self._event_loop and self._event_loop.is_running(): + asyncio.run_coroutine_threadsafe(delayed_advert(), self._event_loop) + else: + return "Error: Event loop not available" + + 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": + # Clock sync happens automatically via sender_timestamp in protocol + 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 + + # 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"]) + 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") + 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) + return f"> {af}" + + elif param == "name": + name = self.repeater_config.get("name", "Unknown") + return f"> {name}" + + elif param == "repeat": + mode = self.repeater_config.get("mode", "forward") + return f"> {'on' if mode == 'forward' else 'off'}" + + elif param == "lat": + lat = self.repeater_config.get("latitude", 0.0) + return f"> {lat}" + + elif param == "lon": + 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) + # 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_mhz = freq_hz / 1_000_000.0 + return f"> {freq_mhz}" + + elif param == "tx": + 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" + try: + pubkey = self.identity.get_public_key() + pubkey_hex = pubkey.hex() + return f"> {pubkey_hex}" + 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", "") + return f"> {guest_pw}" + + elif param == "allow.read.only": + 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) + return f"> {interval}" + + elif param == "flood.advert.interval": + 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", 64) + return f"> {max_flood}" + + elif param == "rxdelay": + 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) + return f"> {delay}" + + elif param == "direct.txdelay": + 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) + return f"> {acks}" + + elif param == "int.thresh": + 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) + 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) + 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 + saved, _ = self.config_manager.save_to_file() + self.config_manager.live_update_daemon(["repeater"]) + return "OK" + + elif key == "repeat": + self.repeater_config["mode"] = "forward" if value.lower() == "on" else "monitor" + saved, _ = self.config_manager.save_to_file() + self.config_manager.live_update_daemon(["repeater"]) + return f"OK - repeat is now {'ON' if self.repeater_config['mode'] == 'forward' else 'OFF'}" + + elif key == "lat": + self.repeater_config["latitude"] = float(value) + 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) + saved, _ = self.config_manager.save_to_file() + 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]) + saved, _ = self.config_manager.save_to_file() + 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) + saved, _ = self.config_manager.save_to_file() + 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) + saved, _ = self.config_manager.save_to_file() + 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 + saved, _ = self.config_manager.save_to_file() + 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" + saved, _ = self.config_manager.save_to_file() + 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 + saved, _ = self.config_manager.save_to_file() + 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 + saved, _ = self.config_manager.save_to_file() + 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 + saved, _ = self.config_manager.save_to_file() + 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 + saved, _ = self.config_manager.save_to_file() + 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 + saved, _ = self.config_manager.save_to_file() + 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 + 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) + 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) + saved, _ = self.config_manager.save_to_file() + 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 + saved, _ = self.config_manager.save_to_file() + 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": + return "Error: Region commands not implemented" + elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"): + 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) + } + + 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) + 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 + + # 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" + if not (7.0 <= bw <= 500.0): + return "Error: invalid bandwidth" + if not (5 <= sf <= 12): + return "Error: invalid spreading factor" + if not (5 <= cr <= 8): + 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": + # TODO: Enable logging + return "Error: Not yet implemented" + elif command == "log stop": + # TODO: Disable logging + return "Error: Not yet implemented" + elif command == "log erase": + # TODO: Clear log file + return "Error: Not yet implemented" + elif command == "log": + return "Error: Use journalctl to view logs" + else: + return "Unknown log command" diff --git a/repeater/handler_helpers/path.py b/repeater/handler_helpers/path.py new file mode 100644 index 0000000..d482118 --- /dev/null +++ b/repeater/handler_helpers/path.py @@ -0,0 +1,92 @@ +import logging +import time + +logger = logging.getLogger("PathHelper") + + +class PathHelper: + def __init__(self, acl_dict=None, log_fn=None): + + self.acl_dict = acl_dict or {} + self.log_fn = log_fn or logger.info + + 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(): + pubkey = client_info.id.get_public_key() + 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)}" + ) + return False + + 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 new file mode 100644 index 0000000..4295693 --- /dev/null +++ b/repeater/handler_helpers/protocol_request.py @@ -0,0 +1,370 @@ +""" +Protocol request (REQ) handling helper for pyMC Repeater. + +Provides repeater-specific callbacks for status and telemetry requests. +""" + +import asyncio +import logging +import struct +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, + ProtocolRequestHandler, +) + +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, + config=None, + ): + + self.identity_manager = identity_manager + self.packet_injector = packet_injector + self.acl_dict = acl_dict or {} + self.radio = radio + self.engine = engine + self.neighbor_tracker = neighbor_tracker + self.config = config or {} + + # Dictionary of core handlers keyed by dest_hash + self.handlers = {} + + def register_identity(self, name: str, identity, identity_type: str = "repeater"): + + 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 ACL contacts wrapper + acl_contacts = self._create_acl_contacts_wrapper(identity_acl) + + # Build request handlers dict + request_handlers = { + REQ_TYPE_GET_STATUS: self._handle_get_status, + REQ_TYPE_GET_ACCESS_LIST: self._make_handle_get_access_list(identity_acl), + REQ_TYPE_GET_NEIGHBOURS: self._handle_get_neighbours, + REQ_TYPE_GET_OWNER_INFO: self._handle_get_owner_info, + } + + # Create core handler + handler = ProtocolRequestHandler( + local_identity=identity, + contacts=acl_contacts, + get_client_fn=lambda src_hash: self._get_client_from_acl(identity_acl, src_hash), + request_handlers=request_handlers, + log_fn=logger.info, + ) + + self.handlers[hash_byte] = { + "handler": handler, + "identity": identity, + "name": name, + "type": identity_type, + } + + 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 + + @property + def contacts(self): + return self._acl.get_all_clients() + + return ACLContactsWrapper(acl) + + def _get_client_from_acl(self, acl, src_hash: int): + """Get client from ACL by source hash.""" + for client_info in acl.get_all_clients(): + if client_info.id.get_public_key()[0] == src_hash: + return client_info + return None + + async def process_request_packet(self, packet): + + try: + if len(packet.payload) < 2: + return False + + dest_hash = packet.payload[0] + + handler_info = self.handlers.get(dest_hash) + if not handler_info: + return False + + # Let core handler build response + response_packet = await handler_info["handler"](packet) + + # Send response after delay + if response_packet and self.packet_injector: + await asyncio.sleep(SERVER_RESPONSE_DELAY_MS / 1000.0) + await self.packet_injector(response_packet, wait_for_ack=False) + + packet.mark_do_not_retransmit() + return True + + except Exception as e: + logger.error(f"Error processing protocol request: {e}", exc_info=True) + return False + + def _handle_get_status(self, client, timestamp: int, req_data: bytes): + """Build 56-byte RepeaterStats (firmware layout from MeshCore simple_repeater/MyMesh.h).""" + # RepeaterStats: uint16 batt, uint16 curr_tx_queue_len, int16 noise_floor, int16 last_rssi, + # uint32 n_packets_recv, n_packets_sent, total_air_time_secs, total_up_time_secs, + # n_sent_flood, n_sent_direct, n_recv_flood, n_recv_direct, + # uint16 err_events, int16 last_snr (×4), uint16 n_direct_dups, n_flood_dups, + # uint32 total_rx_air_time_secs, n_recv_errors → 56 bytes + + # Uptime: use engine start_time when available (fixes wrong "20521 days" from time.time()) + if self.engine and hasattr(self.engine, "start_time"): + total_up_time_secs = int(time.time() - self.engine.start_time) + else: + total_up_time_secs = 0 + + # Radio: noise floor, last RSSI, last SNR (firmware stores SNR × 4) + if self.radio: + noise_floor = int(getattr(self.radio, "get_noise_floor", lambda: 0)() or 0) + if callable(getattr(self.radio, "get_last_rssi", None)): + last_rssi = int(self.radio.get_last_rssi() or -120) + else: + last_rssi = int(getattr(self.radio, "last_rssi", -120) or -120) + if callable(getattr(self.radio, "get_last_snr", None)): + last_snr = int((self.radio.get_last_snr() or 0) * 4) + else: + last_snr = int((getattr(self.radio, "last_snr", 0) or 0) * 4) + else: + noise_floor = 0 + last_rssi = -120 + last_snr = 0 + + # Packet counts: prefer engine (rx_count, forwarded_count); fall back to radio if present + if self.engine: + n_packets_recv = getattr(self.engine, "rx_count", 0) + n_packets_sent = getattr(self.engine, "forwarded_count", 0) + elif self.radio: + n_packets_recv = getattr(self.radio, "packets_received", 0) or 0 + n_packets_sent = getattr(self.radio, "packets_sent", 0) or 0 + else: + n_packets_recv = 0 + n_packets_sent = 0 + + # Airtime (AirtimeManager uses total_airtime_ms for TX; total_rx_airtime_ms if we track RX) + total_air_time_secs = 0 + total_rx_air_time_secs = 0 + if self.engine: + am = getattr(self.engine, "airtime_mgr", None) or getattr( + self.engine, "airtime_manager", None + ) + if am is not None: + total_air_time_secs = int(getattr(am, "total_airtime_ms", 0) or 0) // 1000 + total_rx_air_time_secs = int(getattr(am, "total_rx_airtime_ms", 0) or 0) // 1000 + + # Routing stats (flood/direct and dups - from engine when available) + n_sent_flood = getattr(self.engine, "sent_flood_count", 0) if self.engine else 0 + n_sent_direct = getattr(self.engine, "sent_direct_count", 0) if self.engine else 0 + n_recv_flood = getattr(self.engine, "recv_flood_count", 0) if self.engine else 0 + n_recv_direct = getattr(self.engine, "recv_direct_count", 0) if self.engine else 0 + n_direct_dups = getattr(self.engine, "direct_dup_count", 0) if self.engine else 0 + n_flood_dups = getattr(self.engine, "flood_dup_count", 0) if self.engine else 0 + n_recv_errors = ( + int(getattr(self.radio, "crc_error_count", 0) or 0) + if self.radio + else 0 + ) + + # Pack 56-byte RepeaterStats (layout matches firmware) + stats = struct.pack( + "= 2 and (req_data[0] != 0 or req_data[1] != 0): + logger.debug("GET_ACCESS_LIST: reserved bytes non-zero, ignoring") + return None + + result = bytearray() + for ci in identity_acl.get_all_clients(): + if ci.permissions == 0: + continue # skip deleted entries + pubkey = ci.id.get_public_key() + result.extend(pubkey[:6]) # 6-byte pub_key prefix + result.append(ci.permissions & 0xFF) + + logger.debug("GET_ACCESS_LIST: returning %d entries", len(result) // 7) + return bytes(result) + + def _handle_get_neighbours(self, client, timestamp: int, req_data: bytes): + """Return paginated, sorted neighbour list. + + Matches C++ simple_repeater handleRequest REQ_TYPE_GET_NEIGHBOURS. + Request: version(1) + count(1) + offset(2 LE) + order_by(1) + pubkey_prefix_len(1) + random(4) + Response: total_count(2 LE) + results_count(2 LE) + entries + Each entry: pubkey_prefix(N) + heard_seconds_ago(4 LE) + snr(1 signed) + """ + if len(req_data) < 7: + logger.debug("GET_NEIGHBOURS: req_data too short (%d bytes)", len(req_data)) + return None + + request_version = req_data[0] + if request_version != 0: + logger.debug("GET_NEIGHBOURS: unsupported version %d", request_version) + return None + + count = req_data[1] + offset = struct.unpack_from("= total_count: + break + if len(results) + entry_size > max_results_bytes: + break + + pubkey_hex, heard_ago, snr_int = entries[idx] + try: + pubkey_bytes = bytes.fromhex(pubkey_hex) + except (ValueError, TypeError): + continue + results.extend(pubkey_bytes[:pubkey_prefix_len]) + results.extend(struct.pack(" str: + """ + Handle an incoming command from a client. + + Args: + sender_pubkey: Public key of sender + command: Command string (may include XX| prefix) + is_admin: Whether sender has admin permissions + + Returns: + Reply string to send back to sender + """ + # 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] == "|": + 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: + """Route command to appropriate handler method.""" + + # Help + if command == "help" or command.startswith("help "): + return self._cmd_help(command) + + # System commands + elif command == "reboot": + return self._cmd_reboot() + elif command == "advert": + return self._cmd_advert() + elif command.startswith("clock"): + return self._cmd_clock(command) + elif command.startswith("time "): + return self._cmd_time(command) + elif command == "start ota": + return "Error: OTA not supported in Python repeater" + elif command.startswith("password "): + return self._cmd_password(command) + elif command == "clear stats": + 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" + + # ==================== Help Command ==================== + + def _cmd_help(self, command: str) -> str: + """Show available commands or detailed help for a specific command.""" + parts = command.split(None, 1) + if len(parts) == 2: + return self._help_detail(parts[1]) + + lines = [ + "=== pyMC CLI Commands ===", + "", + "System:", + " reboot Restart the repeater service", + " advert Send self advertisement", + " clock Show current UTC time", + " clock sync Sync clock (no-op, uses system time)", + " ver Show version info", + " password Change admin password", + " clear stats Clear statistics", + "", + "Get:", + " get name Node name", + " get radio Radio params (freq,bw,sf,cr)", + " get freq Frequency (MHz)", + " get tx TX power", + " get af Airtime factor", + " get repeat Repeat mode (on/off)", + " get lat / get lon GPS coordinates", + " get role Identity role", + " get guest.password Guest password", + " get allow.read.only Read-only access setting", + " get advert.interval Advert interval (minutes)", + " get flood.advert.interval Flood advert interval (hours)", + " get flood.max Max flood hops", + " get rxdelay RX delay base", + " get txdelay TX delay factor", + " get direct.txdelay Direct TX delay factor", + " get multi.acks Multi-ack count", + " get int.thresh Interference threshold", + " get agc.reset.interval AGC reset interval", + "", + "Set: (use 'help set' for details)", + " set ", + "", + "Other:", + " neighbors List neighbors", + " neighbor.remove Remove neighbor by pubkey", + " tempradio ", + " setperm Set ACL permissions", + " log start|stop|erase Logging control", + ] + if self.enable_regions: + lines.append(" region ... Region commands") + lines += ["", "Type 'help ' for details on a specific command."] + return "\n".join(lines) + + def _help_detail(self, topic: str) -> str: + """Return detailed help for a specific command topic.""" + topic = topic.strip() + details = { + "set": ( + "Set commands — set :\n" + " set name Set node name\n" + " set radio Set radio (restart required)\n" + " set freq Set frequency (restart required)\n" + " set tx Set TX power\n" + " set af Airtime factor\n" + " set repeat on|off Enable/disable repeating\n" + " set lat Latitude\n" + " set lon Longitude\n" + " set guest.password Guest password\n" + " set allow.read.only on|off Read-only access\n" + " set advert.interval 60-240 minutes\n" + " set flood.advert.interval
3-48 hours\n" + " set flood.max Max flood hops (max 64)\n" + " set rxdelay RX delay base (>=0)\n" + " set txdelay TX delay factor (>=0)\n" + " set direct.txdelay Direct TX delay (>=0)\n" + " set multi.acks Multi-ack count\n" + " set int.thresh Interference threshold\n" + " set agc.reset.interval AGC reset (rounded to x4)" + ), + "get": "Get commands — type 'help' to see all 'get' parameters.", + "reboot": "Restart the repeater service via systemd.", + "advert": "Trigger a self-advertisement flood packet.", + "clock": "'clock' shows UTC time. 'clock sync' is a no-op (system time used).", + "ver": "Show repeater version and identity type.", + "password": "password — Change the admin password.", + "tempradio": ( + "tempradio \n" + " Apply temporary radio parameters that revert after timeout.\n" + " freq: 300-2500 MHz, bw: 7-500 kHz, sf: 5-12, cr: 5-8" + ), + "neighbors": "List known neighbor nodes from the routing table.", + "setperm": "setperm — Set ACL permissions for a node.", + "log": "log start|stop|erase — Control logging.", + } + return details.get(topic, f"No detailed help for '{topic}'. Type 'help' for command list.") + + # ==================== System Commands == + + def _cmd_reboot(self) -> str: + """Reboot the repeater process.""" + from repeater.service_utils import restart_service + + logger.warning("Reboot command received via repeater CLI") + success, message = restart_service() + + if success: + return f"OK - {message}" + else: + return f"Error: {message}" + + def _cmd_advert(self) -> str: + """Send self advertisement.""" + logger.info("Advert command received") + # TODO: Trigger advertisement through packet handler + return "Error: Not yet implemented" + + 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": + # Clock sync happens automatically via sender_timestamp in protocol + 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 + + # Save config + try: + self.save_config() + 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") + 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) + return f"> {af}" + + elif param == "name": + name = self.repeater_config.get("name", "Unknown") + return f"> {name}" + + elif param == "repeat": + mode = self.repeater_config.get("mode", "forward") + return f"> {'on' if mode == 'forward' else 'off'}" + + elif param == "lat": + lat = self.repeater_config.get("latitude", 0.0) + return f"> {lat}" + + elif param == "lon": + 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) + # 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_mhz = freq_hz / 1_000_000.0 + return f"> {freq_mhz}" + + elif param == "tx": + 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" + + 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", "") + return f"> {guest_pw}" + + elif param == "allow.read.only": + 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) + return f"> {interval}" + + elif param == "flood.advert.interval": + 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", 64) + return f"> {max_flood}" + + elif param == "rxdelay": + 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) + return f"> {delay}" + + elif param == "direct.txdelay": + 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) + return f"> {acks}" + + elif param == "int.thresh": + 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) + 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.save_config() + return "OK" + + elif key == "name": + self.repeater_config["name"] = value + self.save_config() + return "OK" + + elif key == "repeat": + self.repeater_config["mode"] = "forward" if value.lower() == "on" else "monitor" + self.save_config() + return f"OK - repeat is now {'ON' if self.repeater_config['mode'] == 'forward' else 'OFF'}" + + elif key == "lat": + self.repeater_config["latitude"] = float(value) + self.save_config() + return "OK" + + elif key == "lon": + 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]) + 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) + 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) + self.save_config() + return "OK" + + elif key == "guest.password": + 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" + 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.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.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.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.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.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.save_config() + return "OK" + + elif key == "multi.acks": + self.repeater_config["multi_acks"] = int(value) + self.save_config() + return "OK" + + elif key == "int.thresh": + 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.save_config() + 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": + return "Error: Region commands not implemented" + elif subcommand in ("allowf", "denyf", "get", "home", "put", "remove"): + return "Error: Region commands not implemented" + else: + return "Err - ??" + + # ==================== Neighbor Commands ==================== + + def _cmd_neighbors(self) -> str: + """List neighbors.""" + # TODO: Get neighbors from routing table + return "Error: Not yet implemented" + + 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" + if not (7.0 <= bw <= 500.0): + return "Error: invalid bandwidth" + if not (5 <= sf <= 12): + return "Error: invalid spreading factor" + if not (5 <= cr <= 8): + 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": + # TODO: Enable logging + return "Error: Not yet implemented" + elif command == "log stop": + # TODO: Disable logging + return "Error: Not yet implemented" + elif command == "log erase": + # TODO: Clear log file + return "Error: Not yet implemented" + elif command == "log": + return "Error: Use journalctl to view logs" + else: + return "Unknown log command" + + +# Backward compatibility alias +RepeaterCLI = MeshCLI diff --git a/repeater/handler_helpers/room_server.py b/repeater/handler_helpers/room_server.py new file mode 100644 index 0000000..f65b0c6 --- /dev/null +++ b/repeater/handler_helpers/room_server.py @@ -0,0 +1,729 @@ +import asyncio +import logging +import time +from typing import Dict, Optional + +from pymc_core.protocol import CryptoUtils, PacketBuilder +from pymc_core.protocol.constants import PAYLOAD_TYPE_TXT_MSG + +logger = logging.getLogger("RoomServer") + +# Hard limit from C++ simple_room_server +MAX_UNSYNCED_POSTS = 32 + +# Text message type constants +TXT_TYPE_PLAIN = 0x00 +TXT_TYPE_CLI_DATA = 0x01 +TXT_TYPE_SIGNED_PLAIN = 0x02 + +# Push timing constants (from C++ simple_room_server) +PUSH_NOTIFY_DELAY_MS = 2000 +SYNC_PUSH_INTERVAL_MS = 1200 +POST_SYNC_DELAY_SECS = 6 +PUSH_ACK_TIMEOUT_FLOOD_MS = 12000 +PUSH_TIMEOUT_BASE_MS = 4000 +PUSH_ACK_TIMEOUT_FACTOR_MS = 2000 + +# Safety limits and protections +MAX_MESSAGE_LENGTH = 160 # Match C++ MAX_POST_TEXT_LEN (151 bytes for text) +MAX_POSTS_PER_CLIENT_PER_MINUTE = 10 # Prevent spam +MAX_CLIENTS_PER_ROOM = 50 # From ACL default +MAX_PUSH_FAILURES = 3 # Evict after this many consecutive failures +INACTIVE_CLIENT_TIMEOUT = 3600 # Evict after 1 hour inactivity (seconds) +MAX_CONSECUTIVE_SYNC_ERRORS = 10 # Circuit breaker threshold +DB_ERROR_RETRY_DELAY = 60 # Wait 1 minute on DB error (seconds) + +# Backoff strategy for failed pushes (seconds) +RETRY_BACKOFF_SCHEDULE = [0, 30, 300, 3600] # 0s, 30s, 5min, 1hr + +# Note: Server/system messages now use the room server's actual public key +# This allows clients to identify which room server sent the message + +# Global rate limiter (shared across all rooms) +_global_push_limiter = None +_global_push_lock = asyncio.Lock() +GLOBAL_MIN_GAP_BETWEEN_MESSAGES = 1.1 # 1.1s minimum gap between transmissions + + +class GlobalRateLimiter: + + def __init__(self, min_gap_seconds: float = 0.1): + 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: + # Enforce minimum gap between consecutive transmissions + now = time.time() + time_since_last = now - self.last_release_time + if time_since_last < self.min_gap: + wait_time = self.min_gap - time_since_last + logger.debug(f"Global rate limiter: waiting {wait_time*1000:.0f}ms") + 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() + + +class RoomServer: + + def __init__( + self, + room_hash: int, + room_name: str, + local_identity, + sqlite_handler, + packet_injector, + acl, + max_posts: int = 32, + config_path: str = None, + config: dict = 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" + ) + return False + + try: + from pymc_core.protocol import PacketBuilder + 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_settings = {} + for rs in room_config: + 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) + + flags = ADVERT_FLAG_IS_ROOM_SERVER | ADVERT_FLAG_HAS_NAME + + packet = PacketBuilder.create_advert( + local_identity=local_identity, + name=node_name, + lat=latitude, + lon=longitude, + feature1=0, + feature2=0, + 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})" + ) + 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, + config_manager, + identity_type="room_server", + enable_regions=False, # Room servers don't support region commands + send_advert_callback=send_room_advert, + identity=local_identity, + 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( + f"Room '{room_name}': max_posts={max_posts} exceeds hard limit " + f"of {MAX_UNSYNCED_POSTS}, capping to {MAX_UNSYNCED_POSTS}" + ) + 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: + self._sync_task.cancel() + try: + await self._sync_task + 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, + ) -> bool: + + try: + # SAFETY: Validate message length + if len(message_text) > MAX_MESSAGE_LENGTH: + logger.warning( + f"Room '{self.room_name}': Message from {client_pubkey[:4].hex()} " + 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 + ] + + # Check rate limit + if len(self.client_post_times[client_key]) >= MAX_POSTS_PER_CLIENT_PER_MINUTE: + logger.warning( + f"Room '{self.room_name}': Client {client_pubkey[:4].hex()} " + 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}", + author_pubkey=client_pubkey.hex(), + message_text=message_text, + post_timestamp=post_timestamp, + sender_timestamp=sender_timestamp, + 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) + logger.debug( + f"Room '{self.room_name}': Updating author's sync_since to {post_timestamp} " + f"to prevent echo" + ) + self.db.upsert_client_sync( + 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(), + ) + + # 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(), + ) + + if sync_state: + 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) + time_since_failure = time.time() - last_failure_time + + if time_since_failure < backoff_delay: + wait_time = backoff_delay - time_since_failure + logger.debug( + f"Room '{self.room_name}': Client 0x{client_info.id.get_public_key()[0]:02X} " + 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 + + # Author prefix (first 4 bytes of 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") + plaintext = ( + 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") + + # 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, + dest=client_info.id, + local_identity=self.local_identity, + secret=client_info.shared_secret, + plaintext=plaintext, + 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_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 + + # 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, + ) + # 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"] + ) + logger.info( + f"Room '{self.room_name}': Pushed post to " + f"0x{client_info.id.get_public_key()[0]:02X} via {route_type.upper()}, ACK received" + ) + else: + # ACK timeout + await self._handle_ack_timeout(client_info.id.get_public_key()) + logger.warning( + 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: + # Update sync state: advance sync_since, clear pending_ack, reset failures + self.db.upsert_client_sync( + room_hash=f"0x{self.room_hash:02X}", + client_pubkey=client_pubkey.hex(), + sync_since=post_timestamp, + pending_ack_crc=0, + push_failures=0, + 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() + ) + + if sync_state: + # Increment failure counter, clear pending_ack + 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, + ) + + if failures >= 3: + logger.warning( + f"Room '{self.room_name}': Client 0x{client_pubkey[0]:02X} " + f"has {failures} consecutive failures" + ) + 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() + ) + + 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, + ) + 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) + + # 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 + ) + + # 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( + f"Room '{self.room_name}': Circuit breaker tripped! " + f"{self.consecutive_sync_errors} consecutive errors. Pausing for {DB_ERROR_RETRY_DELAY}s" + ) + 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: + # Only log once when transitioning from clients to no clients + # 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(), + ) + + # 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) + + if pending_ack != 0: + 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)" + ) + continue + + if push_failures >= 3: + logger.debug( + f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)" + ) + continue + + 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 + logger.info( + f"Room '{self.room_name}': Initializing client " + f"0x{client.id.get_public_key()[0]:02X} with sync_since={sync_since}" + ) + self.db.upsert_client_sync( + room_hash=f"0x{self.room_hash:02X}", + client_pubkey=client.id.get_public_key().hex(), + sync_since=sync_since, + 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, + ) + + if unsynced: + post = unsynced[0] + logger.debug( + f"Room '{self.room_name}': Client 0x{client.id.get_public_key()[0]:02X} " + f"has unsynced message #{post['id']}, post_timestamp={post['post_timestamp']:.1f}" + ) + # Check if enough time has passed since post creation + now = time.time() + 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) + break # Exit the while loop + else: + # Not ready yet, check sooner + self.next_push_time = time.time() + (SYNC_PUSH_INTERVAL_MS / 8000.0) + break # Exit the while loop + 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: + # SAFETY: Track consecutive errors for circuit breaker + self.consecutive_sync_errors += 1 + logger.error( + f"Room '{self.room_name}': Sync loop error #{self.consecutive_sync_errors}: {e}", + 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 now >= timeout_time: + # ACK timeout + 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 + ) + if deleted > 0: + logger.info(f"Room '{self.room_name}': Cleaned up {deleted} old messages") + except Exception as e: + logger.error(f"Error cleaning up old messages: {e}") diff --git a/repeater/handler_helpers/text.py b/repeater/handler_helpers/text.py new file mode 100644 index 0000000..4d9c432 --- /dev/null +++ b/repeater/handler_helpers/text.py @@ -0,0 +1,574 @@ +""" +Text message (TXT_MSG) handling helper for pyMC Repeater. + +This module processes incoming text messages for all managed identities +(repeater identity + identity manager identities). +Also handles CLI commands for admin users on the repeater identity. +""" + +import asyncio +import logging +import struct +import time + +from pymc_core.node.handlers.text import TextMessageHandler + +from .mesh_cli import MeshCLI +from .room_server import RoomServer + +logger = logging.getLogger("TextHelper") + +# Text message type flags +TXT_TYPE_PLAIN = 0x00 +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, + ): + + self.identity_manager = identity_manager + self.packet_injector = packet_injector + self.log_fn = log_fn or logger.info + 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 + self._pending_tasks = set() + + def _track_task(self, task: asyncio.Task) -> None: + self._pending_tasks.add(task) + + def _on_done(done_task: asyncio.Task) -> None: + self._pending_tasks.discard(done_task) + try: + done_task.result() + except asyncio.CancelledError: + pass + except Exception as e: + logger.error(f"Background text task failed: {e}", exc_info=True) + + task.add_done_callback(_on_done) + + def register_identity( + 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, + contacts=acl_contacts, + log_fn=self.log_fn, + 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] = { + "handler": handler, + "identity": identity, + "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( + self.config_path, + self.config, + self.config_manager, + identity_type="repeater", + enable_regions=True, + send_advert_callback=self.send_advert_callback, + identity=identity, + storage_handler=self.sqlite_handler, + ) + 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) + + # Enforce hard limit + if max_posts > MAX_UNSYNCED_POSTS: + logger.warning( + f"Room '{name}': Configured max_posts={max_posts} exceeds hard limit " + 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, + local_identity=identity, + sqlite_handler=self.sqlite_handler, + packet_injector=self.packet_injector, + acl=identity_acl, + max_posts=max_posts, + config_path=self.config_path, + config=self.config, + config_manager=self.config_manager, + ) + + self.room_servers[hash_byte] = room_server + + # Start sync loop + start_task = asyncio.create_task(room_server.start()) + self._track_task(start_task) + + 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}") + + def _create_acl_contacts_wrapper(self, acl): + + class ACLContactsWrapper: + def __init__(self, identity_acl): + self._acl = identity_acl + + @property + def contacts(self): + contact_list = [] + for client_info in self._acl.get_all_clients(): + # Create a minimal contact object that TextMessageHandler needs + class ContactProxy: + 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): + + 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"], + identity_type=handler_info["type"], + packet=packet, + 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") + return False + + except Exception as e: + logger.error(f"Error processing text packet: {e}") + return False + + async def _on_message_received( + self, + identity_name: str, + identity_type: str, + packet, + dest_hash: int, + src_hash: int, + ): + + # 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}" + ) + + # 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}") + + # 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 + ) + + if not is_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 + 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 + ) + + 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 + ) + + # 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 + 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, + ) + + if success: + 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 + ) + + # 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]}" + ) + 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 + 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 + ) + + 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) + + async def _send_packet(self, packet, wait_for_ack: bool = False): + + if self.packet_injector: + try: + return await self.packet_injector(packet, wait_for_ack=wait_for_ack) + except Exception as e: + logger.error(f"Error sending packet: {e}") + return False + else: + logger.error("No packet injector configured, cannot send packet") + return False + + def set_message_callback(self, callback): + + self._message_callback = callback + + def list_registered_identities(self): + + return [ + { + "hash": hash_byte, + "name": info["name"], + "type": info["type"], + } + for hash_byte, info in self.handlers.items() + ] + + async def cleanup(self): + """Cleanup room servers and handlers.""" + # Stop all room server sync loops + for room_server in self.room_servers.values(): + try: + 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] == "|": + 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", + ] + + 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) + 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 + - Uses client->out_path_len to decide routing: + * if out_path_len < 0: sendFlood() + * else: sendDirect() with stored out_path + """ + 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}" + ) + + # 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" + ) + 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 + + # 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}" + ) + + 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, + ) + + # 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_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]}" + ) + + # 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]}" + ) + + 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 0a84dbf..448f6b0 100644 --- a/repeater/handler_helpers/trace.py +++ b/repeater/handler_helpers/trace.py @@ -6,13 +6,15 @@ which are used for network diagnostics to track the path and SNR of packets through the mesh network. """ +import asyncio import logging import time -from typing import Dict, Any +from typing import Any, Dict, List from pymc_core.hardware.signal_utils import snr_register_to_db from pymc_core.node.handlers.trace import TraceHandler from pymc_core.protocol.constants import MAX_PATH_SIZE, ROUTE_TYPE_DIRECT +from pymc_core.protocol.packet_utils import PathUtils logger = logging.getLogger("TraceHelper") @@ -20,23 +22,52 @@ logger = logging.getLogger("TraceHelper") class TraceHelper: """Helper class for processing trace packets in the repeater.""" - def __init__(self, local_hash: int, repeater_handler, packet_injector=None, log_fn=None): + def __init__( + self, + local_hash: int, + repeater_handler, + packet_injector=None, + log_fn=None, + local_identity=None, + ): """ Initialize the trace helper. Args: - local_hash: The local node's hash identifier + local_hash: The local node's 1-byte hash (first byte of pubkey); legacy repeater_handler: The RepeaterHandler instance packet_injector: Callable to inject new packets into the router for sending log_fn: Optional logging function for TraceHandler + local_identity: LocalIdentity (or any object with get_public_key()) for + multibyte TRACE path matching (Mesh.cpp isHashMatch with 1< None + # Create TraceHandler internally as a parsing utility self.trace_handler = TraceHandler(log_fn=log_fn or logger.info) + def _pubkey_prefix(self, width: int) -> bytes: + if width <= 0 or not self._pubkey_bytes: + return b"" + return self._pubkey_bytes[:width] + async def process_trace_packet(self, packet) -> None: """ Process an incoming trace packet. @@ -48,29 +79,55 @@ class TraceHelper: packet: The trace packet to process """ try: - # Only process direct route trace packets - if packet.get_route_type() != ROUTE_TYPE_DIRECT or packet.path_len >= MAX_PATH_SIZE: + # Only process direct route trace packets (SNR path uses len(packet.path)) + if packet.get_route_type() != ROUTE_TYPE_DIRECT or len(packet.path) >= MAX_PATH_SIZE: return # Parse the trace payload 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"] - trace_path_len = len(trace_path) + trace_bytes: bytes = parsed_data.get("trace_path_bytes") or b"" + flags = parsed_data.get("flags", 0) + hash_width = PathUtils.trace_payload_hash_width(flags) + trace_hops: List[bytes] = parsed_data.get("trace_hops") or [] + num_hops = len(trace_hops) + legacy_trace_path = parsed_data.get("trace_path") or [] + + # Check if this is a response to one of our pings + trace_tag = parsed_data.get("tag") + if trace_tag in self.pending_pings: + rssi_val = getattr(packet, "rssi", 0) + if rssi_val == 0: + logger.warning( + f"Ignoring trace response for tag {trace_tag} " + "with RSSI=0 (no signal data)" + ) + return # wait for a valid response or let timeout handle it + ping_info = self.pending_pings[trace_tag] + # Store response data (legacy path list + structured hops) + ping_info["result"] = { + "path": legacy_trace_path, + "trace_hops": trace_hops, + "trace_path_bytes": trace_bytes, + "snr": packet.get_snr(), + "rssi": rssi_val, + "received_at": time.time(), + } + # Signal the waiting coroutine + ping_info["event"].set() + logger.info(f"Ping response received for tag {trace_tag}") # Record the trace packet for dashboard/statistics if self.repeater_handler: - packet_record = self._create_trace_record(packet, trace_path, parsed_data) + packet_record = self._create_trace_record(packet, parsed_data) self.repeater_handler.log_trace_record(packet_record) # Extract and log path SNRs and hashes - path_snrs, path_hashes = self._extract_path_info(packet, trace_path) + path_snrs, path_hashes = self._extract_path_info(packet, parsed_data) # Add packet metadata for logging parsed_data["snr"] = packet.get_snr() @@ -80,68 +137,104 @@ class TraceHelper: logger.info(f"{formatted_response}") logger.info(f"Path SNRs: [{', '.join(path_snrs)}], Hashes: [{', '.join(path_hashes)}]") - # Check if we should forward this trace packet - should_forward = self._should_forward_trace(packet, trace_path, trace_path_len) + should_forward = self._should_forward_trace(packet, trace_bytes, flags, hash_width) if should_forward: - await self._forward_trace_packet(packet, trace_path_len) + await self._forward_trace_packet(packet, num_hops) 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) + self._log_no_forward_reason(packet, trace_bytes, hash_width) + if ( + self.on_trace_complete + and self._is_trace_complete(packet, trace_bytes, hash_width) + and self.repeater_handler + and not self.repeater_handler.is_duplicate(packet) + ): + 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}") - def _create_trace_record(self, packet, trace_path: list, parsed_data: dict) -> Dict[str, Any]: + def _is_trace_complete(self, packet, trace_bytes: bytes, hash_width: int) -> bool: + """Mirror Mesh.cpp: offset = path_len<= len(trace hash bytes).""" + if not trace_bytes or hash_width <= 0: + return False + snr_count = len(packet.path) + return snr_count * hash_width >= len(trace_bytes) + + def _create_trace_record(self, packet, parsed_data: dict) -> Dict[str, Any]: """ Create a packet record for trace packets to log to statistics. Args: packet: The trace packet - trace_path: The parsed trace path from the payload - parsed_data: The parsed trace data + parsed_data: Full parse result from TraceHandler Returns: A dictionary containing the packet record """ - # Format trace path for display - trace_path_bytes = [f"{h:02X}" for h in trace_path[:8]] - if len(trace_path) > 8: + trace_hops: List[bytes] = parsed_data.get("trace_hops") or [] + legacy = parsed_data.get("trace_path") or [] + + trace_path_bytes = [h.hex().upper() for h in trace_hops[:8]] + if len(trace_hops) > 8: trace_path_bytes.append("...") path_hash = "[" + ", ".join(trace_path_bytes) + "]" - # Extract SNR information from the path + # Extract SNR information from the path (one SNR byte per hop along trace) path_snrs = [] path_snr_details = [] - for i in range(packet.path_len): - if i < len(packet.path): - snr_val = packet.path[i] - snr_db = snr_register_to_db(snr_val) - path_snrs.append(f"{snr_val}({snr_db:.1f}dB)") + for i in range(len(packet.path)): + snr_val = packet.path[i] + snr_db = snr_register_to_db(snr_val) + path_snrs.append(f"{snr_val}({snr_db:.1f}dB)") - # 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}", + if i < len(trace_hops): + path_snr_details.append( + { + "hash": trace_hops[i].hex().upper(), "snr_raw": snr_val, - "snr_db": snr_db - }) + "snr_db": snr_db, + } + ) + elif i < len(legacy): + path_snr_details.append( + { + "hash": f"{legacy[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, @@ -150,69 +243,77 @@ class TraceHelper: "path_hash": path_hash, "src_hash": None, "dst_hash": None, - "original_path": [f"{h:02X}" for h in trace_path], + "original_path": [h.hex() for h in trace_hops], "forwarded_path": None, # Add trace-specific SNR path information "path_snrs": path_snrs, # ["58(14.5dB)", "19(4.8dB)"] - "path_snr_details": path_snr_details, # [{"hash": "29", "snr_raw": 58, "snr_db": 14.5}] + "path_snr_details": path_snr_details, "is_trace": True, "raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None, } - def _extract_path_info(self, packet, trace_path: list) -> tuple: + def _extract_path_info(self, packet, parsed_data: dict) -> tuple: """ Extract SNR and hash information from the packet path. - Args: - packet: The trace packet - trace_path: The parsed trace path from the payload - Returns: - A tuple of (path_snrs, path_hashes) lists + A tuple of (path_snrs, path_hashes) display lists """ + trace_hops: List[bytes] = parsed_data.get("trace_hops") or [] path_snrs = [] path_hashes = [] - for i in range(packet.path_len): + for i in range(len(packet.path)): if i < len(packet.path): snr_val = packet.path[i] snr_db = snr_register_to_db(snr_val) path_snrs.append(f"{snr_val}({snr_db:.1f}dB)") - if i < len(trace_path): - path_hashes.append(f"0x{trace_path[i]:02x}") + if i < len(trace_hops): + path_hashes.append(f"0x{trace_hops[i].hex()}") return path_snrs, path_hashes - def _should_forward_trace(self, packet, trace_path: list, trace_path_len: int) -> bool: + def _should_forward_trace( + self, packet, trace_bytes: bytes, flags: int, hash_width: int + ) -> bool: """ - Determine if this node should forward the trace packet. - Uses the same logic as the original working implementation. - - Args: - packet: The trace packet - trace_path: The parsed trace path from the payload - trace_path_len: The length of the trace path - - Returns: - True if the packet should be forwarded, False otherwise + Mesh.cpp TRACE branch: forward if offset < len and next hash matches identity. + offset = pkt->path_len< packet.path_len and - trace_path[packet.path_len] == self.local_hash and - self.repeater_handler and not self.repeater_handler.is_duplicate(packet)) + if not trace_bytes or hash_width <= 0: + return False + snr_count = len(packet.path) + byte_off = snr_count * hash_width + if byte_off >= len(trace_bytes): + return False - async def _forward_trace_packet(self, packet, trace_path_len: int) -> None: + next_hop = trace_bytes[byte_off : byte_off + hash_width] + if len(next_hop) != hash_width: + return False + + pubkey_pfx = self._pubkey_prefix(hash_width) + if len(pubkey_pfx) >= hash_width: + match = next_hop == pubkey_pfx[:hash_width] + else: + match = hash_width == 1 and next_hop[0] == (self.local_hash & 0xFF) + + if not match: + return False + if not self.repeater_handler: + return False + return not self.repeater_handler.is_duplicate(packet) + + async def _forward_trace_packet(self, packet, num_hops: 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 + num_hops: Total hops in trace path (for logging) """ # 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: @@ -242,7 +343,8 @@ class TraceHelper: packet.path_len += 1 logger.info( - f"Forwarding trace, stored SNR {current_snr:.1f}dB at position {packet.path_len - 1}" + f"Forwarding trace ({num_hops} hop path), stored SNR {current_snr:.1f}dB " + f"at SNR index {packet.path_len - 1}" ) # Inject packet into router for proper routing and transmission @@ -251,21 +353,69 @@ class TraceHelper: else: logger.warning("No packet injector available - trace packet not forwarded") - def _log_no_forward_reason(self, packet, trace_path: list, trace_path_len: int) -> None: - """ - Log the reason why a trace packet was not forwarded. + def _log_no_forward_reason(self, packet, trace_bytes: bytes, hash_width: int) -> None: + """Log the reason why this node did not forward the trace.""" + if self.repeater_handler and self.repeater_handler.is_duplicate(packet): + logger.info("Duplicate packet, ignoring") + return + + snr_count = len(packet.path) + if not trace_bytes or hash_width <= 0: + logger.info("Trace: empty path or invalid hash width") + return + + if snr_count * hash_width >= len(trace_bytes): + logger.info("Trace completed (reached end of path)") + return + + byte_off = snr_count * hash_width + next_hop = trace_bytes[byte_off : byte_off + hash_width] + pubkey_pfx = self._pubkey_prefix(hash_width) + if len(next_hop) == hash_width and len(pubkey_pfx) >= hash_width: + if next_hop != pubkey_pfx[:hash_width]: + logger.info(f"Not our turn (next hop: 0x{next_hop.hex()})") + return + elif hash_width == 1 and next_hop: + if (next_hop[0] & 0xFF) != (self.local_hash & 0xFF): + logger.info(f"Not our turn (next hop: 0x{next_hop.hex()})") + return + + logger.info("Trace: not forwarded (internal)") + + def register_ping(self, tag: int, target_hash: int) -> asyncio.Event: + """Register a ping request and return an event to wait on. Args: - packet: The trace packet - trace_path: The parsed trace path from the payload - trace_path_len: The length of the trace path + 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 """ - if packet.path_len >= trace_path_len: - logger.info("Trace completed (reached end of path)") - 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 - 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") + event = asyncio.Event() + self.pending_pings[tag] = { + "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 + ] + for tag in stale_tags: + self.pending_pings.pop(tag) + logger.debug(f"Cleaned up stale ping with tag {tag}") + if stale_tags: + logger.info(f"Cleaned up {len(stale_tags)} stale ping(s)") diff --git a/repeater/identity_manager.py b/repeater/identity_manager.py new file mode 100644 index 0000000..7de98b7 --- /dev/null +++ b/repeater/identity_manager.py @@ -0,0 +1,67 @@ +import logging +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( + f"Hash collision! Identity '{name}' (hash=0x{hash_byte:02X}) " + 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", + "public_key": identity.get_public_key().hex() if identity else None, + } + ) + 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(): + if id_type == identity_type: + results.append((name, identity, config)) + return results diff --git a/repeater/keygen.py b/repeater/keygen.py new file mode 100644 index 0000000..65032be --- /dev/null +++ b/repeater/keygen.py @@ -0,0 +1,69 @@ +""" +MeshCore-compatible Ed25519 vanity key generator. + +Generates Ed25519 keys whose public key hex starts with a user-chosen prefix. +Algorithm matches MeshCore's custom scalar clamping (see meshcore-keygen). + +Requires: PyNaCl (pip install PyNaCl) +""" + +import hashlib +import secrets +from typing import Optional, Tuple + +from nacl.bindings import crypto_scalarmult_ed25519_base_noclamp + + +def generate_meshcore_keypair() -> Tuple[bytes, bytes]: + """Generate a MeshCore-compatible Ed25519 keypair. + + Returns: + (public_key, private_key) as raw bytes. + public_key is 32 bytes, private_key is 64 bytes. + """ + # 1. Random 32-byte seed + seed = secrets.token_bytes(32) + + # 2. SHA-512 hash + digest = hashlib.sha512(seed).digest() + + # 3. Ed25519 scalar clamping on first 32 bytes + clamped = bytearray(digest[:32]) + clamped[0] &= 248 # Clear bottom 3 bits + clamped[31] &= 63 # Clear top 2 bits + clamped[31] |= 64 # Set bit 6 + + # 4. Derive public key + public_key = crypto_scalarmult_ed25519_base_noclamp(bytes(clamped)) + + # 5. Private key = [clamped_scalar][sha512_upper_half] + private_key = bytes(clamped) + digest[32:64] + + return public_key, private_key + + +def generate_vanity_key( + prefix: str, + max_iterations: int = 5_000_000, +) -> Optional[dict]: + """Generate a MeshCore keypair whose public key hex starts with *prefix*. + + Args: + prefix: Hex prefix (1-4 chars, case-insensitive). + max_iterations: Safety cap to avoid infinite loops. + + Returns: + Dict with public_hex, private_hex, attempts on success; None if cap hit. + """ + target = prefix.upper() + + for attempt in range(1, max_iterations + 1): + pub, priv = generate_meshcore_keypair() + if pub.hex().upper().startswith(target): + return { + "public_hex": pub.hex(), + "private_hex": priv.hex(), + "attempts": attempt, + } + + return None diff --git a/repeater/local_cli.py b/repeater/local_cli.py new file mode 100644 index 0000000..b376740 --- /dev/null +++ b/repeater/local_cli.py @@ -0,0 +1,146 @@ +""" +CLI client for pyMC Repeater. +Connects to an already-running repeater daemon via its HTTP API. +Reads admin password and HTTP port from the local config.yaml automatically. +""" + +import sys + + +CONFIG_PATHS = [ + "/etc/pymc_repeater/config.yaml", + "config.yaml", +] + + +def _load_config(config_path=None): + """Load repeater config.yaml, trying common paths.""" + import yaml + from pathlib import Path + + paths = [config_path] if config_path else CONFIG_PATHS + for p in paths: + path = Path(p) + if path.is_file(): + with open(path) as f: + return yaml.safe_load(f) or {} + return {} + + +def run_client_cli(host: str = "127.0.0.1", port: int = 8000, password: str = ""): + """ + Standalone CLI client that connects to a running repeater's HTTP API. + """ + import urllib.request + import urllib.error + import json + + base_url = f"http://{host}:{port}" + + # Authenticate to get JWT token + token = None + if password: + try: + auth_data = json.dumps({ + "username": "admin", + "password": password, + "client_id": "pymc-cli", + }).encode() + req = urllib.request.Request( + f"{base_url}/auth/login", + data=auth_data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + result = json.loads(resp.read()) + token = result.get("token") or result.get("data", {}).get("token") + except urllib.error.URLError as e: + print(f"Error: Cannot connect to repeater at {base_url} — {e.reason}") + sys.exit(1) + except Exception as e: + print(f"Authentication failed: {e}") + sys.exit(1) + + if not token: + print("Error: Authentication failed. Check password or repeater status.") + sys.exit(1) + + print(f"\npyMC Repeater CLI (connected to {base_url})") + print("Type 'help' for available commands, 'exit' to quit.\n") + + while True: + try: + command = input(">> ").strip() + except (EOFError, KeyboardInterrupt): + print() + break + + if not command: + continue + if command in ("exit", "quit"): + break + + try: + payload = json.dumps({"command": command}).encode() + req = urllib.request.Request( + f"{base_url}/api/cli", + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {token}", + }, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: + result = json.loads(resp.read()) + if result.get("success"): + print(result["data"]["reply"]) + else: + print(f"Error: {result.get('error', 'Unknown error')}") + except urllib.error.URLError as e: + print(f"Connection error: {e.reason}") + except Exception as e: + print(f"Error: {e}") + + +def main(): + """Entry point for pymc-cli command.""" + import argparse + + parser = argparse.ArgumentParser( + description="Connect to a running pyMC Repeater and issue CLI commands" + ) + parser.add_argument( + "--config", default=None, + help="Path to config.yaml (auto-detected if not set)", + ) + parser.add_argument( + "--host", default=None, + help="Repeater HTTP host (default: 127.0.0.1)", + ) + parser.add_argument( + "--port", type=int, default=None, + help="Repeater HTTP port (default: from config or 8000)", + ) + args = parser.parse_args() + + # Load config to get password and port automatically + config = _load_config(args.config) + repeater_cfg = config.get("repeater", {}) + security_cfg = repeater_cfg.get("security", {}) + password = security_cfg.get("admin_password", "") + + if not password: + print("Error: No admin_password found in config.yaml.") + print("Searched: " + ", ".join(CONFIG_PATHS)) + sys.exit(1) + + host = args.host or "127.0.0.1" + port = args.port or config.get("http", {}).get("port", 8000) + + run_client_cli(host=host, port=port, password=password) + + +if __name__ == "__main__": + main() diff --git a/repeater/main.py b/repeater/main.py index ef11e51..2910b28 100644 --- a/repeater/main.py +++ b/repeater/main.py @@ -1,13 +1,29 @@ import asyncio +import functools import logging import os +import signal import sys +import socket +import time -from repeater.config import get_radio_for_board, load_config +from repeater.companion.utils import validate_companion_node_name, normalize_companion_identity_key +from repeater.config import get_radio_for_board, load_config, save_config +from repeater.config_manager import ConfigManager +from repeater.data_acquisition.glass_handler import GlassHandler from repeater.engine import RepeaterHandler -from repeater.web.http_server import HTTPStatsServer, _log_buffer -from repeater.handler_helpers import TraceHelper, DiscoveryHelper, AdvertHelper +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") @@ -22,12 +38,23 @@ class RepeaterDaemon: self.repeater_handler = None self.local_hash = None self.local_identity = None + self.identity_manager = None + self.config_manager = None self.http_server = None self.trace_helper = None self.advert_helper = None self.discovery_helper = None + self.login_helper = None + self.text_helper = None + self.path_helper = None + self.protocol_request_helper = None + self.glass_handler = None + self.acl = None self.router = None - + self.companion_bridges: dict[int, object] = {} + self.companion_frame_servers: list = [] + self._shutdown_started = False + self._main_task = None log_level = config.get("logging", {}).get("level", "INFO") logging.basicConfig( @@ -43,40 +70,67 @@ class RepeaterDaemon: logger.info(f"Initializing repeater: {self.config['repeater']['node_name']}") + #----------------------------------------------- + # Get the actual Network IP Address + try: + # This looks for the IP assigned to the default hostname + host_name = socket.gethostname() + # We try to get the IP associated with the hostname + self.network_ip = socket.gethostbyname(host_name) + + # If that still gives 127.0.x.x, let's try a different internal method + if self.network_ip.startswith("127."): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + # We use a non-routable IP that doesn't require an actual connection + s.connect(("10.255.255.255", 1)) + self.network_ip = s.getsockname()[0] + s.close() + except Exception as e: + logger.warning(f"Could not determine network IP: {e}") + self.network_ip = "Unknown" + + logger.info(f"System Network IP: {self.network_ip}") + #----------------------------------------------- + if self.radio is None: - logger.info("Initializing radio hardware...") + radio_type = self.config.get("radio_type", "sx1262") + logger.info(f"Initializing radio hardware... (radio_type={radio_type})") try: self.radio = get_radio_for_board(self.config) - - if hasattr(self.radio, 'set_custom_cad_thresholds'): + + # 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", {}) 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 @@ -84,7 +138,12 @@ class RepeaterDaemon: self.dispatcher = Dispatcher(self.radio) logger.info("Dispatcher initialized") - identity_key = self.config.get("mesh", {}).get("identity_key") + # Initialize Identity Manager for additional identities (e.g., room servers) + self.identity_manager = IdentityManager(self.config) + logger.info("Identity manager initialized") + + # Set up default repeater identity (not managed by identity manager) + identity_key = self.config.get("repeater", {}).get("identity_key") if not identity_key: logger.error("No identity key found in configuration. Cannot init repeater.") raise RuntimeError("Identity key is required for repeater operation") @@ -95,39 +154,60 @@ 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}" + local_hash_hex = f"0x{self.local_hash:02x}" logger.info(f"Local node hash (from identity): {local_hash_hex}") + # Load additional identities from config (e.g., room servers) + await self._load_additional_identities() 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 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) logger.info("Packet router registered as fallback (catches all packets)") + # Set default path hash mode for flood 0-hop packets (adverts, etc.) + path_hash_mode = self.config.get("mesh", {}).get("path_hash_mode", 0) + if path_hash_mode not in (0, 1, 2): + logger.warning( + f"Invalid mesh.path_hash_mode={path_hash_mode}, must be 0/1/2; using 0" + ) + path_hash_mode = 0 + self.dispatcher.set_default_path_hash_mode(path_hash_mode) + mode_names = {0: "1-byte", 1: "2-byte", 2: "3-byte"} + logger.info( + f"Path hash mode set to {mode_names[path_hash_mode]} (mesh.path_hash_mode={path_hash_mode})" + ) + # Create processing helpers (handlers created internally) self.trace_helper = TraceHelper( local_hash=self.local_hash, repeater_handler=self.repeater_handler, packet_injector=self.router.inject_packet, log_fn=logger.info, + local_identity=self.local_identity, ) logger.info("Trace processing helper initialized") - + # Create advert helper for neighbor tracking self.advert_helper = AdvertHelper( local_identity=self.local_identity, storage=self.repeater_handler.storage if self.repeater_handler else None, + config=self.config, log_fn=logger.info, ) logger.info("Advert processing helper initialized") @@ -140,15 +220,657 @@ 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: logger.info("Discovery response handler disabled") + # Create login helper (will create per-identity ACLs) + self.login_helper = LoginHelper( + identity_manager=self.identity_manager, + 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 + ) + + # Register room server identities with their configs + for name, identity, config in self.identity_manager.get_identities_by_type( + "room_server" + ): + self.login_helper.register_identity( + name=name, + identity=identity, + identity_type="room_server", + 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=self.config, + 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=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 + 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", {}), + ) + + # Register room server identities for text messages + 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.) + ) + + 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, + packet_injector=self.router.inject_packet, + acl_dict=self.login_helper.get_acl_dict(), + radio=self.radio, + engine=self.repeater_handler, + neighbor_tracker=self.advert_helper, + config=self.config, + ) + # Register repeater identity for protocol requests + self.protocol_request_helper.register_identity( + name="repeater", identity=self.local_identity, identity_type="repeater" + ) + logger.info("Protocol request handler initialized") + + # 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, + ) + + # Subscribe to parsed packets (pre-dedup) so duplicate path variants + # still appear in the web UI even though the Dispatcher blocks them. + self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging) + + # 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 + + # Optional pyMC_Glass integration loop (inform/control plane) + self.glass_handler = GlassHandler( + config=self.config, + daemon_instance=self, + config_manager=self.config_manager, + ) + await self.glass_handler.start() + if ( + self.repeater_handler + and self.repeater_handler.storage + and hasattr(self.repeater_handler.storage, "set_glass_publisher") + ): + self.repeater_handler.storage.set_glass_publisher(self.glass_handler.publish_telemetry) + except Exception as e: logger.error(f"Failed to initialize dispatcher: {e}") raise + 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") + continue + + # Convert identity_key to bytes if it's a hex string + if isinstance(identity_key, bytes): + identity_key_bytes = identity_key + elif isinstance(identity_key, str): + 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)" + ) + 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)}" + ) + 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", + ) + + 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") + + 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.models import Channel, Contact + + from repeater.companion import CompanionFrameServer, RepeaterCompanionBridge + + 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 + if not sqlite_handler and companions_config: + logger.warning( + "Companion persistence disabled: no storage (contacts/channels will not survive restart or disconnect)" + ) + + 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(normalize_companion_identity_key(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"0x{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") + tcp_timeout_raw = settings.get("tcp_timeout", 8 * 60 * 60) # 8 hours + client_idle_timeout_sec = None if tcp_timeout_raw == 0 else int(tcp_timeout_raw) + + def _make_sync_node_name_to_config(companion_name: str): + """Return a callback that syncs node_name to config for this companion (binds name at creation).""" + def _sync(new_node_name: str) -> None: + try: + validated = validate_companion_node_name(new_node_name) + except ValueError: + return + companions = (self.config.get("identities") or {}).get("companions") or [] + for entry in companions: + if entry.get("name") == companion_name: + if "settings" not in entry: + entry["settings"] = {} + entry["settings"]["node_name"] = validated + config_path = getattr(self, "config_path", None) + if config_path: + save_config(self.config, config_path) + break + return _sync + + 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, + on_prefs_saved=_make_sync_node_name_to_config(name), + ) + + # 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 (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: + 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 + 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, + client_idle_timeout_sec=client_idle_timeout_sec, + 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"Loaded companion '{name}': hash=0x{companion_hash:02x}, " + f"port={tcp_port}, bind={bind_address}, client_idle_timeout_sec={client_idle_timeout_sec}" + ) + + 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(normalize_companion_identity_key(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") + tcp_timeout_raw = settings.get("tcp_timeout", 120) + client_idle_timeout_sec = None if tcp_timeout_raw == 0 else int(tcp_timeout_raw) + + 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, + client_idle_timeout_sec=client_idle_timeout_sec, + 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}, client_idle_timeout_sec={client_idle_timeout_sec}" + ) + + 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) + + def _on_raw_packet_for_dedup_logging(self, pkt, data: bytes, analysis: dict) -> None: + """Record duplicate packets for UI visibility. + + Called by Dispatcher's raw_packet_subscriber (pre-dedup) so we see + all path variants. Only records packets the engine has already seen; + novel packets are left for the normal handler path. + """ + if not self.repeater_handler: + return + if not self.repeater_handler.is_duplicate(pkt): + return # First variant — will reach engine via normal handler path + rssi = getattr(pkt, "_rssi", 0) or 0 + snr = getattr(pkt, "_snr", 0.0) or 0.0 + self.repeater_handler.record_duplicate(pkt, rssi=rssi, snr=snr) + + 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_hashes = parsed_data.get("trace_path_bytes") or b"" + if not path_hashes: + return + flags = parsed_data.get("flags", 0) + path_sz = flags & 0x03 + hash_len = len(path_hashes) + expected_snr_len = hash_len >> path_sz + if expected_snr_len <= 0: + return + tag = parsed_data.get("tag", 0) + auth_code = parsed_data.get("auth_code", 0) + snr_scaled = max(-128, min(127, int(round(packet.get_snr() * 4)))) + snr_byte = snr_scaled if snr_scaled >= 0 else (256 + snr_scaled) + # Firmware: memcpy path_snrs from pkt->path (length hash_len >> path_sz), then final SNR byte + raw = bytes(packet.path)[:expected_snr_len] + if len(raw) < expected_snr_len: + raw = raw + b"\x00" * (expected_snr_len - len(raw)) + path_snrs = raw + for fs in getattr(self, "companion_frame_servers", []): + try: + await fs.push_trace_data_async( + hash_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, identity, config: dict, identity_type: str + ) -> bool: + """ + Register an identity with the manager and all helpers in one place. + This is the single source of truth for identity registration. + """ + # Register with identity manager + success = self.identity_manager.register_identity( + 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 + ) + + if self.text_helper: + self.text_helper.register_identity( + name=name, + identity=identity, + identity_type=identity_type, + 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 + ) + + return True + async def _router_callback(self, packet): """ Single entry point for ALL packets. @@ -159,9 +881,31 @@ 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 + ): + + if not self.text_helper: + logger.warning("Text helper not initialized, cannot register identity") + return False + + try: + self.text_helper.register_identity( + name=name, + identity=identity, + identity_type=identity_type, + radio_config=radio_config or self.config.get("radio", {}), + ) + logger.info(f"Registered text handler for {identity_type} '{name}'") + return True + 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 @@ -171,15 +915,73 @@ class RepeaterDaemon: stats["public_key"] = pubkey.hex() except Exception: stats["public_key"] = None - + return stats + 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, + STATS_TYPE_PACKETS, + STATS_TYPE_RADIO, + ) + + 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_cached_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: logger.error("Cannot send advert: dispatcher or identity not initialized") return False + mode = self.config.get("repeater", {}).get("mode", "forward") + if mode == "no_tx": + logger.debug("Adverts disabled in no_tx mode") + return False + try: from pymc_core.protocol import PacketBuilder from pymc_core.protocol.constants import ADVERT_FLAG_HAS_NAME, ADVERT_FLAG_IS_REPEATER @@ -218,57 +1020,186 @@ class RepeaterDaemon: logger.error(f"Failed to send advert: {e}", exc_info=True) return False + def _signal_shutdown(self, sig, loop): + """Handle SIGTERM/SIGINT by scheduling async shutdown.""" + if self._shutdown_started: + logger.info(f"Received signal {sig.name}, shutdown already in progress") + return + logger.info(f"Received signal {sig.name}, shutting down...") + loop.create_task(self._shutdown()) + # Cancel run() so dispatcher.run_forever() unwinds cleanly. + if self._main_task and not self._main_task.done(): + self._main_task.cancel() + + async def _shutdown(self): + """Best-effort shutdown: stop background services and release hardware.""" + if self._shutdown_started: + return + self._shutdown_started = True + + # Stop companion frame servers first to close client sockets and child workers. + for frame_server in getattr(self, "companion_frame_servers", []): + try: + await frame_server.stop() + except Exception as e: + logger.warning(f"Companion frame server stop error: {e}") + + # Stop companion bridges to flush/persist state. + 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.warning(f"Companion bridge stop error: {e}") + + # Stop router + if self.router: + try: + await self.router.stop() + except Exception as e: + logger.warning(f"Error stopping router: {e}") + + # Stop HTTP server + if self.http_server: + try: + await asyncio.wait_for(asyncio.to_thread(self.http_server.stop), timeout=3) + except asyncio.TimeoutError: + logger.warning("Timeout stopping HTTP server") + except Exception as e: + logger.warning(f"Error stopping HTTP server: {e}") + + # Stop Glass inform loop + if self.glass_handler: + try: + await self.glass_handler.stop() + except Exception as e: + logger.warning(f"Error stopping Glass handler: {e}") + + # Close storage publishers (MQTT/LetsMesh) to stop their worker threads. + try: + if self.repeater_handler and self.repeater_handler.storage: + await asyncio.wait_for( + asyncio.to_thread(self.repeater_handler.storage.close), timeout=5 + ) + except asyncio.TimeoutError: + logger.warning("Timeout closing storage publishers") + except Exception as e: + logger.warning(f"Error closing storage: {e}") + + # Release radio resources + if self.radio and hasattr(self.radio, "cleanup"): + try: + self.radio.cleanup() + except Exception as e: + logger.warning(f"Error cleaning up radio: {e}") + + # Release CH341 USB device if in use + try: + if self.config.get("radio_type", "sx1262").lower() == "sx1262_ch341": + from pymc_core.hardware.ch341.ch341_async import CH341Async + + CH341Async.reset_instance() + except Exception as e: + logger.debug(f"CH341 reset skipped/failed: {e}") + + # Do not force-stop the event loop here; asyncio.run() owns loop lifecycle. + + @staticmethod + def _detect_container() -> bool: + """Detect if running inside an LXC/Docker/systemd-nspawn container.""" + try: + with open("/proc/1/environ", "rb") as f: + if b"container=" in f.read(): + return True + except (OSError, PermissionError): + pass + return os.path.exists("/run/host/container-manager") + async def run(self): logger.info("Repeater daemon started") + self._main_task = asyncio.current_task() - await self.initialize() + # Register signal handlers for graceful shutdown + loop = asyncio.get_running_loop() + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler( + sig, + functools.partial(self._signal_shutdown, sig, loop), + ) - # Start HTTP stats server - http_port = self.config.get("http", {}).get("port", 8000) - http_host = self.config.get("http", {}).get("host", "0.0.0.0") - - node_name = self.config.get("repeater", {}).get("node_name", "Repeater") - - # Format public key for display - pub_key_formatted = "" - if self.local_identity: - pub_key_hex = self.local_identity.get_public_key().hex() - # Format as - if len(pub_key_hex) >= 16: - pub_key_formatted = f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}" - else: - pub_key_formatted = pub_key_hex - - current_loop = asyncio.get_event_loop() - - self.http_server = HTTPStatsServer( - host=http_host, - port=http_port, - stats_getter=self.get_stats, - 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'), - ) + # Warn if running inside a container (udev rules won't work here) + if os.path.exists("/.dockerenv") or os.environ.get("container") or self._detect_container(): + logger.warning( + "Container environment detected. " + "USB device udev rules must be configured on the HOST, not inside this container." + ) try: - self.http_server.start() - except Exception as e: - logger.error(f"Failed to start HTTP server: {e}") + await self.initialize() - # Run dispatcher (handles RX/TX via pymc_core) - try: - await self.dispatcher.run_forever() - except KeyboardInterrupt: - logger.info("Shutting down...") - if self.router: - await self.router.stop() - if self.http_server: - self.http_server.stop() + # Start HTTP stats server + http_port = self.config.get("http", {}).get("port", 8000) + http_host = self.config.get("http", {}).get("host", "0.0.0.0") + + node_name = self.config.get("repeater", {}).get("node_name", "Repeater") + + # Format public key for display + pub_key_formatted = "" + if self.local_identity: + pub_key_hex = self.local_identity.get_public_key().hex() + # Format as + if len(pub_key_hex) >= 16: + pub_key_formatted = f"{pub_key_hex[:8]}...{pub_key_hex[-8:]}" + else: + pub_key_formatted = pub_key_hex + + current_loop = asyncio.get_event_loop() + + self.http_server = HTTPStatsServer( + host=http_host, + port=http_port, + stats_getter=self.get_stats, + 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"), + ) + + try: + self.http_server.start() + except Exception as e: + logger.error(f"Failed to start HTTP server: {e}") + + # Run dispatcher (handles RX/TX via pymc_core) + try: + await self.dispatcher.run_forever() + except asyncio.CancelledError: + logger.info("Dispatcher loop cancelled for shutdown") + 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: + self.http_server.stop() + finally: + await self._shutdown() def main(): @@ -290,14 +1221,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 8621c6f..0b5024e 100644 --- a/repeater/packet_router.py +++ b/repeater/packet_router.py @@ -1,41 +1,82 @@ -""" -Packet router for pyMC Repeater. - -This module provides a simple router that routes packets to appropriate handlers -based on payload type. All statistics, queuing, and processing logic is handled -by the repeater engine for better separation of concerns. -""" - import asyncio import logging +import time -from pymc_core.node.handlers.trace import TraceHandler -from pymc_core.node.handlers.control import ControlHandler +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.path import PathHandler +from pymc_core.node.handlers.protocol_request import ProtocolRequestHandler +from pymc_core.node.handlers.protocol_response import ProtocolResponseHandler +from pymc_core.node.handlers.text import TextMessageHandler +from pymc_core.node.handlers.trace import TraceHandler +from pymc_core.protocol.constants import ( + PH_ROUTE_MASK, + ROUTE_TYPE_DIRECT, + ROUTE_TYPE_TRANSPORT_DIRECT, +) 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 + + +def _is_direct_final_hop(packet) -> bool: + """True if packet is DIRECT (or TRANSPORT_DIRECT) with empty path — we're the final destination.""" + route = getattr(packet, "header", 0) & PH_ROUTE_MASK + if route != ROUTE_TYPE_DIRECT and route != ROUTE_TYPE_TRANSPORT_DIRECT: + return False + path = getattr(packet, "path", None) + return not path or len(path) == 0 + class PacketRouter: - """ - Simple router that processes packets through handlers sequentially. - All statistics and processing decisions are handled by the engine. - """ - + def __init__(self, daemon_instance): self.daemon = daemon_instance - self.queue = asyncio.Queue() + self.queue = asyncio.Queue(maxsize=500) self.running = False 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 = {} + # Safety valve: cap the number of _route_packet tasks sleeping concurrently. + # LoRa's airtime budget naturally limits throughput, but burst arrivals + # (multi-hop amplification, collision retries) can stack many sleeping + # delay tasks before the duty-cycle gate fires. 30 is very generous for + # any realistic LoRa network but protects against pathological scenarios + # (e.g. a busy bridge node during a mesh-wide flood) exhausting memory or + # starving the event loop. + self._in_flight: int = 0 + self._max_in_flight: int = 30 + # Live set of in-flight tasks — kept in sync with _in_flight via the + # done-callback. Used exclusively for shutdown drain; the integer + # counter is used for the cap check (faster, single source of truth). + self._route_tasks: set = set() + # Total packets dropped because the cap was reached. Exposed in logs + # at shutdown so operators know whether the cap is actually firing. + self._cap_drop_count: int = 0 + async def start(self): - """Start the router processing task.""" self.running = True self.router_task = asyncio.create_task(self._process_queue()) logger.info("Packet router started") async def stop(self): - """Stop the router processing task.""" self.running = False if self.router_task: self.router_task.cancel() @@ -43,89 +84,374 @@ class PacketRouter: await self.router_task except asyncio.CancelledError: pass + + # Drain in-flight tasks gracefully, then cancel any that outlast the + # timeout. This mirrors what the old _route_tasks set enabled and gives + # in-progress packets a fair chance to finish (e.g. their TX delay sleep + # + send) before the process exits. + if self._route_tasks: + pending_snapshot = set(self._route_tasks) + logger.info( + "Draining %d in-flight route task(s) (5 s timeout)...", + len(pending_snapshot), + ) + _, still_pending = await asyncio.wait(pending_snapshot, timeout=5.0) + if still_pending: + logger.warning( + "Cancelling %d route task(s) that did not finish within the shutdown timeout", + len(still_pending), + ) + for task in still_pending: + task.cancel() + await asyncio.gather(*still_pending, return_exceptions=True) + + if self._cap_drop_count: + logger.warning( + "In-flight cap dropped %d packet(s) during this session — " + "consider raising _max_in_flight if this is frequent", + self._cap_drop_count, + ) logger.info("Packet router stopped") + + def _on_route_done(self, task: asyncio.Task) -> None: + """Done-callback for _route_packet tasks: decrement counter and surface errors.""" + self._in_flight -= 1 + self._route_tasks.discard(task) + if not task.cancelled(): + exc = task.exception() + if exc is not None: + logger.error("_route_packet raised: %s", exc, exc_info=exc) + 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 entries only when the dict grows large, avoiding a full + # dict comprehension on every packet. 200 entries × 60 s TTL means a + # sweep only triggers after ~200 unique PATH packets with no expiry — far + # more than any realistic companion session, and well below the 1000-entry + # threshold that could accumulate over hours without pruning. + if len(self._companion_delivered) > 200: + 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 + + def _record_for_ui(self, packet, metadata: dict) -> None: + """Record an injection-only packet for the web UI (storage + recent_packets).""" + handler = getattr(self.daemon, "repeater_handler", None) + if handler and getattr(handler, "storage", None): + try: + handler.record_packet_only(packet, metadata) + except Exception as e: + logger.debug("Record for UI failed: %s", e) + async def enqueue(self, packet): """Add packet to router queue.""" + if self.queue.full(): + logger.warning("Packet router queue full (%d), dropping oldest", self.queue.maxsize) + try: + self.queue.get_nowait() + except asyncio.QueueEmpty: + pass await self.queue.put(packet) async def inject_packet(self, packet, wait_for_ack: bool = False): - """ - Inject a new packet into the system for transmission through the engine. - - This method uses the engine's main packet handler with the local_transmission - flag to bypass forwarding logic while maintaining proper statistics and airtime. - - Args: - packet: The packet to send - wait_for_ack: Whether to wait for acknowledgment - - Returns: - True if packet was sent successfully, False otherwise - """ 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) - + + # 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 + ) + + # Mark so when this packet is dequeued we don't pass to engine again (avoid double-send / double-count) + packet._injected_for_tx = 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)") + 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: logger.error(f"Error injecting packet through engine: {e}") return False async def _process_queue(self): - """Process packets through the router queue.""" while self.running: try: packet = await asyncio.wait_for(self.queue.get(), timeout=0.1) - await self._route_packet(packet) + # Drop early if the in-flight cap is reached. This is a last-resort + # safety valve — under normal operation LoRa airtime and the duty-cycle + # gate keep _in_flight well below _max_in_flight. + if self._in_flight >= self._max_in_flight: + self._cap_drop_count += 1 + logger.warning( + "In-flight task cap reached (%d/%d), dropping packet " + "(session total dropped: %d)", + self._in_flight, self._max_in_flight, self._cap_drop_count, + ) + continue + self._in_flight += 1 + task = asyncio.create_task(self._route_packet(packet)) + self._route_tasks.add(task) + task.add_done_callback(self._on_route_done) except asyncio.TimeoutError: continue except Exception as e: logger.error(f"Router error: {e}", exc_info=True) - - + async def _route_packet(self, packet): - """ - Route a packet to appropriate handlers based on payload type. - - Simple routing logic: - 1. Route to specific handlers for parsing - 2. Pass to repeater engine for all processing decisions - """ + payload_type = packet.get_payload_type() processed_by_injection = False - + metadata = { + "rssi": getattr(packet, "rssi", 0), + "snr": getattr(packet, "snr", 0.0), + "timestamp": getattr(packet, "timestamp", 0), + } + # Route to specific handlers for parsing only if payload_type == TraceHandler.payload_type(): - # Process trace packet - if self.daemon.trace_helper: + # Locally injected TRACE requests are TX-only and re-enter the router so + # companion delivery can still happen. They are not inbound RF responses, + # so skip TraceHelper parsing to avoid matching pending ping tags against + # zeroed local metadata. + if getattr(packet, "_injected_for_tx", False): + processed_by_injection = True + elif self.daemon.trace_helper: await self.daemon.trace_helper.process_trace_packet(packet) # Skip engine processing for trace packets - they're handled by trace helper processed_by_injection = True + # Do not call _record_for_ui: TraceHelper.log_trace_record already persists the + # trace path from the payload. record_packet_only would treat packet.path (SNR bytes) + # as routing hashes and log bogus duplicate rows. elif payload_type == ControlHandler.payload_type(): # Process control/discovery packet 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: 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(): + # Route to companion if dest is a companion; else to login_helper (for logging into this repeater). + # When dest is remote (not handled), pass to engine so DIRECT/FLOOD ANON_REQ can be forwarded. + # Our own injected ANON_REQ is suppressed by the engine's duplicate (mark_seen) check. + 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) + if handled: + processed_by_injection = True + if processed_by_injection: + self._record_for_ui(packet, metadata) + + 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. + # Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop. + 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}") + + elif payload_type == TextMessageHandler.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 + self._record_for_ui(packet, metadata) + elif self.daemon.text_helper: + handled = await self.daemon.text_helper.process_text_packet(packet) + if handled: + processed_by_injection = True + self._record_for_ui(packet, metadata) + + elif payload_type == PathHandler.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: + if self._should_deliver_path_to_companions(packet): + await companion_bridges[dest_hash].process_received_packet(packet) + # Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop. + 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), + ) + # Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop. + elif self.daemon.path_helper: + await self.daemon.path_helper.process_path_packet(packet) + + elif payload_type == LoginResponseHandler.payload_type(): + # 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). + # Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop. + dest_hash = packet.payload[0] if packet.payload and len(packet.payload) >= 1 else None + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + local_hash = getattr(self.daemon, "local_hash", None) + if dest_hash is not None and dest_hash in companion_bridges: + try: + 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 RESPONSE error: {e}") + 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), + ) + elif companion_bridges: + # 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 (anon) delivered to %d bridge(s) for matching", + dest_hash or 0, + len(companion_bridges), + ) + if companion_bridges and _is_direct_final_hop(packet): + # DIRECT with empty path: we're the final hop; don't pass to engine (it would drop with "Direct: no path") + processed_by_injection = True + self._record_for_ui(packet, metadata) + + elif payload_type == ProtocolResponseHandler.payload_type(): + # PAYLOAD_TYPE_PATH (0x08): protocol responses (telemetry, binary, etc.). + # Deliver at most once per logical packet so the client is not spammed with duplicates. + # Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop. + companion_bridges = getattr(self.daemon, "companion_bridges", {}) + 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 and _is_direct_final_hop(packet): + # DIRECT with empty path: we're the final hop; ensure delivery to all bridges (anon) + if not 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 (final hop) error: {e}") + processed_by_injection = True + self._record_for_ui(packet, metadata) + + elif payload_type == ProtocolRequestHandler.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 + self._record_for_ui(packet, metadata) + elif self.daemon.protocol_request_helper: + handled = await self.daemon.protocol_request_helper.process_request_packet(packet) + if handled: + processed_by_injection = True + self._record_for_ui(packet, metadata) + elif companion_bridges and _is_direct_final_hop(packet): + # DIRECT with empty path: we're the final hop; deliver to all bridges for anon matching + for bridge in companion_bridges.values(): + try: + await bridge.process_received_packet(packet) + except Exception as e: + logger.debug(f"Companion bridge REQ (final hop) error: {e}") + processed_by_injection = True + self._record_for_ui(packet, metadata) + + 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 + # Skip engine for packets we injected for TX (already sent; avoid double-send/double-count) + if getattr(packet, "_injected_for_tx", False): + processed_by_injection = True if self.daemon.repeater_handler and not processed_by_injection: metadata = { "rssi": getattr(packet, "rssi", 0), diff --git a/repeater/service_utils.py b/repeater/service_utils.py new file mode 100644 index 0000000..a127e48 --- /dev/null +++ b/repeater/service_utils.py @@ -0,0 +1,110 @@ +""" +Service management utilities for pyMC Repeater. +Provides functions for service control operations like restart. +""" + +import logging +import os +import subprocess +from typing import Tuple + +logger = logging.getLogger("ServiceUtils") +INIT_SCRIPT = "/etc/init.d/S80pymc-repeater" + + +def is_buildroot() -> bool: + if os.path.exists("/etc/pymc-image-build-id"): + return True + if os.path.exists("/etc/os-release"): + try: + with open("/etc/os-release", "r", encoding="utf-8") as handle: + return any(line.strip() == "ID=buildroot" for line in handle) + except OSError: + return False + return False + + +def restart_service() -> Tuple[bool, str]: + """ + Restart the pymc-repeater service. + + On Buildroot/Luckfox, use the shipped init script directly. + On systemd hosts, try polkit-based restart first (plain systemctl), then + fall back to sudo-based restart (requires sudoers.d rule installed by + manage.sh). + + Returns: + Tuple[bool, str]: (success, message) + """ + if is_buildroot(): + if not os.path.exists(INIT_SCRIPT): + logger.error("Buildroot init script not found: %s", INIT_SCRIPT) + return False, f"init script not found: {INIT_SCRIPT}" + + try: + subprocess.Popen( + ["/bin/sh", "-c", f"sleep 1; exec {INIT_SCRIPT} restart >/dev/null 2>&1"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + stdin=subprocess.DEVNULL, + start_new_session=True, + ) + logger.info("Service restart scheduled via Buildroot init script") + return True, "Service restart initiated" + except Exception as exc: + logger.error(f"Buildroot restart failed: {exc}") + return False, f"Restart failed: {exc}" + + # Try polkit-based restart first (works on bare metal / VMs with polkit running) + try: + result = subprocess.run( + ["systemctl", "restart", "pymc-repeater"], capture_output=True, text=True, timeout=5 + ) + + if result.returncode == 0: + logger.info("Service restart via polkit succeeded") + return True, "Service restart initiated" + + stderr = result.stderr or "" + if "Access denied" in stderr or "authorization" in stderr.lower(): + logger.info("Polkit denied restart, trying sudo fallback...") + else: + # Some other error, still try sudo + logger.warning(f"systemctl restart failed ({result.returncode}): {stderr.strip()}") + + except subprocess.TimeoutExpired: + # Timeout likely means it's restarting - that's success + logger.warning("Service restart command timed out (service may be restarting)") + return True, "Service restart initiated (timeout - likely restarting)" + except FileNotFoundError: + logger.error("systemctl not found") + return False, "systemctl not available" + except Exception as e: + logger.warning(f"Polkit restart attempt failed: {e}") + + # Fallback: use sudo (requires /etc/sudoers.d/pymc-repeater rule) + try: + result = subprocess.run( + ['sudo', '--non-interactive', 'systemctl', 'restart', 'pymc-repeater'], + capture_output=True, + text=True, + timeout=5 + ) + + if result.returncode == 0: + logger.info("Service restart via sudo succeeded") + return True, "Service restart initiated" + else: + error_msg = result.stderr or "Unknown error" + logger.error(f"Service restart via sudo failed: {error_msg}") + return False, f"Restart failed: {error_msg}" + + except subprocess.TimeoutExpired: + logger.warning("Sudo restart timed out (service likely restarting)") + return True, "Service restart initiated (timeout - likely restarting)" + except FileNotFoundError: + logger.error("sudo not found - cannot restart service") + return False, "Neither polkit nor sudo available for service restart" + except Exception as e: + logger.error(f"Error executing sudo restart: {e}") + return False, f"Restart command failed: {str(e)}" diff --git a/repeater/web/__init__.py b/repeater/web/__init__.py index 77ae3f9..eb91a64 100644 --- a/repeater/web/__init__.py +++ b/repeater/web/__init__.py @@ -1,12 +1,14 @@ -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 +from .update_endpoints import UpdateAPIEndpoints __all__ = [ - 'HTTPStatsServer', - 'StatsApp', - 'LogBuffer', - 'APIEndpoints', - 'CADCalibrationEngine', - '_log_buffer' -] \ No newline at end of file + "HTTPStatsServer", + "StatsApp", + "LogBuffer", + "APIEndpoints", + "CADCalibrationEngine", + "UpdateAPIEndpoints", + "_log_buffer", +] diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index ccb2e17..4776038 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -3,95 +3,233 @@ import logging import os 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 repeater.companion.identity_resolve import ( + derive_companion_public_key_hex, + find_companion_index, + heal_companion_empty_names, +) +from repeater.config import update_unscoped_flood_policy + +from .auth.middleware import require_auth +from .auth_endpoints import AuthAPIEndpoints from .cad_calibration_engine import CADCalibrationEngine +from .companion_endpoints import CompanionAPIEndpoints +from .update_endpoints import UpdateAPIEndpoints logger = logging.getLogger("HTTPServer") -# system systems -# GET /api/stats -# GET /api/logs +# ============================================================================ +# API ENDPOINT DOCUMENTATION +# ============================================================================ -# # Packets -# GET /api/packet_stats?hours=24 -# GET /api/recent_packets?limit=100 -# GET /api/filtered_packets?type=4&route=1&start_timestamp=X&end_timestamp=Y&limit=1000 -# GET /api/packet_by_hash?packet_hash=abc123 -# GET /api/packet_type_stats?hours=24 +# Authentication (see auth_endpoints.py for implementation) +# POST /auth/login - Authenticate and get JWT token +# POST /auth/refresh - Refresh JWT token +# GET /auth/verify - Verify current authentication +# POST /auth/change_password - Change admin password +# GET /api/auth/tokens - List all API tokens (RESTful) +# POST /api/auth/tokens - Create new API token (RESTful) +# DELETE /api/auth/tokens/{token_id} - Revoke API token (RESTful) -# Charts & RRD -# GET /api/rrd_data?start_time=X&end_time=Y&resolution=average -# GET /api/packet_type_graph_data?hours=24&resolution=average&types=all -# GET /api/metrics_graph_data?hours=24&resolution=average&metrics=all - -# Noise Floor -# GET /api/noise_floor_history?hours=24 -# GET /api/noise_floor_stats?hours=24 -# GET /api/noise_floor_chart_data?hours=24 +# System +# GET /api/stats - Get system statistics +# GET /api/logs - Get system logs +# GET /api/hardware_stats - Get hardware statistics +# GET /api/hardware_processes - Get process information +# POST /api/restart_service - Restart the repeater service +# GET /api/openapi - Get OpenAPI specification # Repeater Control -# POST /api/send_advert -# POST /api/set_mode {"mode": "forward|monitor"} -# POST /api/set_duty_cycle {"enabled": true|false} +# POST /api/send_advert - Send repeater advertisement +# POST /api/set_mode {"mode": "forward|monitor|no_tx"} - Set repeater mode +# POST /api/set_duty_cycle {"enabled": true|false} - Enable/disable duty cycle +# POST /api/update_duty_cycle_config {"enabled": true, "on_time": 300, "off_time": 60} - Update duty cycle config +# POST /api/update_radio_config - Update radio configuration +# POST /api/update_advert_rate_limit_config - Update advert rate limiting settings +# GET /api/mqtt_status - Get MQTT Observer connection status +# POST /api/update_mqtt_config - Update MQTT Observer configuration + +# Packets +# GET /api/packet_stats?hours=24 - Get packet 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 +# GET /api/packet_by_hash?packet_hash=abc123 - Get specific packet by hash + +# Charts & RRD +# GET /api/rrd_data?start_time=X&end_time=Y&resolution=average - Get RRD data +# GET /api/packet_type_graph_data?hours=24&resolution=average&types=all - Get packet type graph data +# GET /api/metrics_graph_data?hours=24&resolution=average&metrics=all - Get metrics graph data + +# Noise Floor +# GET /api/noise_floor_history?hours=24 - Get noise floor history +# GET /api/noise_floor_stats?hours=24 - Get noise floor statistics +# GET /api/noise_floor_chart_data?hours=24 - Get noise floor chart data # CAD Calibration -# POST /api/cad_calibration_start {"samples": 8, "delay": 100} -# POST /api/cad_calibration_stop -# POST /api/save_cad_settings {"peak": 127, "min_val": 64} -# GET /api/cad_calibration_stream (SSE) +# POST /api/cad_calibration_start {"samples": 8, "delay": 100} - Start CAD calibration +# POST /api/cad_calibration_stop - Stop CAD calibration +# POST /api/save_cad_settings {"peak": 127, "min_val": 64} - Save CAD settings +# GET /api/cad_calibration_stream - CAD calibration SSE stream +# Adverts & Contacts +# GET /api/adverts_by_contact_type?contact_type=X&limit=100&hours=24 - Get adverts by contact type +# GET /api/advert?advert_id=123 - Get specific advert +# GET /api/advert_rate_limit_stats - Get advert rate limiting and adaptive tier stats + +# Transport Keys +# GET /api/transport_keys - List all transport keys +# POST /api/transport_keys - Create new transport key +# GET /api/transport_key?key_id=X - Get specific transport key +# DELETE /api/transport_key?key_id=X - Delete transport key + +# Network Policy +# GET /api/unscoped_flood_policy - Get unscoped flood policy +# POST /api/unscoped_flood_policy - Update unscoped flood policy +# POST /api/ping_neighbor - Ping a neighbor node + +# Identity Management +# GET /api/identities - List all identities +# GET /api/identity?name= - Get specific identity +# POST /api/create_identity {"name": "...", "identity_key": "...", "type": "room_server", "settings": {...}} - Create identity +# PUT /api/update_identity {"name": "...", "new_name": "...", "identity_key": "...", "settings": {...}} - Update identity +# DELETE /api/delete_identity?name= - Delete identity +# POST /api/send_room_server_advert {"name": "...", "node_name": "...", "latitude": 0.0, "longitude": 0.0} - Send room server advert + +# ACL (Access Control List) +# GET /api/acl_info - Get ACL configuration and stats for all identities +# GET /api/acl_clients?identity_hash=0x42&identity_name=repeater - List authenticated clients +# POST /api/acl_remove_client {"public_key": "...", "identity_hash": "0x42"} - Remove client from ACL +# GET /api/acl_stats - Overall ACL statistics + +# Room Server +# GET /api/room_messages?room_name=General&limit=50&offset=0&since_timestamp=X - Get messages from room +# GET /api/room_messages?room_hash=0x42&limit=50 - Get messages by room hash +# POST /api/room_post_message {"room_name": "General", "message": "Hello", "author_pubkey": "abc123"} - Post message +# GET /api/room_stats?room_name=General - Get room statistics +# GET /api/room_stats - Get all rooms statistics +# GET /api/room_clients?room_name=General - Get clients synced to room +# DELETE /api/room_message?room_name=General&message_id=123 - Delete specific message +# DELETE /api/room_messages_clear?room_name=General - Clear all messages in room + +# OTA Updates +# GET /api/update/status - Current + latest version, channel, state +# POST /api/update/check - Force fresh GitHub version check +# POST /api/update/install - Start background upgrade; stream via /progress +# GET /api/update/progress - SSE stream of live install log lines +# GET /api/update/channels - List available release channels (branches) +# POST /api/update/set_channel - Switch release channel {"channel": "dev"} + +# Setup Wizard +# GET /api/needs_setup - Check if repeater needs initial setup +# GET /api/hardware_options - Get available hardware configurations +# GET /api/radio_presets - Get radio preset configurations +# POST /api/setup_wizard - Complete initial setup wizard + +# Backup & Restore +# GET /api/config_export - Export config as JSON (redacts secrets, ?include_secrets=true for full backup) +# POST /api/config_import - Import config JSON and apply (supports full backup restore with secrets) +# GET /api/identity_export - Export repeater identity key as hex string +# POST /api/generate_vanity_key - Generate Ed25519 key with hex prefix {"prefix": "F8", "apply": false} +# +# Database Management +# GET /api/db_stats - Get table row counts, date ranges, database size +# POST /api/db_purge - Purge (empty) one or more tables +# POST /api/db_vacuum - Reclaim disk space (VACUUM) # Common Parameters -# hours - Time range (default: 24) -# resolution - 'average', 'max', 'min' (default: 'average') -# limit - Max results (default varies) +# hours - Time range in hours (default: 24) +# resolution - Data resolution: 'average', 'max', 'min' (default: 'average') +# limit - Maximum results (default varies by endpoint) +# offset - Result offset for pagination (default: 0) # type - Packet type 0-15 # route - Route type 1-3 - +# ============================================================================ 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 + ) + + # 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, self.config_manager + ) + + # Create nested update object for /api/update/* routes + self.update = UpdateAPIEndpoints() + def _is_cors_enabled(self): return self.config.get("web", {}).get("cors_enabled", False) 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): @@ -118,9 +256,22 @@ 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 _fmt_hash(self, pubkey: bytes) -> str: + """Format a node hash as a hex string respecting the configured path_hash_mode. + + path_hash_mode 0 (default) → 1-byte "0x19" + path_hash_mode 1 → 2-byte "0x1927" + path_hash_mode 2 → 3-byte "0x192722" + """ + mode = self.config.get("mesh", {}).get("path_hash_mode", 0) + byte_count = {0: 1, 1: 2, 2: 3}.get(mode, 1) + hex_chars = byte_count * 2 + value = int.from_bytes(bytes(pubkey[:byte_count]), "big") + return f"0x{value:0{hex_chars}X}" + def _get_time_range(self, hours): end_time = int(time.time()) return end_time - (hours * 3600), end_time @@ -142,6 +293,324 @@ class APIEndpoints: values = [v if v is not None else 0 for v in data_points] return [[timestamps_ms[i], values[i]] for i in range(min(len(values), len(timestamps_ms)))] + # ============================================================================ + # SETUP WIZARD ENDPOINTS + # ============================================================================ + + @cherrypy.expose + @cherrypy.tools.json_out() + def needs_setup(self): + """Check if the repeater needs initial setup configuration""" + try: + 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", ""] + + 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, + }, + } + except Exception as e: + logger.error(f"Error checking setup status: {e}") + return {"needs_setup": False, "error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_out() + def hardware_options(self): + """Get available hardware configurations from radio-settings.json""" + try: + import json + + # Check config-based location first, then development location + storage_dir_cfg = ( + self.config.get("storage", {}).get("storage_dir") + or self.config.get("storage_dir") + or "/var/lib/pymc_repeater" + ) + config_dir = Path(storage_dir_cfg) + 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: + 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}") + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_out() + def radio_presets(self): + """Get radio preset configurations from local file""" + try: + import json + + # Check config-based location first, then development location + storage_dir_cfg = ( + self.config.get("storage", {}).get("storage_dir") + or self.config.get("storage_dir") + or "/var/lib/pymc_repeater" + ) + config_dir = Path(storage_dir_cfg) + 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: + 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"} + + except Exception as e: + logger.error(f"Error loading radio presets: {e}") + return {"error": str(e)} + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def setup_wizard(self): + """Complete initial setup wizard configuration""" + try: + self._require_post() + data = cherrypy.request.json + + # Validate required fields + node_name = data.get("node_name", "").strip() + if not node_name: + 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 not hardware_key: + 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() + if not admin_password or len(admin_password) < 6: + return {"success": False, "error": "Admin password must be at least 6 characters"} + + import json + + storage_dir_cfg = ( + self.config.get("storage", {}).get("storage_dir") + or self.config.get("storage_dir") + or "/var/lib/pymc_repeater" + ) + config_dir = Path(storage_dir_cfg) + 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 hardware_key != "kiss": + 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) + 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}"} + else: + hw_config = {} + + import yaml + + # 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 (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 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 / sx1262_ch341: radio_type and optional CH341 from hw_config + if "radio_type" in hw_config: + config_yaml["radio_type"] = hw_config.get("radio_type") + else: + config_yaml["radio_type"] = "sx1262" + + ch341_cfg = hw_config.get("ch341") if isinstance(hw_config.get("ch341"), dict) else None + vid = (ch341_cfg or {}).get("vid", hw_config.get("vid")) + pid = (ch341_cfg or {}).get("pid", hw_config.get("pid")) + if vid is not None or pid is not None: + if "ch341" not in config_yaml: + config_yaml["ch341"] = {} + if vid is not None: + config_yaml["ch341"]["vid"] = vid + if pid is not None: + config_yaml["ch341"]["pid"] = pid + + 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 "en_pin" in hw_config: + config_yaml["sx1262"]["en_pin"] = hw_config.get("en_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 "dio3_tcxo_voltage" in hw_config: + config_yaml["sx1262"]["dio3_tcxo_voltage"] = hw_config.get("dio3_tcxo_voltage", 1.8) + 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" + ) + + # 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: + from repeater.service_utils import restart_service + restart_service() + 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"), + } + 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)} + + # ============================================================================ + # SYSTEM ENDPOINTS + # ============================================================================ + @cherrypy.expose @cherrypy.tools.json_out() def stats(self): @@ -150,6 +619,7 @@ class APIEndpoints: stats["version"] = __version__ try: import pymc_core + stats["core_version"] = pymc_core.__version__ except ImportError: stats["core_version"] = "unknown" @@ -161,6 +631,12 @@ class APIEndpoints: @cherrypy.expose @cherrypy.tools.json_out() 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: @@ -168,9 +644,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 @@ -182,12 +663,18 @@ class APIEndpoints: @cherrypy.tools.json_out() @cherrypy.tools.json_in() 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 new_mode = data.get("mode", "forward") - if new_mode not in ["forward", "monitor"]: - return self._error("Invalid mode. Must be 'forward' or 'monitor'") + if new_mode not in ["forward", "monitor", "no_tx"]: + return self._error("Invalid mode. Must be 'forward', 'monitor', or 'no_tx'") if "repeater" not in self.config: self.config["repeater"] = {} self.config["repeater"]["mode"] = new_mode @@ -204,6 +691,12 @@ class APIEndpoints: @cherrypy.tools.json_out() @cherrypy.tools.json_in() 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 @@ -220,10 +713,472 @@ class APIEndpoints: logger.error(f"Error setting duty cycle: {e}", exc_info=True) return self._error(e) + @cherrypy.expose + @cherrypy.tools.json_out() + @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"]) + if percent < 0.1 or percent > 100.0: + return self._error("Max airtime percent must be 0.1-100.0") + # Convert percent to milliseconds per minute + 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"] + ) + + 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": 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: + logger.error(f"Error updating duty cycle config: {e}") + return self._error(str(e)) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def update_advert_rate_limit_config(self): + """Update advert rate limiting configuration using ConfigManager. + + POST /api/update_advert_rate_limit_config + Body: { + "rate_limit_enabled": true, + "bucket_capacity": 2, + "refill_tokens": 1, + "refill_interval_seconds": 36000, + "min_interval_seconds": 3600, + "penalty_enabled": true, + "violation_threshold": 2, + "violation_decay_seconds": 43200, + "base_penalty_seconds": 21600, + "penalty_multiplier": 2.0, + "max_penalty_seconds": 86400, + "adaptive_enabled": true, + "ewma_alpha": 0.1, + "hysteresis_seconds": 300, + "quiet_max": 0.05, + "normal_max": 0.20, + "busy_max": 0.50 + } + """ + 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 "repeater" not in self.config: + self.config["repeater"] = {} + if "advert_rate_limit" not in self.config["repeater"]: + self.config["repeater"]["advert_rate_limit"] = {} + if "advert_penalty_box" not in self.config["repeater"]: + self.config["repeater"]["advert_penalty_box"] = {} + if "advert_adaptive" not in self.config["repeater"]: + self.config["repeater"]["advert_adaptive"] = {"thresholds": {}} + + rate_cfg = self.config["repeater"]["advert_rate_limit"] + penalty_cfg = self.config["repeater"]["advert_penalty_box"] + adaptive_cfg = self.config["repeater"]["advert_adaptive"] + + # Rate limit settings + if "rate_limit_enabled" in data: + rate_cfg["enabled"] = bool(data["rate_limit_enabled"]) + applied.append(f"rate_limit={'enabled' if rate_cfg['enabled'] else 'disabled'}") + + if "bucket_capacity" in data: + cap = max(1, int(data["bucket_capacity"])) + rate_cfg["bucket_capacity"] = cap + applied.append(f"bucket_capacity={cap}") + + if "refill_tokens" in data: + tokens = max(1, int(data["refill_tokens"])) + rate_cfg["refill_tokens"] = tokens + applied.append(f"refill_tokens={tokens}") + + if "refill_interval_seconds" in data: + interval = max(60, int(data["refill_interval_seconds"])) + rate_cfg["refill_interval_seconds"] = interval + applied.append(f"refill_interval={interval}s") + + if "min_interval_seconds" in data: + min_int = max(0, int(data["min_interval_seconds"])) + rate_cfg["min_interval_seconds"] = min_int + applied.append(f"min_interval={min_int}s") + + # Penalty box settings + if "penalty_enabled" in data: + penalty_cfg["enabled"] = bool(data["penalty_enabled"]) + applied.append(f"penalty={'enabled' if penalty_cfg['enabled'] else 'disabled'}") + + if "violation_threshold" in data: + thresh = max(1, int(data["violation_threshold"])) + penalty_cfg["violation_threshold"] = thresh + applied.append(f"violation_threshold={thresh}") + + if "violation_decay_seconds" in data: + decay = max(60, int(data["violation_decay_seconds"])) + penalty_cfg["violation_decay_seconds"] = decay + applied.append(f"violation_decay={decay}s") + + if "base_penalty_seconds" in data: + base = max(60, int(data["base_penalty_seconds"])) + penalty_cfg["base_penalty_seconds"] = base + applied.append(f"base_penalty={base}s") + + if "penalty_multiplier" in data: + mult = max(1.0, float(data["penalty_multiplier"])) + penalty_cfg["penalty_multiplier"] = mult + applied.append(f"penalty_multiplier={mult}") + + if "max_penalty_seconds" in data: + max_pen = max(60, int(data["max_penalty_seconds"])) + penalty_cfg["max_penalty_seconds"] = max_pen + applied.append(f"max_penalty={max_pen}s") + + # Adaptive settings + if "adaptive_enabled" in data: + adaptive_cfg["enabled"] = bool(data["adaptive_enabled"]) + applied.append(f"adaptive={'enabled' if adaptive_cfg['enabled'] else 'disabled'}") + + if "ewma_alpha" in data: + alpha = max(0.01, min(1.0, float(data["ewma_alpha"]))) + adaptive_cfg["ewma_alpha"] = alpha + applied.append(f"ewma_alpha={alpha}") + + if "hysteresis_seconds" in data: + hyst = max(0, int(data["hysteresis_seconds"])) + adaptive_cfg["hysteresis_seconds"] = hyst + applied.append(f"hysteresis={hyst}s") + + # Adaptive thresholds + if "thresholds" not in adaptive_cfg: + adaptive_cfg["thresholds"] = {} + + if "quiet_max" in data: + adaptive_cfg["thresholds"]["quiet_max"] = float(data["quiet_max"]) + applied.append(f"quiet_max={data['quiet_max']}") + + if "normal_max" in data: + adaptive_cfg["thresholds"]["normal_max"] = float(data["normal_max"]) + applied.append(f"normal_max={data['normal_max']}") + + if "busy_max" in data: + adaptive_cfg["thresholds"]["busy_max"] = float(data["busy_max"]) + applied.append(f"busy_max={data['busy_max']}") + + 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=['repeater'] + ) + + logger.info(f"Advert rate limit config updated: {', '.join(applied)}") + + return self._success({ + "applied": applied, + "persisted": result.get("saved", False), + "live_update": result.get("live_updated", False), + "restart_required": False, + "message": "Advert rate limit settings applied immediately." + }) + + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"Error updating advert rate limit config: {e}") + return self._error(str(e)) + + @cherrypy.expose + @cherrypy.tools.json_out() + 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" + exists = os.path.isdir(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) + + 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.", + } + ) + else: + return self._error(result.get("error", "Failed to update web configuration")) + + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"Error updating web config: {e}") + return self._error(str(e)) + + @cherrypy.expose + @cherrypy.tools.json_out() + def mqtt_status(self): + """Get MQTT connection status and configuration.""" + self._set_cors_headers() + try: + # mqtt_cfg = self.config.get("mqtt_brokers", {}) + + # Walk the chain to the mqtt_handler + handler = None + try: + storage = self._get_storage() + handler = getattr(storage, "mqtt_handler", None) + except Exception: + pass + + connected_brokers = [] + if handler: + for conn in getattr(handler, "connections", []): + connected_brokers.append({ + "enabled": conn.enabled, + "name": conn.broker.get("name", ""), + "host": conn.broker.get("host", ""), + "status": { + "connected": conn.is_connected(), + "reconnecting": conn.has_pending_reconnect(), + }, + "format": conn.format + }) + + return self._success({ + "handler_active": handler is not None, + "brokers": connected_brokers, + }) + except Exception as e: + logger.error(f"Error getting MQTT status: {e}") + return self._error(str(e)) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def update_mqtt_config(self): + """Update MQTT Observer configuration. + + POST /api/update_mqtt_config + Body: { + "iata_code": "SFO", + "status_interval": 300, + "owner": "Callsign", + "email": "user@example.com", + "brokers": [ + { + + }] + } + """ + self._set_cors_headers() + + if cherrypy.request.method == "OPTIONS": + return "" + + try: + self._require_post() + data = cherrypy.request.json or {} + + if not data: + return self._error("No configuration updates provided") + + mqtt_updates = {} + + if "iata_code" in data: + mqtt_updates["iata_code"] = str(data["iata_code"]).strip() + if "status_interval" in data: + mqtt_updates["status_interval"] = max(60, int(data["status_interval"])) + if "owner" in data: + mqtt_updates["owner"] = str(data["owner"]).strip() + if "email" in data: + mqtt_updates["email"] = str(data["email"]).strip() + # if "disallowed_packet_types" in data: + # mqtt_updates["disallowed_packet_types"] = list(data["disallowed_packet_types"]) + if "brokers" in data: + brokers = data["brokers"] + if not isinstance(brokers, list): + return self._error("brokers must be a list") + validated = [] + for i, b in enumerate(brokers): + if not isinstance(b, dict): + return self._error(f"Broker at index {i} must be an object") + for field in ("name", "host", "port", "format"): + if not b.get(field, ""): + return self._error(f"Broker at index {i} missing required field: {field}") + + try: + port = int(b.get("port", 443)) + except (ValueError, TypeError): + return self._error(f"Broker at index {i} has invalid port") + + new_broker = { + "name": str(b["name"]).strip(), + "enabled": b.get("enabled", False), + "transport": str(b.get("transport", "websockets")).strip(), + "host": str(b["host"]).strip(), + "port": port, + "format": str(b["format"]).strip(), + "disallowed_packet_types": list(b.get("disallowed_packet_types", [])), + "retain_status": bool(b.get("retain_status", False)), + "tls": { + "enabled": bool(b.get("tls", {}).get("enabled", True if port == 443 else False)), + "insecure": bool(b.get("tls", {}).get("insecure", False)), + } + } + + if b.get("use_jwt_auth", False): + new_broker["use_jwt_auth"] = True + new_broker["audience"] = str(b["audience"]).strip() + else: + new_broker["use_jwt_auth"] = False + new_broker["username"] = b.get("username", None) + new_broker["password"] = b.get("password", None) + + validated.append(new_broker) + + mqtt_updates["brokers"] = validated + + if not mqtt_updates: + return self._error("No valid settings provided") + + result = self.config_manager.update_and_save( + updates={"mqtt_brokers": mqtt_updates, "mqtt": None, "letsmesh": None}, + live_update=False, # Restart required for MQTT handler changes + ) + + if result.get("success"): + logger.info(f"MQTT config updated: {list(mqtt_updates.keys())}") + return self._success({ + "persisted": result.get("saved", False), + "restart_required": True, + "message": "Observer settings saved. Restart the service for changes to take effect.", + }) + else: + return self._error(result.get("error", "Failed to update LetsMesh configuration")) + + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"Error updating LetsMesh config: {e}") + return self._error(str(e)) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def restart_service(self): + """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: + logger.error(f"Error in restart_service endpoint: {e}", exc_info=True) + return self._error(e) + @cherrypy.expose @cherrypy.tools.json_out() def logs(self): from .http_server import _log_buffer + try: logs = list(_log_buffer.logs) return { @@ -262,6 +1217,104 @@ class APIEndpoints: logger.error(f"Error getting hardware stats: {e}") return self._error(e) + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def memory_debug(self, **kwargs): + """Memory diagnostics endpoint. + + GET — returns current status + data if tracing is active. + POST {"action": "start"} — starts tracemalloc and captures baseline. + POST {"action": "stop"} — stops tracemalloc and clears data. + """ + import tracemalloc + + self._set_cors_headers() + if cherrypy.request.method == "OPTIONS": + return "" + + # ---------- POST: start / stop ---------- + if cherrypy.request.method == "POST": + data = cherrypy.request.json or {} + action = data.get("action") + + if action == "start": + if not tracemalloc.is_tracing(): + # Use 1 frame instead of 10 — much less overhead & faster snapshots + tracemalloc.start(1) + self._tracemalloc_baseline = tracemalloc.take_snapshot().filter_traces(( + tracemalloc.Filter(False, tracemalloc.__file__), + tracemalloc.Filter(False, ""), + )) + logger.info("Memory tracing started") + return self._success({ + "tracing": True, + "message": "Tracing started — check again after some time to see growth", + }) + + if action == "stop": + if tracemalloc.is_tracing(): + tracemalloc.stop() + self._tracemalloc_baseline = None + logger.info("Memory tracing stopped") + return self._success({"tracing": False}) + + return self._error("Invalid action — use 'start' or 'stop'") + + # ---------- GET: status + data ---------- + tracing = tracemalloc.is_tracing() + result: dict = {"tracing": tracing} + + # Always include RSS regardless of tracing state + try: + import resource + rusage = resource.getrusage(resource.RUSAGE_SELF) + result["rss_mb"] = round(rusage.ru_maxrss / 1024, 1) + except Exception: + pass + + if not tracing: + return self._success(result) + + # Filter out tracemalloc's own allocations to keep snapshot small & fast + current = tracemalloc.take_snapshot().filter_traces(( + tracemalloc.Filter(False, tracemalloc.__file__), + tracemalloc.Filter(False, ""), + )) + baseline = getattr(self, "_tracemalloc_baseline", None) + + # Top 20 allocations right now + top_current = current.statistics("lineno")[:20] + current_stats = [] + for stat in top_current: + current_stats.append({ + "file": str(stat.traceback), + "size_kb": round(stat.size / 1024, 1), + "count": stat.count, + }) + result["current_top_20"] = current_stats + + # Growth since baseline + if baseline: + diff = current.compare_to(baseline, "lineno") + growth = [d for d in diff if d.size_diff > 0] + growth.sort(key=lambda d: d.size_diff, reverse=True) + growth_stats = [] + for stat in growth[:20]: + growth_stats.append({ + "file": str(stat.traceback), + "size_diff_kb": round(stat.size_diff / 1024, 1), + "count_diff": stat.count_diff, + "current_size_kb": round(stat.size / 1024, 1), + }) + result["growth_since_baseline"] = growth_stats + + traced_current, traced_peak = tracemalloc.get_traced_memory() + result["traced_current_mb"] = round(traced_current / (1024 * 1024), 2) + result["traced_peak_mb"] = round(traced_peak / (1024 * 1024), 2) + + return self._success(result) + @cherrypy.expose @cherrypy.tools.json_out() def hardware_processes(self): @@ -274,7 +1327,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: @@ -326,24 +1381,138 @@ class APIEndpoints: return self._error(e) @cherrypy.expose + @cherrypy.tools.gzip(compress_level=6) @cherrypy.tools.json_out() - def filtered_packets(self): + def bulk_packets(self, limit=1000, offset=0, start_timestamp=None, end_timestamp=None): + """ + Optimized bulk packet retrieval with gzip compression and DB-level pagination. + """ try: - params = self._get_params({ - 'type': None, - 'route': None, - 'start_timestamp': None, - 'end_timestamp': None, - 'limit': 1000 - }) - packets = self._get_storage().get_filtered_packets(**params) - return self._success(packets, count=len(packets), filters=params) + # 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() + packets = storage.get_filtered_packets( + packet_type=None, + route=None, + start_timestamp=float(start_timestamp) if start_timestamp else None, + end_timestamp=float(end_timestamp) if end_timestamp else None, + limit=limit, + offset=offset, + ) + + response = { + "success": True, + "data": packets, + "count": len(packets), + "offset": offset, + "limit": limit, + "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 + ): + # 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 + route_int = int(route) if route is not None else None + 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, + ) + 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: logger.error(f"Error getting filtered packets: {e}") return self._error(e) + @cherrypy.expose + @cherrypy.tools.json_out() + def airtime_data(self, start_timestamp=None, end_timestamp=None, limit=50000): + """Lightweight endpoint returning only columns needed for airtime charting.""" + try: + 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 = min(int(limit), 50000) + packets = self._get_storage().get_airtime_data( + start_timestamp=start_ts, end_timestamp=end_ts, limit=limit_int, + ) + return self._success(packets, count=len(packets)) + except Exception as e: + logger.error(f"Error getting airtime data: {e}") + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + def airtime_chart_data( + self, + start_timestamp=None, + end_timestamp=None, + bucket_seconds=60, + sf=9, + bw_hz=62500, + cr=5, + preamble=17, + ): + """Server-side aggregated airtime utilization for chart rendering. + + Returns pre-bucketed rx_ms/tx_ms per time bucket instead of raw packet rows, + reducing response size from potentially hundreds of KB to a few KB. + """ + try: + now = __import__("time").time() + start_ts = float(start_timestamp) if start_timestamp is not None else now - 86400 + end_ts = float(end_timestamp) if end_timestamp is not None else now + bucket_s = max(10, min(int(bucket_seconds), 3600)) + result = self._get_storage().get_airtime_buckets( + start_timestamp=start_ts, + end_timestamp=end_ts, + bucket_seconds=bucket_s, + sf=int(sf), + bw_hz=int(bw_hz), + cr=int(cr), + preamble=int(preamble), + ) + return self._success(result) + except Exception as e: + logger.error(f"Error getting airtime chart data: {e}") + return self._error(e) + @cherrypy.expose @cherrypy.tools.json_out() def packet_by_hash(self, packet_hash=None): @@ -371,11 +1540,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: @@ -386,33 +1553,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, @@ -420,11 +1594,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: @@ -433,59 +1607,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: @@ -493,10 +1677,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 {} @@ -512,11 +1696,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() @@ -527,43 +1711,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') - self._save_config_to_file(config_path) - - logger.info(f"Saved CAD settings to config: peak={peak}, min={min_val}, rate={detection_rate:.1f}%") + + config_path = getattr(self, "_config_path", "/etc/pymc_repeater/config.yaml") + saved = self.config_manager.save_to_file() + if not saved: + 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}%" + ) 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 @@ -572,93 +1764,343 @@ class APIEndpoints: logger.error(f"Error saving CAD settings: {e}") return self._error(e) - def _save_config_to_file(self, config_path): + @cherrypy.expose + @cherrypy.tools.json_out() + @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) + "frequency": 869618000, # Frequency in Hz (100-1000 MHz) + "bandwidth": 62500, # Bandwidth in Hz (valid: 7.8, 10.4, 15.6, 20.8, 31.25, 41.7, 62.5, 125, 250, 500 kHz) + "spreading_factor": 8, # Spreading factor (5-12) + "coding_rate": 8, # Coding rate (5-8 for 4/5 to 4/8) + "tx_delay_factor": 1.0, # TX delay factor (0.0-5.0) + "direct_tx_delay_factor": 0.5, # Direct TX delay (0.0-5.0) + "rx_delay_base": 0.0, # RX delay base (>= 0) + "node_name": "MyNode", # Node name + "latitude": 0.0, # Latitude (-90 to 90) + "longitude": 0.0, # Longitude (-180 to 180) + "max_flood_hops": 64, # Max flood hops (0-64) + "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: - import yaml - import os - os.makedirs(os.path.dirname(config_path), exist_ok=True) - with open(config_path, 'w') as f: - yaml.dump(self.config, f, default_flow_style=False, indent=2) - logger.info(f"Configuration saved to {config_path}") - except Exception as e: - logger.error(f"Failed to save config to {config_path}: {e}") + self._require_post() + data = cherrypy.request.json or {} + + applied = [] + + # Ensure config sections exist + if "radio" not in self.config: + self.config["radio"] = {} + if "delays" not in self.config: + self.config["delays"] = {} + if "repeater" not in self.config: + self.config["repeater"] = {} + if "mesh" not in self.config: + self.config["mesh"] = {} + + # Update TX power (up to 30 dBm for high-power radios) + if "tx_power" in data: + power = int(data["tx_power"]) + if power < 2 or power > 30: + 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"]) + if freq < 100_000_000 or freq > 1_000_000_000: + 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"])) + valid_bw = [7800, 10400, 15600, 20800, 31250, 41700, 62500, 125000, 250000, 500000] + if bw not in valid_bw: + 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"]) + if sf < 5 or sf > 12: + 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"]) + if cr < 5 or cr > 8: + 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"]) + if tdf < 0.0 or tdf > 5.0: + 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"]) + if dtdf < 0.0 or dtdf > 5.0: + 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"]) + if rxd < 0.0: + 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: + 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"]) + if lat < -90 or lat > 90: + 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"]) + if lon < -180 or lon > 180: + 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"]) + if hops < 0 or hops > 64: + 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"]) + if hours != 0 and (hours < 3 or hours > 48): + 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"]) + if mins != 0 and (mins < 1 or mins > 10080): + 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") + + # Update path hash mode (mesh: 0=1-byte, 1=2-byte, 2=3-byte) + if "path_hash_mode" in data: + phm = int(data["path_hash_mode"]) + if phm not in (0, 1, 2): + return self._error("Path hash mode must be 0 (1-byte), 1 (2-byte), or 2 (3-byte)") + self.config["mesh"]["path_hash_mode"] = phm + applied.append(f"path_hash_mode={phm}") + + # 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") + + # Update flood loop detection mode + if "loop_detect" in data: + mode = str(data["loop_detect"]).strip().lower() + if mode not in ("off", "minimal", "moderate", "strict"): + return self._error("loop_detect must be one of: off, minimal, moderate, strict") + if "mesh" not in self.config: + self.config["mesh"] = {} + self.config["mesh"]["loop_detect"] = mode + applied.append(f"loop_detect={mode}") + + if not applied: + return self._error("No valid settings provided") + + live_sections = ["repeater", "delays", "radio"] + if "mesh" in self.config and any(k in data for k in ("path_hash_mode", "loop_detect")): + live_sections.append("mesh") + 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, + ) + + 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": 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: + logger.error(f"Error updating radio config: {e}") + return self._error(str(e)) @cherrypy.expose @cherrypy.tools.json_out() - def noise_floor_history(self, hours: int = 24): - + def noise_floor_history(self, hours: int = 24, limit: int = None): + try: storage = self._get_storage() hours = int(hours) - history = storage.get_noise_floor_history(hours=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)}) + 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}) + 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}) + except Exception as e: + logger.error(f"Error fetching noise floor chart data: {e}") + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + def crc_error_count(self, hours: int = 24): + """Return total CRC errors within the given time window.""" + try: + storage = self._get_storage() + hours = int(hours) + count = storage.get_crc_error_count(hours=hours) + return self._success({ + "crc_error_count": count, + "hours": hours + }) + except Exception as e: + logger.error(f"Error fetching CRC error count: {e}") + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + def crc_error_history(self, hours: int = 24, limit: int = None): + """Return CRC error records within the given time window.""" + try: + storage = self._get_storage() + hours = int(hours) + limit = int(limit) if limit else None + history = storage.get_crc_error_history(hours=hours, limit=limit) 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 - }) - 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 - }) - except Exception as e: - logger.error(f"Error fetching noise floor chart data: {e}") + logger.error(f"Error fetching CRC error history: {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), @@ -666,13 +2108,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: @@ -682,54 +2124,72 @@ 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: logger.error(f"Error getting adverts by contact type: {e}") return self._error(e) + @cherrypy.expose + @cherrypy.tools.json_out() + def advert_rate_limit_stats(self): + """Get advert rate limiting statistics and adaptive tier info.""" + try: + if not self.daemon_instance or not hasattr(self.daemon_instance, 'advert_helper'): + return self._error("Advert helper not available") + + advert_helper = self.daemon_instance.advert_helper + if not advert_helper: + return self._error("Advert helper not initialized") + + if not hasattr(advert_helper, 'get_rate_limit_stats'): + return self._error("Rate limit stats not supported by this advert helper version") + + stats = advert_helper.get_rate_limit_stats() + return self._success(stats) + + except Exception as e: + logger.error(f"Error getting advert rate limit stats: {e}") + return self._error(e) + @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() def transport_keys(self): - + if cherrypy.request.method == "GET": try: storage = self._get_storage() @@ -738,7 +2198,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 {} @@ -747,30 +2207,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: @@ -781,7 +2246,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) @@ -796,35 +2261,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: @@ -832,15 +2301,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: @@ -852,52 +2323,57 @@ class APIEndpoints: @cherrypy.expose @cherrypy.tools.json_out() @cherrypy.tools.json_in() - def global_flood_policy(self): - + def unscoped_flood_policy(self): """ - Update global flood policy configuration - - POST /global_flood_policy - Body: {"global_flood_allow": true/false} + Update unscoped flood policy configuration + + POST /unscoped_flood_policy + Body: {"unscoped_flood_allow": true/false} """ if cherrypy.request.method == "POST": 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") - + unscoped_flood_allow = data.get("unscoped_flood_allow") + + if unscoped_flood_allow is None: + return self._error("Missing required field: unscoped_flood_allow") + + if not isinstance(unscoped_flood_allow, bool): + return self._error("unscoped_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 - + self.config["mesh"]["unscoped_flood_allow"] = unscoped_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 the same method as CAD + + logger.info(f"Using config path for unscoped flood policy: {config_path}") + + # Update the configuration file using ConfigManager try: - self._save_config_to_file(config_path) - logger.info(f"Updated running config and saved global flood policy to file: {'allow' if global_flood_allow else 'deny'}") + saved = self.config_manager.save_to_file() + if saved: + logger.info( + f"Updated running config and saved unscoped flood policy to file: {'allow' if unscoped_flood_allow else 'deny'}" + ) + else: + logger.error("Failed to save unscoped 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}") + logger.error(f"Failed to save unscoped 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)" + {"unscoped_flood_allow": unscoped_flood_allow}, + message=f"Unscoped flood policy updated to {'allow' if unscoped_flood_allow else 'deny'} (live and saved)", ) - + except Exception as e: - logger.error(f"Error updating global flood policy: {e}") + logger.error(f"Error updating unscoped flood policy: {e}") return self._error(e) else: return self._error("Method not supported") @@ -908,7 +2384,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": @@ -916,7 +2392,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: @@ -933,24 +2409,2631 @@ class APIEndpoints: @cherrypy.tools.json_out() @cherrypy.tools.json_in() def ping_neighbor(self): + # 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") - - # TODO: Implement actual ping functionality when available - # For now, return success to indicate the endpoint works - logger.info(f"Ping request for neighbor: {target_id}") - return self._success({"target_id": target_id}, message="Ping sent successfully") - + + # Derive byte width from path_hash_mode (issue #133): + # 0 = 1-byte (legacy), 1 = 2-byte, 2 = 3-byte + path_hash_mode = self.config.get("mesh", {}).get("path_hash_mode", 0) + byte_count = {0: 1, 1: 2, 2: 3}.get(path_hash_mode, 1) + hex_chars = byte_count * 2 + max_hash = (1 << (byte_count * 8)) - 1 + + # Parse target hash (accepts hex string like "0xA5", "0xA5F0", or bare hex) + try: + target_hash = int(target_id, 16) if isinstance(target_id, str) else int(target_id) + if target_hash < 0 or target_hash > max_hash: + return self._error( + f"target_id must be a valid {byte_count}-byte hash " + f"(0x00-0x{max_hash:0{hex_chars}X})" + ) + 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"): + return self._error("Packet router not available") + + router = self.daemon_instance.router + 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 + + path_bytes = list(target_hash.to_bytes(byte_count, "big")) + packet = PacketBuilder.create_trace( + tag=trace_tag, auth_code=0x12345678, flags=0x00, path=path_bytes + ) + + # 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:0{hex_chars}x} with tag {trace_tag} (path_hash_mode={path_hash_mode})") + + 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") + if result: + # Calculate round-trip time + rtt_ms = (result["received_at"] - ping_info["sent_at"]) * 1000 + + # Prefer structured hops from TraceHelper; else legacy flat list. + if result.get("trace_hops"): + grouped_path = [ + int.from_bytes(bytes(h), "big") for h in result["trace_hops"] + ] + else: + raw_path = result["path"] + if byte_count > 1: + grouped_path = [ + int.from_bytes(bytes(raw_path[i : i + byte_count]), "big") + for i in range(0, len(raw_path), byte_count) + ] + else: + grouped_path = raw_path + + return self._success( + { + "target_id": f"0x{target_hash:0{hex_chars}x}", + "rtt_ms": round(rtt_ms, 2), + "snr_db": result["snr"], + "rssi": result["rssi"], + "path": [f"0x{h:0{hex_chars}x}" for h in grouped_path], + "tag": trace_tag, + "path_hash_mode": path_hash_mode, + }, + 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: - logger.error(f"Error pinging neighbor: {e}") - return self._error(e) \ No newline at end of file + logger.error(f"Error pinging neighbor: {e}", exc_info=True) + 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"): + 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 [] + + companions_cfg = identities_config.get("companions") or [] + if heal_companion_empty_names(companions_cfg): + self.config.setdefault("identities", {})["companions"] = companions_cfg + if self.config_manager: + if self.config_manager.save_to_file(): + logger.info( + "Healed companion registration name(s): empty name -> companion_" + ) + else: + logger.warning("Failed to save config after healing companion name(s)") + + # Enhance with config data (room servers) + 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 + ) + + 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, + } + ) + + # 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") + raw_ik = comp_config.get("identity_key", "") + if isinstance(raw_ik, bytes): + ik_hex = raw_ik.hex() + else: + ik_hex = str(raw_ik) + settings = comp_config.get("settings", {}) + + matching = next( + ( + r + for r in registered_identities + if r["name"] == f"companion:{name}" + ), + None, + ) + + pk_display = None + if matching: + pk_display = matching.get("public_key") + else: + pk_display = derive_companion_public_key_hex(comp_config.get("identity_key")) + + configured_companions.append( + { + "name": name, + "type": "companion", + "identity_key": ( + ik_hex[:16] + "..." if len(ik_hex) > 16 else ik_hex + ), + "identity_key_length": len(ik_hex), + "settings": settings, + "hash": matching["hash"] if matching else None, + "public_key": pk_display, + "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), + } + ) + + 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): + """ + GET /api/identity?name= - Get a specific identity by name + """ + # 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 [] + companions = identities_config.get("companions") or [] + + # 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 (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) + + if runtime_info: + identity_obj, config, identity_type = runtime_info + identity_config["runtime"] = { + "hash": self._fmt_hash(identity_obj.get_public_key()), + "address": identity_obj.get_address_bytes().hex(), + "type": identity_type, + "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 + "type": "room_server", + "settings": { + "node_name": "My Room", + "latitude": 0.0, + "longitude": 0.0, + "disable_fwd": true, + "admin_password": "secret123", # Optional - admin access password + "guest_password": "guest456" # Optional - guest/read-only access password + } + } + """ + # 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 {} + + raw_name = data.get("name") + name = str(raw_name).strip() if raw_name is not None else "" + 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 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 + if not identity_key: + try: + # Generate a new random 32-byte key (same method as config.py) + random_key = os.urandom(32) + identity_key = random_key.hex() + key_was_generated = True + logger.info(f"Auto-generated identity key for '{name}': {identity_key[:16]}...") + 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}") + + identities_config = self.config.get("identities", {}) + if "identities" not in self.config: + self.config["identities"] = {} + + 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(str(c.get("name") or "").strip() == 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"), + } + if "tcp_timeout" in settings: + comp_settings["tcp_timeout"] = settings["tcp_timeout"] + 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(str(r.get("name") or "").strip() == 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 = self.config_manager.save_to_file() + if not saved: + 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 ''}" + ) + + # Hot reload - register identity immediately + registration_success = False + if identity_type == "room_server" and 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 + elif isinstance(identity_key, str): + try: + 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") + ) + 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"): + registration_success = self.daemon_instance._register_identity_everywhere( + name=name, + identity=room_identity, + config=new_identity, + identity_type=identity_type, + ) + if registration_success: + 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 + ) + + 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." + + 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 + "identity_key": "new_hex_key", # Optional - update key + "settings": { # Optional - update settings + "node_name": "Updated Room Name", + "latitude": 1.0, + "longitude": 2.0, + "admin_password": "newsecret", # Optional - admin password + "guest_password": "newguest" # Optional - guest password + } + } + """ + # 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" + raise cherrypy.HTTPError(405, "Method not allowed. This endpoint requires PUT.") + + data = cherrypy.request.json or {} + + name = data.get("name") + name_s = str(name).strip() if name is not None else "" + lookup_identity_key = data.get("lookup_identity_key") + public_key_prefix = data.get("public_key_prefix") + + 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." + ) + + identities_config = self.config.get("identities", {}) + + if identity_type == "companion": + companions = identities_config.get("companions") or [] + if name_s: + identity_index, err = find_companion_index(companions, name=name_s) + else: + identity_index, err = find_companion_index( + companions, + identity_key=lookup_identity_key, + public_key_prefix=public_key_prefix, + ) + if err: + return self._error(err) + identity = companions[identity_index] + resolved_name = str(identity.get("name") or "").strip() + + if "new_name" in data: + new_name = data["new_name"] + new_name = str(new_name).strip() if new_name is not None else "" + if not new_name: + return self._error("new_name cannot be empty") + if any( + str(c.get("name") or "").strip() == 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 '{resolved_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", "tcp_timeout"): + 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: {resolved_name}") + message = ( + f"Companion '{resolved_name}' updated successfully. " + "Restart required to apply changes." + ) + return self._success(identity, message=message) + + # Room server path + if not name_s: + return self._error("Missing required field: name") + + room_servers = identities_config.get("room_servers") or [] + identity_index = next( + ( + i + for i, r in enumerate(room_servers) + if str(r.get("name") or "").strip() == name_s + ), + None, + ) + + if identity_index is None: + return self._error(f"Identity '{name_s}' not found") + + # Update fields + identity = room_servers[identity_index] + + if "new_name" in data: + new_name = data["new_name"] + new_name = str(new_name).strip() if new_name is not None else "" + if not new_name: + return self._error("new_name cannot be empty") + # Check if new name conflicts + if any( + str(r.get("name") or "").strip() == 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"]: + new_key = data["identity_key"] + # Check if it's a truncated key (contains "...") or not a valid 64-char hex string + if "..." not in new_key and len(new_key) == 64: + try: + # Validate it's proper hex + bytes.fromhex(new_key) + identity["identity_key"] = new_key + logger.info(f"Updated identity_key for '{name_s}'") + 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 + + saved = self.config_manager.save_to_file() + if not saved: + return self._error("Failed to save configuration to file") + + logger.info(f"Updated identity: {name_s}") + + # 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 + + 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 + elif isinstance(identity_key, str): + 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") + ) + 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"): + registration_success = self.daemon_instance._register_identity_everywhere( + name=final_name, + identity=room_identity, + config=identity, + identity_type="room_server", + ) + if registration_success: + 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}'" + ) + + except Exception as reg_error: + logger.error( + f"Failed to hot reload identity {name_s}: {reg_error}", exc_info=True + ) + + if needs_reload: + message = ( + f"Identity '{name_s}' updated successfully and changes applied immediately!" + if registration_success + else f"Identity '{name_s}' updated successfully. Restart required to apply changes." + ) + else: + message = ( + f"Identity '{name_s}' 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, type=None, lookup_identity_key=None, public_key_prefix=None): + """ + DELETE /api/delete_identity?name=&type= - Delete an identity + Companions may also be deleted with lookup_identity_key or public_key_prefix when name is empty. + """ + # 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" + raise cherrypy.HTTPError(405, "Method not allowed. This endpoint requires DELETE.") + + name_s = str(name).strip() if name is not None else "" + + 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": + if not name_s and not lookup_identity_key and not public_key_prefix: + return self._error( + "Missing name parameter or lookup_identity_key or public_key_prefix" + ) + companions = identities_config.get("companions") or [] + if name_s: + idx, err = find_companion_index(companions, name=name_s) + else: + idx, err = find_companion_index( + companions, + identity_key=lookup_identity_key, + public_key_prefix=public_key_prefix, + ) + if err: + return self._error(err) + resolved_name = str(companions[idx].get("name") or "").strip() + companions.pop(idx) + 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: {resolved_name}") + unregister_success = False + if self.daemon_instance and hasattr(self.daemon_instance, "identity_manager"): + identity_manager = self.daemon_instance.identity_manager + if resolved_name and resolved_name in identity_manager.named_identities: + del identity_manager.named_identities[resolved_name] + logger.info(f"Removed companion {resolved_name} from named_identities") + unregister_success = True + message = ( + f"Companion '{resolved_name}' deleted successfully and deactivated immediately!" + if unregister_success + else ( + f"Companion '{resolved_name}' deleted successfully. " + "Restart required to fully remove." + ) + ) + return self._success({"name": resolved_name}, message=message) + + # Room server path + if not name_s: + return self._error("Missing name parameter") + + 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 str(r.get("name") or "").strip() != name_s + ] + + if len(room_servers) == initial_count: + return self._error(f"Identity '{name_s}' not found") + + # Update config + self.config["identities"]["room_servers"] = room_servers + + saved = self.config_manager.save_to_file() + if not saved: + return self._error("Failed to save configuration to file") + + logger.info(f"Deleted identity: {name_s}") + + unregister_success = False + if self.daemon_instance: + try: + if hasattr(self.daemon_instance, "identity_manager"): + identity_manager = self.daemon_instance.identity_manager + + # Remove from named_identities dict + if name_s in identity_manager.named_identities: + del identity_manager.named_identities[name_s] + logger.info(f"Removed identity {name_s} 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_s}: {unreg_error}", exc_info=True + ) + + message = ( + f"Identity '{name_s}' deleted successfully and deactivated immediately!" + if unregister_success + else f"Identity '{name_s}' deleted successfully. Restart required to fully remove." + ) + + return self._success({"name": name_s}, 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"): + 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, + ), + 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}'", + ) + 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 + ): + """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, + ) + + 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, + lat=latitude, + lon=longitude, + feature1=0, + feature2=0, + 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})" + ) + 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 + - Number of authenticated clients + - Password configuration status + - Read-only access setting + """ + # 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"): + 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": self._fmt_hash(self.daemon_instance.local_identity.get_public_key()), + "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": self._fmt_hash(identity.get_public_key()), + "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, + } + ) + + # Add companion identities (no login/ACL fields; use registered + active for status) + companion_bridges = getattr(self.daemon_instance, "companion_bridges", {}) + # Build hash -> active TCP connection and client IP (frame server has at most one client) + active_by_hash = {} + client_ip_by_hash = {} + for fs in getattr(self.daemon_instance, "companion_frame_servers", []): + try: + ch = getattr(fs, "companion_hash", None) + h = ( + int(ch, 16) + if isinstance(ch, str) and ch.startswith("0x") + else (int(ch) if ch is not None else None) + ) + if h is not None: + writer = getattr(fs, "_client_writer", None) + active_by_hash[h] = writer is not None + if writer is not None: + peername = writer.get_extra_info("peername") if hasattr(writer, "get_extra_info") else None + client_ip_by_hash[h] = str(peername[0]) if peername else None + except (ValueError, TypeError): + pass + for name, identity, config in identity_manager.get_identities_by_type("companion"): + hash_byte = identity.get_public_key()[0] + active = active_by_hash.get(hash_byte, False) + entry = { + "name": name, + "type": "companion", + "hash": f"0x{hash_byte:02X}", + "registered": hash_byte in companion_bridges, + "active": active, + } + if active: + entry["client_ip"] = client_ip_by_hash.get(hash_byte) + else: + entry["client_ip"] = None + acl_info_list.append(entry) + + return self._success( + { + "acls": acl_info_list, + "total_identities": len(acl_info_list), + "total_authenticated_clients": sum( + a.get("authenticated_clients", 0) 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 + - Permissions (admin/guest) + - Last activity timestamp + - Last login timestamp + - Identity they're authenticated to + """ + # 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"): + 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": self._fmt_hash(self.daemon_instance.local_identity.get_public_key()), + } + + # 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": self._fmt_hash(identity.get_public_key()), + } + + # Add companions + for name, identity, config in identity_manager.get_identities_by_type("companion"): + hash_byte = identity.get_public_key()[0] + identity_map[hash_byte] = { + "name": name, + "type": "companion", + "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) + ) + except ValueError: + return self._error(f"Invalid identity_hash format: {identity_hash}") + elif identity_name: + # Find hash by name + for hash_byte, info in identity_map.items(): + if info["name"] == identity_name: + target_hash = hash_byte + 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}"} + ) + + all_clients = acl.get_all_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"], + } + ) + 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 + ), + } + ) + + 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 + } + """ + # 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"): + 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_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)", + ) + 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 + - Breakdown by identity type + - Admin vs guest counts + """ + # 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"): + 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}, + "companion": {"count": 0, "clients": 0}, + } + + # Count repeater + 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: + identity_stats["repeater"]["count"] = 1 + 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) + if acl: + 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 + + # Count companions (no admin/guest; they use frame server, not OTA login) + companions = identity_manager.get_identities_by_type("companion") + identity_stats["companion"]["count"] = len(companions) + + for name, identity, config in companions: + hash_byte = identity.get_public_key()[0] + acl = acl_dict.get(hash_byte) + if acl: + clients = acl.get_all_clients() + identity_stats["companion"]["clients"] += len(clients) + total_clients += len(clients) + + 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) + + # ====================== + # Room Server Endpoints + # ====================== + + 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"): + raise Exception("Text helper not available") + + text_helper = self.daemon_instance.text_helper + 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") + for name, identity, config in identities: + if name == room_name: + hash_byte = identity.get_public_key()[0] + 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, + } + raise Exception(f"Room '{room_name}' not found") + + # Find by hash + if room_hash: + if isinstance(room_hash, str): + 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 + identities = identity_manager.get_identities_by_type("room_server") + 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, + } + # Found server but no name match + return { + "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 + ): + """ + 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, + "data": { + "room_name": "General", + "room_hash": "0x42", + "messages": [ + { + "id": 1, + "author_pubkey": "abc123...", + "author_prefix": "abc1", + "post_timestamp": 1234567890.0, + "sender_timestamp": 1234567890, + "message_text": "Hello world", + "txt_type": 0, + "created_at": 1234567890.0 + } + ], + "count": 1, + "total": 100, + "limit": 50, + "offset": 0 + } + } + """ + # 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"] + + # 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), + ) + else: + messages = db.get_room_messages( + 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"] + 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"]), + } + + # 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_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), + } + ) + + except Exception as e: + logger.error(f"Error getting room messages: {e}", exc_info=True) + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def room_post_message(self): + """ + Post a message to a room server. + + POST Body: + { + "room_name": "General", // or "room_hash": "0x42" + "message": "Hello world", + "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) + + 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"): + # 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"] + # Use the room server's actual public key + author_bytes = room_server.local_identity.get_public_key() + author_pubkey = author_bytes.hex() + is_server_message = True + elif isinstance(author_pubkey, str): + author_bytes = bytes.fromhex(author_pubkey) + is_server_message = False + else: + author_bytes = bytes(author_pubkey) + 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", + ): + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) + 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 + # API is allowed to send these (TODO: Add authentication/authorization) + future = asyncio.run_coroutine_threadsafe( + room_server.add_post( + client_pubkey=author_bytes, + message_text=message, + sender_timestamp=sender_timestamp, + txt_type=txt_type, + allow_server_author=is_server_message, # Allow server key from API + ), + 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" + ), + } + ) + 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: + logger.error(f"Error posting room message: {e}", exc_info=True) + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + 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, + "data": { + "room_name": "General", + "room_hash": "0x42", + "total_messages": 100, + "total_clients": 5, + "active_clients": 3, + "max_posts": 32, + "sync_running": true, + "clients": [ + { + "pubkey": "abc123...", + "pubkey_prefix": "abc1", + "sync_since": 1234567890.0, + "unsynced_count": 2, + "pending_ack": false, + "push_failures": 0, + "last_activity": 1234567890.0 + } + ] + } + } + """ + # 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"): + 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 = [] + for hash_byte, room_server in text_helper.room_servers.items(): + # Find room name + room_name_found = f"Room_0x{hash_byte:02X}" + identities = text_helper.identity_manager.get_identities_by_type("room_server") + for name, identity, config in identities: + 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)}) + + # Get specific room stats + room_info = self._get_room_server_by_name_or_hash(room_name, room_hash) + 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"]] + + # Format client info + clients_info = [] + active_count = 0 + for client_sync in all_clients_sync: + 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), + ) + + 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, + } + ) + + except Exception as e: + logger.error(f"Error getting room stats: {e}", exc_info=True) + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + 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, + "data": { + "room_name": "General", + "room_hash": "0x42", + "clients": [...] + } + } + """ + # 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"], + } + ) + else: + return stats + except Exception as e: + logger.error(f"Error getting room clients: {e}") + return self._error(e) + + @cherrypy.expose + @cherrypy.tools.json_out() + 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"] + 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"]} + ) + 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) + + @cherrypy.expose + @cherrypy.tools.json_out() + 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"] + 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, + } + ) + + except Exception as e: + logger.error(f"Error clearing room messages: {e}") + return self._error(e) + + # ====================== + # CLI Command Endpoint + # ====================== + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + @require_auth + def cli(self): + """Execute a CLI command on the running repeater. + POST /api/cli {"command": "get name"} + Returns {"success": true, "reply": "..."} + """ + self._set_cors_headers() + if cherrypy.request.method == "OPTIONS": + return "" + try: + self._require_post() + data = cherrypy.request.json + command = data.get("command", "").strip() + if not command: + return self._error("Missing 'command' field") + + if not self.daemon_instance or not hasattr(self.daemon_instance, "text_helper"): + return self._error("Repeater not initialized") + text_helper = self.daemon_instance.text_helper + if not text_helper or not hasattr(text_helper, "cli") or not text_helper.cli: + return self._error("CLI handler not available") + + reply = text_helper.cli.handle_command( + sender_pubkey=b"api-cli", + command=command, + is_admin=True, + ) + return self._success({"reply": reply}) + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"CLI endpoint error: {e}", exc_info=True) + return self._error(str(e)) + + # ====================== + # Backup & Restore + # ====================== + + @cherrypy.expose + @cherrypy.tools.json_out() + def config_export(self, include_secrets=None): + """Export the full configuration as JSON. + + GET /api/config_export + GET /api/config_export?include_secrets=true (full backup with secrets) + + By default, sensitive fields (passwords, JWT secrets, identity keys) + are redacted. Pass ?include_secrets=true for a full backup that + includes all secrets — required for restoring to a new device. + + Returns: {"success": true, "data": {"meta": {...}, "config": {...}}} + """ + self._set_cors_headers() + if cherrypy.request.method == "OPTIONS": + return "" + try: + import copy + + full_backup = str(include_secrets).lower() in ("true", "1", "yes") + exported = copy.deepcopy(self.config) + + if full_backup: + # Convert binary identity key to hex for JSON serialisation + rep = exported.get("repeater", {}) + if "identity_key" in rep and isinstance(rep["identity_key"], bytes): + rep["identity_key"] = rep["identity_key"].hex() + + # Convert identity keys in companion / room_server configs + for section in ("room_servers", "companions"): + entries = exported.get("identities", {}).get(section, []) or [] + for entry in entries: + if isinstance(entry.get("identity_key"), bytes): + entry["identity_key"] = entry["identity_key"].hex() + else: + # Redact sensitive fields + sec = exported.get("repeater", {}).get("security", {}) + for field in ("admin_password", "guest_password", "jwt_secret"): + if field in sec: + sec[field] = "*** REDACTED ***" + + # Redact repeater identity key + rep = exported.get("repeater", {}) + if "identity_key" in rep: + del rep["identity_key"] + + # Redact identity keys in companion / room_server configs + for section in ("room_servers", "companions"): + entries = exported.get("identities", {}).get(section, []) or [] + for entry in entries: + if "identity_key" in entry: + entry["identity_key"] = "*** REDACTED ***" + + # Ensure all bytes values are converted to hex for JSON serialisation + def _sanitize(obj): + if isinstance(obj, bytes): + return obj.hex() + if isinstance(obj, dict): + return {k: _sanitize(v) for k, v in obj.items()} + if isinstance(obj, list): + return [_sanitize(v) for v in obj] + return obj + + exported = _sanitize(exported) + + meta = { + "exported_at": datetime.utcnow().isoformat() + "Z", + "version": __version__, + "config_path": self._config_path, + "includes_secrets": full_backup, + } + + return {"success": True, "data": {"meta": meta, "config": exported}} + + except Exception as e: + logger.error(f"Config export error: {e}", exc_info=True) + return self._error(str(e)) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def config_import(self): + """Import a configuration JSON and apply it. + + POST /api/config_import + Body: {"config": { ... }, "restart_after": false} + + The imported config is merged section-by-section into the current config. + Sections present in the import will overwrite current values. + Redacted sentinel values ("*** REDACTED ***") are skipped so that + existing passwords / keys are preserved. + + If the import contains a non-redacted identity_key (from a full backup), + it will be restored. Redacted or missing identity keys are left unchanged. + + Returns: {"success": true, "message": "...", "restart_required": true, + "sections_updated": [...]} + """ + self._set_cors_headers() + if cherrypy.request.method == "OPTIONS": + return "" + try: + self._require_post() + data = cherrypy.request.json + imported_config = data.get("config") + + if not imported_config or not isinstance(imported_config, dict): + return self._error("Missing or invalid 'config' object in request body") + + # Sections we allow to be imported + ALLOWED_SECTIONS = { + "repeater", "mesh", "radio", "identities", "delays", + "ch341", "web", "letsmesh", "glass", "logging", "radio_type", + } + + updated_sections = [] + restart_required = False + + for section, value in imported_config.items(): + if section not in ALLOWED_SECTIONS: + logger.info(f"Config import: skipping unknown section '{section}'") + continue + + if section == "repeater" and isinstance(value, dict): + # Preserve security secrets that are redacted + sec = value.get("security", {}) + if isinstance(sec, dict): + cur_sec = self.config.get("repeater", {}).get("security", {}) + for field in ("admin_password", "guest_password", "jwt_secret"): + if sec.get(field) == "*** REDACTED ***": + sec[field] = cur_sec.get(field, "") + # Restore identity_key only if a real (non-redacted) hex value is provided + ik = value.get("identity_key") + if ik and isinstance(ik, str) and ik != "*** REDACTED ***": + try: + value["identity_key"] = bytes.fromhex(ik) + except ValueError: + logger.warning("Config import: invalid identity_key hex, skipping") + value.pop("identity_key", None) + else: + value.pop("identity_key", None) + value.pop("identity_file", None) + + if section == "identities" and isinstance(value, dict): + # Preserve identity keys that are redacted + for id_section in ("room_servers", "companions"): + entries = value.get(id_section, []) or [] + cur_entries = ( + self.config.get("identities", {}).get(id_section, []) or [] + ) + cur_by_name = {e.get("name"): e for e in cur_entries} + for entry in entries: + if entry.get("identity_key") == "*** REDACTED ***": + existing = cur_by_name.get(entry.get("name"), {}) + entry["identity_key"] = existing.get("identity_key", "") + + if section == "radio": + restart_required = True + + if section == "radio_type": + # radio_type is a top-level scalar, not a dict + self.config[section] = value + else: + if section not in self.config: + self.config[section] = {} + if isinstance(value, dict) and isinstance(self.config[section], dict): + self.config[section].update(value) + else: + self.config[section] = value + + updated_sections.append(section) + + if not updated_sections: + return self._error("No valid configuration sections found in import") + + # Persist and live-reload + result = self.config_manager.update_and_save( + updates={}, # Already applied above + live_update=True, + live_update_sections=updated_sections, + ) + + # Save to file (update_and_save with empty updates may not save) + saved = self.config_manager.save_to_file() + + return { + "success": True, + "message": f"Imported {len(updated_sections)} config section(s)", + "sections_updated": updated_sections, + "saved": saved, + "restart_required": restart_required, + } + + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"Config import error: {e}", exc_info=True) + return self._error(str(e)) + + @cherrypy.expose + @cherrypy.tools.json_out() + def identity_export(self): + """Export the repeater's identity key as a hex string. + + GET /api/identity_export + + WARNING: This transmits the private key over the network. + Only use on trusted networks. + + Returns: {"success": true, "data": {"identity_key_hex": "abcdef...", + "key_length_bytes": 32, "public_key_hex": "...", + "node_address": "0x42"}} + """ + self._set_cors_headers() + if cherrypy.request.method == "OPTIONS": + return "" + try: + identity_key = self.config.get("repeater", {}).get("identity_key") + if not identity_key: + return self._error("No identity key configured") + + # Convert to hex + if isinstance(identity_key, bytes): + key_hex = identity_key.hex() + elif isinstance(identity_key, str): + key_hex = identity_key + else: + return self._error(f"Identity key has unexpected type: {type(identity_key).__name__}") + + result = { + "identity_key_hex": key_hex, + "key_length_bytes": len(bytes.fromhex(key_hex)), + } + + # Try to derive public key info + try: + if self.daemon_instance and hasattr(self.daemon_instance, "local_identity"): + li = self.daemon_instance.local_identity + pub = li.get_public_key() + result["public_key_hex"] = bytes(pub).hex() + result["node_address"] = f"0x{pub[0]:02x}" + except Exception: + pass # Not critical + + return {"success": True, "data": result} + + except Exception as e: + logger.error(f"Identity export error: {e}", exc_info=True) + return self._error(str(e)) + + # ====================== + # Vanity Key Generation + # ====================== + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def generate_vanity_key(self): + """Generate a MeshCore Ed25519 key whose public key starts with a hex prefix. + + POST /api/generate_vanity_key + Body: {"prefix": "F8A1", "apply": false} + + prefix: 1-4 hex characters (required) + apply: if true, save the generated key as the repeater identity key + + Returns: {"success": true, "data": {"public_hex": "...", "private_hex": "...", + "attempts": 1234}} + """ + self._set_cors_headers() + if cherrypy.request.method == "OPTIONS": + return "" + try: + self._require_post() + data = cherrypy.request.json + + prefix = (data.get("prefix") or "").strip().upper() + if not prefix or len(prefix) > 8: + return self._error("Prefix must be 1-8 hex characters") + try: + int(prefix, 16) + except ValueError: + return self._error("Prefix must be valid hexadecimal characters") + + apply_key = bool(data.get("apply", False)) + + from repeater.keygen import generate_vanity_key as _gen + + # Max iterations scales with prefix length: ~16^n * 20 safety margin + max_iter = min(20_000_000, max(500_000, (16 ** len(prefix)) * 20)) + result = _gen(prefix, max_iterations=max_iter) + + if result is None: + return self._error( + f"Could not find a key with prefix '{prefix}' within {max_iter:,} attempts. " + "Try a shorter prefix." + ) + + if apply_key: + # Save as the repeater identity key + self.config.setdefault("repeater", {})["identity_key"] = bytes.fromhex( + result["private_hex"] + ) + self.config_manager.save_to_file() + result["applied"] = True + logger.info( + f"Applied new vanity identity key (prefix={prefix}, " + f"pub={result['public_hex'][:16]}...)" + ) + + return {"success": True, "data": result} + + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"Vanity key generation error: {e}", exc_info=True) + return self._error(str(e)) + + # ====================== + # Database Management + # ====================== + + @cherrypy.expose + @cherrypy.tools.json_out() + def db_stats(self): + """Get database table statistics. + + GET /api/db_stats + + Returns row counts, date ranges, and total database size. + """ + self._set_cors_headers() + if cherrypy.request.method == "OPTIONS": + return "" + try: + storage = self._get_storage() + stats = storage.sqlite_handler.get_table_stats() + + # Add RRD file size if it exists + rrd_path = storage.sqlite_handler.storage_dir / "metrics.rrd" + stats["rrd_size_bytes"] = ( + rrd_path.stat().st_size if rrd_path.exists() else 0 + ) + + return {"success": True, "data": stats} + except Exception as e: + logger.error(f"DB stats error: {e}", exc_info=True) + return self._error(str(e)) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def db_purge(self): + """Purge (empty) one or more database tables. + + POST /api/db_purge + Body: {"tables": ["packets", "adverts"]} + or {"tables": "all"} to purge all data tables + + Returns per-table row counts deleted. + """ + self._set_cors_headers() + if cherrypy.request.method == "OPTIONS": + return "" + try: + self._require_post() + data = cherrypy.request.json + tables_param = data.get("tables") + + if not tables_param: + return self._error("Missing 'tables' parameter") + + ALL_PURGEABLE = [ + "packets", "adverts", "noise_floor", "crc_errors", + "room_messages", "room_client_sync", + "companion_contacts", "companion_channels", + "companion_messages", "companion_prefs", + ] + + if tables_param == "all": + tables = ALL_PURGEABLE + elif isinstance(tables_param, list): + tables = tables_param + else: + return self._error("'tables' must be a list of table names or 'all'") + + storage = self._get_storage() + results = {} + for table in tables: + try: + deleted = storage.sqlite_handler.purge_table(table) + results[table] = {"deleted": deleted} + except ValueError as ve: + results[table] = {"error": str(ve)} + + return { + "success": True, + "data": results, + "message": f"Purged {len([r for r in results.values() if 'deleted' in r])} table(s)", + } + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"DB purge error: {e}", exc_info=True) + return self._error(str(e)) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in() + def db_vacuum(self): + """Reclaim disk space after purging tables. + + POST /api/db_vacuum + + Runs SQLite VACUUM to compact the database file. + """ + self._set_cors_headers() + if cherrypy.request.method == "OPTIONS": + return "" + try: + self._require_post() + storage = self._get_storage() + size_before = storage.sqlite_handler.sqlite_path.stat().st_size + storage.sqlite_handler.vacuum() + size_after = storage.sqlite_handler.sqlite_path.stat().st_size + return { + "success": True, + "data": { + "size_before": size_before, + "size_after": size_after, + "freed_bytes": size_before - size_after, + }, + } + except cherrypy.HTTPError: + raise + except Exception as e: + logger.error(f"DB vacuum error: {e}", exc_info=True) + return self._error(str(e)) + + # ====================== + # OpenAPI Documentation + # ====================== + + @cherrypy.expose + def openapi(self): + """Serve OpenAPI specification in YAML format.""" + import os + + spec_path = os.path.join(os.path.dirname(__file__), "openapi.yaml") + try: + 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") + 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") + + @cherrypy.expose + def docs(self): + """Serve Swagger UI for interactive API documentation.""" + html = """ + + + + + pyMC Repeater API Documentation + + + + +
+ + + + +""" + 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 new file mode 100644 index 0000000..2e0695e --- /dev/null +++ b/repeater/web/auth/__init__.py @@ -0,0 +1,5 @@ +from .api_tokens import APITokenManager +from .jwt_handler import JWTHandler +from .middleware import require_auth + +__all__ = ["JWTHandler", "APITokenManager", "require_auth"] diff --git a/repeater/web/auth/api_tokens.py b/repeater/web/auth/api_tokens.py new file mode 100644 index 0000000..5105e70 --- /dev/null +++ b/repeater/web/auth/api_tokens.py @@ -0,0 +1,44 @@ +import hashlib +import hmac +import logging +import secrets +from typing import Dict, List, Optional + +logger = logging.getLogger(__name__) + + +class APITokenManager: + def __init__(self, sqlite_handler, secret_key: str): + + self.db = sqlite_handler + 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() + + def create_token(self, name: str) -> tuple[int, str]: + plaintext_token = self.generate_api_token() + token_hash = self.hash_token(plaintext_token) + + token_id = self.db.create_api_token(name, token_hash) + + logger.info(f"Created API token '{name}' with ID {token_id}") + return token_id, plaintext_token + + def verify_token(self, token: str) -> Optional[Dict]: + token_hash = self.hash_token(token) + return self.db.verify_api_token(token_hash) + + def revoke_token(self, token_id: int) -> bool: + deleted = self.db.revoke_api_token(token_id) + + if deleted: + 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 new file mode 100644 index 0000000..f107dc5 --- /dev/null +++ b/repeater/web/auth/cherrypy_tool.py @@ -0,0 +1,84 @@ +import logging + +import cherrypy + +logger = logging.getLogger("HTTPServer") + + +def check_auth(): + """ + CherryPy tool to check authentication before processing request. + + Checks for either JWT in Authorization header, API token in X-API-Key header, + or JWT token in query parameter (for EventSource/SSE connections). + Sets cherrypy.request.user on success. + Returns 401 JSON response on failure. + """ + # Skip auth check for OPTIONS requests (CORS preflight) + if cherrypy.request.method == "OPTIONS": + return + + # Skip auth check for /auth/login endpoint + if cherrypy.request.path_info == "/auth/login": + return + + # Get auth handlers from config + 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 initialized in cherrypy.config") + cherrypy.response.status = 500 + return {"success": False, "error": "Authentication system not configured"} + + # Check for JWT token in Authorization header first + 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: + cherrypy.request.user = { + "username": payload.get("sub"), + "client_id": payload.get("client_id"), + "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") + if query_token: + payload = jwt_handler.verify_jwt(query_token) + + if payload: + cherrypy.request.user = { + "username": payload.get("sub"), + "client_id": payload.get("client_id"), + "auth_type": "jwt_query", + } + # Remove token from params to avoid exposing it in logs + del cherrypy.request.params["token"] + return + + # Check for API token in X-API-Key header + api_key = cherrypy.request.headers.get("X-API-Key", "") + if api_key: + token_info = token_manager.verify_token(api_key) + + if token_info: + cherrypy.request.user = { + "token_id": token_info["id"], + "token_name": token_info["name"], + "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) +logger.info("CherryPy require_auth tool registered") diff --git a/repeater/web/auth/jwt_handler.py b/repeater/web/auth/jwt_handler.py new file mode 100644 index 0000000..bc9d257 --- /dev/null +++ b/repeater/web/auth/jwt_handler.py @@ -0,0 +1,35 @@ +import logging +import time +from typing import Dict, Optional + +import jwt + +logger = logging.getLogger(__name__) + + +class JWTHandler: + def __init__(self, secret: str, expiry_minutes: int = 15): + self.secret = secret + self.expiry_minutes = expiry_minutes + + def create_jwt(self, username: str, client_id: str) -> str: + + 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") + 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"]) + return payload + except jwt.ExpiredSignatureError: + logger.warning("JWT token expired") + return None + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid JWT token: {e}") + return None diff --git a/repeater/web/auth/middleware.py b/repeater/web/auth/middleware.py new file mode 100644 index 0000000..54ecc9b --- /dev/null +++ b/repeater/web/auth/middleware.py @@ -0,0 +1,66 @@ +import logging +from functools import wraps + +import cherrypy + +logger = logging.getLogger(__name__) + + +def require_auth(func): + + @wraps(func) + def wrapper(*args, **kwargs): + # Skip authentication for OPTIONS requests (CORS preflight) + 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") + + 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 "): + 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", + } + 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", "") + 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", + } + 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 diff --git a/repeater/web/auth_endpoints.py b/repeater/web/auth_endpoints.py new file mode 100644 index 0000000..2ceb572 --- /dev/null +++ b/repeater/web/auth_endpoints.py @@ -0,0 +1,463 @@ +""" +Authentication endpoints for login and token management +""" +import cherrypy +import logging +from .auth.middleware import require_auth + +logger = logging.getLogger(__name__) + + +class AuthAPIEndpoints: + """Nested endpoint for /api/auth/* RESTful routes""" + + def __init__(self): + # Create tokens nested endpoint for /api/auth/tokens + self.tokens = TokensAPIEndpoint() + + +class TokensAPIEndpoint: + """RESTful token management endpoints for /api/auth/tokens""" + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def index(self): + # Handle CORS preflight + if cherrypy.request.method == 'OPTIONS': + return {} + + # Get token manager from cherrypy config + 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': + try: + tokens = token_manager.list_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': + try: + import json + body = cherrypy.request.body.read().decode('utf-8') + data = json.loads(body) if body else {} + name = data.get('name', '').strip() + + if not name: + cherrypy.response.status = 400 + 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']}") + + return { + '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' + } + 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': + return {} + + # Get token manager from cherrypy config + 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': + try: + if not token_id: + cherrypy.response.status = 400 + 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' + } + + # 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' + } + else: + cherrypy.response.status = 404 + 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' + } + else: + raise cherrypy.HTTPError(405, "Method not allowed") + + +class AuthEndpoints: + + def __init__(self, config, jwt_handler, token_manager, config_manager=None): + self.config = config + self.jwt_handler = jwt_handler + self.token_manager = token_manager + self.config_manager = config_manager + + @cherrypy.expose + def login(self, **kwargs): + + 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': + 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') + data = json.loads(body) if body else {} + + 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') + + # 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', '') + + # 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: + # 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') + 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') + + except Exception as e: + logger.error(f"Login error: {e}") + 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': + raise cherrypy.HTTPError(405, "Method not allowed") + + return { + 'success': True, + 'authenticated': True, + 'user': cherrypy.request.user + } + + @cherrypy.expose + def refresh(self, **kwargs): + + 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': + 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') + + user_info = None + + # Check JWT first + 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' + } + + # 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' + } + + if not user_info: + 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') + data = json.loads(body) if body else {} + + 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') + + # 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') + + except Exception as e: + logger.error(f"Token refresh error: {e}") + 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' + + # 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': + 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') + + 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', '') + user = None + + 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' + } + + # Try API token authentication if JWT failed + if not user: + 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' + } + + if not user: + cherrypy.response.status = 401 + 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') + data = json.loads(body) if body else {} + + 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') + + # 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') + + # Verify current 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') + + if current_password != config_password: + cherrypy.response.status = 401 + 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 + + # Save to config file using ConfigManager + if self.config_manager: + 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') + else: + cherrypy.response.status = 500 + 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') + + 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 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 new file mode 100644 index 0000000..7a5688d --- /dev/null +++ b/repeater/web/companion_endpoints.py @@ -0,0 +1,724 @@ +""" +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 threading +import time +from typing import Optional + +import cherrypy + +from repeater.companion.utils import validate_companion_node_name + +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, config_manager=None): + self.daemon_instance = daemon_instance + self.event_loop = event_loop + self.config = config or {} + self.config_manager = config_manager + + http_cfg = self.config.get("http", {}) if isinstance(self.config, dict) else {} + self._sse_queue_maxsize = max(32, int(http_cfg.get("sse_queue_maxsize", 64))) + self._sse_keepalive_sec = max(5, int(http_cfg.get("sse_keepalive_sec", 15))) + + # 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: + msg = f"Companion 0x{companion_hash:02X} not found" # noqa: E231 + raise cherrypy.HTTPError(404, msg) + 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}") + + def _get_sqlite_handler(self): + """Return the repeater's sqlite_handler, or raise 503 if unavailable.""" + if not self.daemon_instance: + raise cherrypy.HTTPError(503, "Daemon not initialized") + if ( + not hasattr(self.daemon_instance, "repeater_handler") + or not self.daemon_instance.repeater_handler + ): + raise cherrypy.HTTPError(503, "Repeater handler not initialized") + storage = getattr(self.daemon_instance.repeater_handler, "storage", None) + if not storage: + raise cherrypy.HTTPError(503, "Storage not initialized") + sqlite_handler = getattr(storage, "sqlite_handler", None) + if not sqlite_handler: + raise cherrypy.HTTPError(503, "SQLite storage not available") + return sqlite_handler + + # ------------------------------------------------------------------ + # 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}", # 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 ----- + + @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, + } + ) + + @cherrypy.expose + @cherrypy.tools.json_out() + @require_auth + def import_repeater_contacts(self, **kwargs): + """POST /api/companion/import_repeater_contacts {companion_name, contact_types?, hours?, limit?} + + Import repeater adverts into this companion's contact store (one-time seed). + Optional: contact_types (list), hours (only adverts seen in last N hours), + limit (max contacts to import, capped by companion max_contacts). + Results are sorted by last_seen DESC. After import, contacts are hot-reloaded. + """ + self._require_post() + body = self._get_json_body() + companion_name = body.get("companion_name") + if not companion_name: + raise cherrypy.HTTPError(400, "companion_name required") + contact_types = body.get("contact_types") + if contact_types is not None: + if not isinstance(contact_types, list): + raise cherrypy.HTTPError(400, "contact_types must be a list") + allowed = {"companion", "repeater", "room_server", "sensor"} + for t in contact_types: + if not isinstance(t, str) or t not in allowed: + raise cherrypy.HTTPError( + 400, + f"contact_types must contain only: companion, repeater, room_server, sensor (got {t!r})", + ) + if not contact_types: + contact_types = None + hours = body.get("hours") + if hours is not None: + try: + hours = int(hours) + except (TypeError, ValueError): + raise cherrypy.HTTPError(400, "hours must be a positive integer") + if hours < 1: + raise cherrypy.HTTPError(400, "hours must be a positive integer") + limit = body.get("limit") + if limit is not None: + try: + limit = int(limit) + except (TypeError, ValueError): + raise cherrypy.HTTPError(400, "limit must be a positive integer") + if limit < 1: + raise cherrypy.HTTPError(400, "limit must be a positive integer") + bridge = self._get_bridge(**self._resolve_bridge_params(body)) + if limit is not None: + max_contacts = getattr(bridge, "max_contacts", 1000) + limit = min(limit, max_contacts) + companion_hash = getattr(bridge, "_companion_hash", None) + if not companion_hash: + raise cherrypy.HTTPError(503, "Companion hash not available") + sqlite_handler = self._get_sqlite_handler() + count = sqlite_handler.companion_import_repeater_contacts( + companion_hash, + contact_types=contact_types, + hours=hours, + limit=limit, + ) + contact_rows = sqlite_handler.companion_load_contacts(companion_hash) + 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) + return self._success({"imported": count}) + + # ----- 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. + + 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", 20.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") + try: + validated_name = validate_companion_node_name(name) + except ValueError as e: + raise cherrypy.HTTPError(400, str(e)) from e + bridge.set_advert_name(validated_name) + # Optionally sync node_name to config.yaml so it survives restart + companion_name = body.get("companion_name") + if companion_name is None and getattr(self.daemon_instance, "identity_manager", None): + pubkey = bridge.get_public_key() + for reg_name, identity, _ in self.daemon_instance.identity_manager.get_identities_by_type( + "companion" + ): + if identity.get_public_key() == pubkey: + companion_name = reg_name + break + if companion_name and self.config_manager: + companions = (self.config.get("identities") or {}).get("companions") or [] + for entry in companions: + if entry.get("name") == companion_name: + if "settings" not in entry: + entry["settings"] = {} + entry["settings"]["node_name"] = validated_name + try: + if not self.config_manager.save_to_file(): + logger.warning("Failed to save config after set_advert_name") + except Exception as e: + logger.warning("Error saving config after set_advert_name: %s", e) + break + 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=self._sse_queue_maxsize) + with self._sse_lock: + self._sse_clients.append(client_queue) + + def generate(): + try: + payload = {"event": "connected", "timestamp": int(time.time())} + yield f"data: {json.dumps(payload)}\n\n" + + while True: + try: + item = client_queue.get(timeout=float(self._sse_keepalive_sec)) + yield f"data: {json.dumps(item)}\n\n" + except queue.Empty: + # Keep-alive comment frame keeps EventSource connected + # without allocating additional JSON payload objects. + yield ": keepalive\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/repeater/web/companion_ws_proxy.py b/repeater/web/companion_ws_proxy.py new file mode 100644 index 0000000..d023fc9 --- /dev/null +++ b/repeater/web/companion_ws_proxy.py @@ -0,0 +1,230 @@ +""" +WebSocket proxy for the companion frame protocol. + +Bridges browser WebSocket to the companion TCP frame server. +Raw byte pipe — no parsing, all protocol logic lives in the client. +""" + +import logging +import socket +import threading +from urllib.parse import parse_qs + +import cherrypy +from ws4py.websocket import WebSocket + +logger = logging.getLogger("CompanionWSProxy") + +# Set by http_server.py before CherryPy starts +_daemon = None + + +def set_daemon(instance): + global _daemon + _daemon = instance + + +class CompanionFrameWebSocket(WebSocket): + + def opened(self): + """Authenticate, resolve companion, open TCP socket, start reader.""" + # JWT auth — same pattern as PacketWebSocket + jwt_handler = cherrypy.config.get("jwt_handler") + + qs = "" + if hasattr(self, "environ"): + qs = self.environ.get("QUERY_STRING", "") + + params = parse_qs(qs) + token = params.get("token", [None])[0] + companion_name = params.get("companion_name", [None])[0] + + if not jwt_handler: + logger.warning("Connection rejected: no JWT handler configured") + self.close(code=1011, reason="server configuration error") + return + + if not token: + logger.warning("Connection rejected: missing token") + self.close(code=1008, reason="unauthorized") + return + + try: + payload = jwt_handler.verify_jwt(token) + if not payload: + logger.warning("Connection rejected: invalid token") + self.close(code=1008, reason="unauthorized") + return + except Exception as e: + logger.warning(f"Auth error: {e}") + self.close(code=1008, reason="unauthorized") + return + + if not companion_name: + logger.warning("Connection rejected: missing companion_name") + self.close(code=1008, reason="missing companion_name") + return + + # Resolve companion TCP port + bind address from config + resolved = self._resolve_tcp_endpoint(companion_name) + if resolved is None: + logger.warning(f"Connection rejected: companion '{companion_name}' not found") + self.close(code=1008, reason="companion not found") + return + + tcp_host, tcp_port = resolved + + # Open TCP socket to the companion frame server + try: + self._tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._tcp.settimeout(5.0) + self._tcp.connect((tcp_host, tcp_port)) + self._tcp.settimeout(None) + logger.debug(f"TCP connected to {tcp_host}:{tcp_port} for '{companion_name}'") + except Exception as e: + logger.error(f"TCP connect failed for '{companion_name}' {tcp_host}:{tcp_port}: {e}") + self._tcp = None + self.close(code=1011, reason="TCP connect failed") + return + + self._closing = False + self._companion_name = companion_name + self._reader = threading.Thread( + target=self._tcp_to_ws, daemon=True, name=f"ws-tcp-{companion_name}" + ) + self._reader.start() + + user = payload.get("sub", "unknown") + logger.info(f"Companion WS opened: user={user}, companion={companion_name}, tcp={tcp_host}:{tcp_port}") + + def received_message(self, message): + """WS → TCP""" + tcp = getattr(self, "_tcp", None) + if tcp is None or getattr(self, "_closing", True): + return + try: + data = message.data + if isinstance(data, str): + data = data.encode("latin-1") + tcp.sendall(data) + except Exception as e: + name = getattr(self, "_companion_name", "?") + logger.warning(f"WS→TCP send failed for '{name}': {e}") + self._teardown() + + def closed(self, code, reason=None): + name = getattr(self, "_companion_name", "?") + logger.info(f"Companion WS closed: companion={name}, code={code}, reason={reason}") + self._teardown() + + # ── internal ───────────────────────────────────────────────────────── + + def _resolve_tcp_endpoint(self, companion_name): + """Look up companion TCP host + port from daemon config. + + Returns ``(host, port)`` tuple or ``None`` if the companion can't be + resolved. When ``bind_address`` is ``0.0.0.0`` (all interfaces) we + connect via ``127.0.0.1``; otherwise we use the configured address. + """ + if not _daemon: + logger.warning("_resolve_tcp_endpoint: daemon not set") + return None + + identity_manager = getattr(_daemon, "identity_manager", None) + bridges = getattr(_daemon, "companion_bridges", {}) + + if not identity_manager: + logger.warning("_resolve_tcp_endpoint: no identity_manager") + return None + if not bridges: + logger.warning("_resolve_tcp_endpoint: no companion_bridges (dict empty or missing)") + return None + + # Find the companion identity by name and verify its bridge is running + found = False + for name, identity, _cfg in identity_manager.get_identities_by_type("companion"): + if name == companion_name: + h = identity.get_public_key()[0] + if h in bridges: + found = True + else: + logger.warning( + f"_resolve_tcp_endpoint: companion '{companion_name}' identity found " + f"(hash=0x{h:02x}) but no bridge registered for that hash. " + f"Known bridge hashes: {[f'0x{k:02x}' for k in bridges.keys()]}" + ) + break + else: + # Loop completed without finding the name + known = [n for n, _, _ in identity_manager.get_identities_by_type("companion")] + logger.warning( + f"_resolve_tcp_endpoint: companion '{companion_name}' not in identity_manager. " + f"Known companions: {known}" + ) + + if not found: + return None + + # Look up TCP port + bind address from config + companions = _daemon.config.get("identities", {}).get("companions") or [] + for entry in companions: + if entry.get("name") == companion_name: + settings = entry.get("settings") or {} + port = settings.get("tcp_port", 5000) + bind = settings.get("bind_address", "0.0.0.0") + # 0.0.0.0 = all interfaces — connect via loopback + host = "127.0.0.1" if bind == "0.0.0.0" else bind + logger.debug(f"_resolve_tcp_endpoint: '{companion_name}' → {host}:{port}") + return (host, port) + + logger.warning( + f"_resolve_tcp_endpoint: '{companion_name}' found in identity_manager but missing from config" + ) + return None + + def _tcp_to_ws(self): + """TCP → WS reader loop""" + name = getattr(self, "_companion_name", "?") + tcp = getattr(self, "_tcp", None) + if tcp is None: + return + try: + while not getattr(self, "_closing", True): + data = tcp.recv(4096) + if not data: + logger.info(f"TCP→WS: frame server closed connection for '{name}'") + break + try: + self.send(data, binary=True) + except Exception as e: + logger.warning(f"TCP→WS: WS send failed for '{name}': {e}") + break + except OSError as e: + # Socket error (connection reset, etc.) — normal during teardown + if not getattr(self, "_closing", True): + logger.warning(f"TCP→WS: socket error for '{name}': {e}") + except Exception as e: + logger.warning(f"TCP→WS: unexpected error for '{name}': {e}") + finally: + self._teardown() + + def _teardown(self): + if getattr(self, "_closing", True): + return + self._closing = True + + name = getattr(self, "_companion_name", "?") + logger.debug(f"Tearing down WS proxy for '{name}'") + + tcp = getattr(self, "_tcp", None) + if tcp: + try: + tcp.close() + except Exception: + pass + self._tcp = None + + try: + self.close() + except Exception: + pass diff --git a/repeater/web/html/assets/CADCalibration-CK9zSc8M.js b/repeater/web/html/assets/CADCalibration-CK9zSc8M.js new file mode 100644 index 0000000..2c2af32 --- /dev/null +++ b/repeater/web/html/assets/CADCalibration-CK9zSc8M.js @@ -0,0 +1 @@ +import{r as e}from"./chunk-DECur_0Z.js";import{C as t,S as n,f as r,ft as i,g as a,l as o,o as s,p as c,pt as l,s as u,u as d,w as f,z as p}from"./runtime-core.esm-bundler-HnidnMFy.js";import{c as m,t as h}from"./api-CbM6k1ZB.js";import{t as g}from"./system-BH4r-ii6.js";import{t as _}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{t as v}from"./plotly.min-Dl7ekyci.js";var y=e(v(),1),ee={class:`p-6 space-y-6`},b={class:`glass-card rounded-[15px] p-6`},te={class:`flex justify-center`},ne={class:`flex gap-4`},re=[`disabled`],ie=[`disabled`],ae={class:`glass-card rounded-[15px] p-6 space-y-4`},oe={class:`text-content-primary dark:text-content-primary`},se={key:0,class:`p-4 bg-primary/10 border border-primary/30 rounded-lg`},ce={class:`text-content-primary dark:text-primary`},le={class:`space-y-2`},ue={class:`w-full bg-white/10 rounded-full h-2`},de={class:`text-content-secondary dark:text-content-muted text-sm`},fe={class:`grid grid-cols-2 md:grid-cols-4 gap-4`},x={class:`glass-card rounded-[15px] p-4 text-center`},S={class:`text-2xl font-bold text-primary`},C={class:`glass-card rounded-[15px] p-4 text-center`},w={class:`text-2xl font-bold text-primary`},T={class:`glass-card rounded-[15px] p-4 text-center`},E={class:`text-2xl font-bold text-primary`},D={class:`glass-card rounded-[15px] p-4 text-center`},O={class:`text-2xl font-bold text-primary`},k={key:0,class:`glass-card rounded-[15px] p-6 space-y-4`},A={key:0,class:`p-4 bg-accent-green/10 border border-accent-green/30 rounded-lg`},j={class:`text-content-primary dark:text-content-primary mb-4`},M={key:1,class:`p-4 bg-secondary/20 border border-secondary/40 rounded-lg`},N=_(a({name:`CADCalibrationView`,__name:`CADCalibration`,setup(e){let a=g(),_=s(()=>document.documentElement.classList.contains(`dark`)),v=()=>{let e=_.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)`}},N=p(!1),P=p(null),F=p(null),I=p({}),L=p(null),R=p([]),z=p({}),B=p(`Ready to start calibration`),V=p(0),H=p(0),U=p(0),W=p(0),G=p(0),K=p(0),q=p(null),J=p(!1),Y=p(!1),X=p(!1),Z=p(!1),Q=null,pe={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 me(){let e=v(),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`}],n={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};y.default.newPlot(`plotly-chart`,t,n,pe)}function he(){if(Object.keys(I.value).length===0)return;let e=Object.values(I.value),t=[],n=[],r=[];for(let i of e)t.push(i.det_peak),n.push(i.det_min),r.push(i.detection_rate);let i={x:[t],y:[n],"marker.color":[r],hovertemplate:`Peak: %{x}
Min: %{y}
Detection Rate: %{marker.color:.1f}%
Status: Tested
`};y.default.restyle(`plotly-chart`,i,[0])}async function ge(){try{let e=await h.post(`/cad-calibration-start`,{samples:10,delay_ms:50});if(e.success)N.value=!0,P.value=Date.now(),a.setCadCalibrationRunning(!0),I.value={},R.value=[],z.value={},L.value=null,J.value=!1,Y.value=!1,X.value=!1,Z.value=!1,U.value=0,W.value=0,G.value=0,K.value=0,V.value=0,H.value=0,Q=setInterval(()=>{P.value&&(K.value=Math.floor((Date.now()-P.value)/1e3))},1e3),_e();else throw Error(e.error||`Failed to start calibration`)}catch(e){B.value=`Error: ${e instanceof Error?e.message:`Unknown error`}`}}async function $(){try{(await h.post(`/cad-calibration-stop`)).success&&(N.value=!1,a.setCadCalibrationRunning(!1),F.value&&=(F.value.close(),null),Q&&=(clearInterval(Q),null))}catch(e){console.error(`Failed to stop calibration:`,e)}}function _e(){F.value&&F.value.close();let e=m(),t=e?`?token=${encodeURIComponent(e)}`:``;F.value=new EventSource(`/api/cad-calibration-stream${t}`),F.value.onmessage=function(e){try{ve(JSON.parse(e.data))}catch(e){console.error(`Failed to parse SSE data:`,e)}},F.value.onerror=function(e){console.error(`SSE connection error:`,e),N.value||(F.value&&=(F.value.close(),null))}}function ve(e){switch(e.type){case`status`:B.value=e.message||`Status update`,e.test_ranges&&(q.value=e.test_ranges,J.value=!0);break;case`progress`:V.value=e.current||0,H.value=e.total||0,U.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){let t=`${e.det_peak}_${e.det_min}`;I.value[t]={det_peak:e.det_peak,det_min:e.det_min,detection_rate:e.detection_rate,detections:e.detections,samples:e.samples},he(),ye()}break;case`complete`:case`completed`:N.value=!1,B.value=e.message||`Calibration completed`,a.setCadCalibrationRunning(!1),be(),F.value&&=(F.value.close(),null),Q&&=(clearInterval(Q),null);break;case`error`:B.value=`Error: ${e.message}`,a.setCadCalibrationRunning(!1),$();break}}function ye(){let e=Object.values(I.value).map(e=>e.detection_rate);e.length!==0&&(W.value=Math.max(...e),G.value=e.reduce((e,t)=>e+t,0)/e.length)}function be(){Y.value=!0;let e=null,t=0;for(let n of Object.values(I.value))n.detection_rate>t&&(t=n.detection_rate,e=n);L.value=e,e&&t>0?(X.value=!0,Z.value=!1):(X.value=!1,Z.value=!0)}async function xe(){if(!L.value){B.value=`Error: No calibration results to save`;return}try{let e=await h.post(`/save_cad_settings`,{peak:L.value.det_peak,min_val:L.value.det_min,detection_rate:L.value.detection_rate});if(e.success)B.value=`Settings saved! Peak=${L.value.det_peak}, Min=${L.value.det_min} applied to configuration.`;else throw Error(e.error||`Failed to save settings`)}catch(e){B.value=`Error: Failed to save settings: ${e instanceof Error?e.message:`Unknown error`}`}}return n(()=>{me()}),t(()=>{F.value&&F.value.close(),Q&&clearInterval(Q),a.setCadCalibrationRunning(!1),document.getElementById(`plotly-chart`)&&y.default.purge(`plotly-chart`)}),(e,t)=>(f(),d(`div`,ee,[t[14]||=u(`div`,null,[u(`h1`,{class:`text-2xl font-bold text-content-primary dark:text-content-primary`},` CAD Calibration Tool `),u(`p`,{class:`text-content-secondary dark:text-content-muted mt-2`},` Channel Activity Detection calibration `)],-1),u(`div`,b,[u(`div`,te,[u(`div`,ne,[u(`button`,{onClick:ge,disabled:N.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]||=[r(`
Start Calibration
Begin testing
`,2)]],8,re),u(`button`,{onClick:$,disabled:!N.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]||=[r(`
Stop
Halt calibration
`,2)]],8,ie)])])]),u(`div`,ae,[u(`div`,oe,l(B.value),1),J.value&&q.value?(f(),d(`div`,se,[u(`div`,ce,[t[2]||=u(`strong`,null,`Configuration:`,-1),c(` SF`+l(q.value.spreading_factor)+` | Peak: `+l(q.value.peak_min)+` - `+l(q.value.peak_max)+` | Min: `+l(q.value.min_min)+` - `+l(q.value.min_max)+` | `+l((q.value.peak_max-q.value.peak_min+1)*(q.value.min_max-q.value.min_min+1))+` tests `,1)])])):o(``,!0),u(`div`,le,[u(`div`,ue,[u(`div`,{class:`bg-gradient-to-r from-primary to-accent-green h-2 rounded-full transition-all duration-300`,style:i({width:H.value>0?`${V.value/H.value*100}%`:`0%`})},null,4)]),u(`div`,de,l(V.value)+` / `+l(H.value)+` tests completed `,1)])]),u(`div`,fe,[u(`div`,x,[u(`div`,S,l(U.value),1),t[3]||=u(`div`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Tests Completed`,-1)]),u(`div`,C,[u(`div`,w,l(W.value.toFixed(1))+`%`,1),t[4]||=u(`div`,{class:`text-content-secondary dark:text-content-muted text-sm`},` Best Detection Rate `,-1)]),u(`div`,T,[u(`div`,E,l(G.value.toFixed(1))+`%`,1),t[5]||=u(`div`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Average Rate`,-1)]),u(`div`,D,[u(`div`,O,l(K.value)+`s`,1),t[6]||=u(`div`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Elapsed Time`,-1)])]),t[15]||=u(`div`,{class:`glass-card rounded-[15px] p-6`},[u(`div`,{id:`plotly-chart`,class:`w-full h-96`})],-1),Y.value?(f(),d(`div`,k,[t[13]||=u(`h3`,{class:`text-xl font-bold text-content-primary dark:text-content-primary`},` Calibration Results `,-1),X.value&&L.value?(f(),d(`div`,A,[t[11]||=u(`h4`,{class:`font-medium text-accent-green mb-2`},`Optimal Settings Found:`,-1),u(`p`,j,[t[7]||=c(` Peak: `,-1),u(`strong`,null,l(L.value.det_peak),1),t[8]||=c(`, Min: `,-1),u(`strong`,null,l(L.value.det_min),1),t[9]||=c(`, Rate: `,-1),u(`strong`,null,l(L.value.detection_rate.toFixed(1))+`%`,1)]),u(`div`,{class:`flex justify-center`},[u(`button`,{onClick:xe,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]||=[r(`
Save Settings
Apply to configuration
`,2)]])])])):o(``,!0),Z.value?(f(),d(`div`,M,[...t[12]||=[u(`h4`,{class:`font-medium text-secondary mb-2`},`No Optimal Settings Found`,-1),u(`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)]])):o(``,!0)])):o(``,!0)]))}}),[[`__scopeId`,`data-v-60d82848`]]);export{N as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/CADCalibration-gZQwotT3.css b/repeater/web/html/assets/CADCalibration-gZQwotT3.css new file mode 100644 index 0000000..2fe46cd --- /dev/null +++ b/repeater/web/html/assets/CADCalibration-gZQwotT3.css @@ -0,0 +1 @@ +.glass-card[data-v-60d82848]{background:var(--color-glass-bg);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-glass-border);box-shadow:var(--color-glass-shadow)} diff --git a/repeater/web/html/assets/Companions-Cm95T8nb.js b/repeater/web/html/assets/Companions-Cm95T8nb.js new file mode 100644 index 0000000..5f9553d --- /dev/null +++ b/repeater/web/html/assets/Companions-Cm95T8nb.js @@ -0,0 +1 @@ +import{E as e,K as t,S as n,b as r,dt as i,f as a,g as o,j as s,k as c,l,m as u,o as d,p as f,pt as p,r as m,s as h,u as g,w as _,z as v}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t as y}from"./api-CbM6k1ZB.js";import{f as b,h as x,l as S,u as C}from"./index-BFltqMtv.js";import{t as w}from"./ConfirmDialog-PLW-eI8u.js";import{t as T}from"./MessageDialog-CEzYMZ-3.js";var E={id:`import-modal-description`,class:`text-content-secondary dark:text-content-muted text-sm mb-4`},D={class:`mb-4`},O={class:`flex items-center gap-2 mb-2`},k={key:0,class:`text-content-muted dark:text-content-muted text-xs mb-2`},A={key:1,class:`flex flex-wrap gap-3 ml-6`},j=[`value`],M={class:`text-content-primary dark:text-content-primary text-sm capitalize`},N={class:`border-t border-stroke-subtle dark:border-white/10 pt-4 mt-4 mb-4`},P={class:`flex flex-wrap gap-3 mb-2`},F=[`value`],ee={class:`text-content-primary dark:text-content-primary text-sm`},te={class:`flex flex-wrap items-center gap-2 mt-2`},ne={class:`flex items-center gap-2`},I={key:1,class:`text-content-muted dark:text-content-muted text-sm`},L={class:`border-t border-stroke-subtle dark:border-white/10 pt-4 mt-4 mb-4`},R={class:`flex flex-wrap items-center gap-2`},z={key:0,role:`alert`,class:`mb-4 p-3 rounded-lg bg-accent-red/10 dark:bg-accent-red/20 border border-accent-red/30 text-accent-red text-sm`},B={key:1,class:`text-content-muted dark:text-content-muted text-sm mb-4`},V={class:`flex justify-end gap-3`},H=[`disabled`],U=[`disabled`],W=o({name:`ImportRepeaterContactsModal`,__name:`ImportRepeaterContactsModal`,props:{isOpen:{type:Boolean},companionName:{}},emits:[`close`,`imported`],setup(n,{emit:i}){let a=[`companion`,`repeater`,`room_server`,`sensor`],o=[{label:`All time`,value:null},{label:`Last 24 hours`,value:24},{label:`Last 7 days`,value:168},{label:`Last 30 days`,value:720},{label:`Custom`,value:`custom`}].slice(0,4),u=n,w=i,T=v(!1),W=v(null),G=v(!0),K=v([]),q=v(null),J=v(``),Y=v(``),X=v(null),Z=v(null);function Q(){let e=q.value;if(e===null||e===`custom`){if(e===`custom`){let e=J.value;if(e===``||e===null)return;let t=Number(e);return Number.isInteger(t)&&t>=1?t:void 0}return}return e}function $(){let e=Y.value;if(e===``||e===null)return;let t=Number(e);return Number.isInteger(t)&&t>=1?t:void 0}function re(){G.value=!0,K.value=[],q.value=null,J.value=``,Y.value=``,W.value=null}c(()=>u.isOpen,e=>{e&&(re(),r(()=>{Z.value?.focus()}))}),c(q,e=>{e===`custom`&&r(()=>{X.value?.focus()})});let ie=d(()=>{let e=G.value?`All types`:K.value.map(e=>e.replace(`_`,` `)).join(`, `),t,n=q.value;if(n===null)t=`all time`;else if(n===`custom`){let e=Q();t=e===void 0?`custom`:`last ${e} hours`}else t=n===24?`last 24 hours`:n===168?`last 7 days`:n===720?`last 30 days`:`all time`;let r=$(),i=r===void 0?`no limit`:`max ${r} contacts`;return`Import: ${e}, ${t}, ${i}.`});function ae(){if(q.value===`custom`){let e=Q();if(e===void 0||e<1)return`Custom recency must be at least 1 hour.`}let e=$();if(Y.value!==``&&(e===void 0||e<1))return`Limit must be at least 1.`;if(!G.value&&K.value.length===0)return`Select at least one contact type or use All types.`;if(!G.value){let e=K.value.filter(e=>!a.includes(e));if(e.length>0)return`Invalid contact type: ${e.join(`, `)}`}return null}async function oe(){W.value=null;let e=ae();if(e){W.value=e;return}let t={companion_name:u.companionName};!G.value&&K.value.length>0&&(t.contact_types=[...K.value]);let n=Q();n!==void 0&&(t.hours=n);let r=$();r!==void 0&&(t.limit=r),T.value=!0;try{let e=await y.importRepeaterContacts(t);e.success&&e.data?(w(`imported`,e.data.imported),w(`close`)):W.value=e.error||`Import failed.`}catch(e){W.value=e instanceof Error?e.message:`Import failed.`}finally{T.value=!1}}function se(e){e.target===e.currentTarget&&w(`close`)}function ce(e){e.key===`Escape`&&w(`close`)}return(r,i)=>n.isOpen?(_(),g(`div`,{key:0,class:`fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4`,onClick:se,onKeydown:ce},[h(`div`,{role:`dialog`,"aria-describedby":`import-modal-description`,class:`bg-white dark:bg-surface-elevated backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6 max-w-lg w-full max-h-[90vh] overflow-y-auto`,onClick:i[7]||=x(()=>{},[`stop`])},[i[18]||=h(`h2`,{class:`text-xl font-bold text-content-primary dark:text-content-primary mb-4`},` Import repeater contacts `,-1),h(`p`,E,[i[8]||=f(` Seed `,-1),h(`strong`,null,p(n.companionName),1),i[9]||=f(` with contacts from the repeater's adverts. Results are ordered by most recent first. `,-1)]),h(`div`,D,[i[11]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},` Contact types `,-1),h(`label`,O,[s(h(`input`,{ref_key:`firstFocusRef`,ref:Z,"onUpdate:modelValue":i[0]||=e=>G.value=e,type:`checkbox`,class:`rounded border-stroke-subtle dark:border-stroke/20 text-primary focus:ring-primary/50`},null,512),[[S,G.value]]),i[10]||=h(`span`,{class:`text-content-primary dark:text-content-primary text-sm`},`All types`,-1)]),G.value?(_(),g(`p`,k,` Uncheck to filter by type (repeater, companion, room server, sensor). `)):l(``,!0),G.value?l(``,!0):(_(),g(`div`,A,[(_(),g(m,null,e(a,e=>h(`label`,{key:e,class:`flex items-center gap-2`},[s(h(`input`,{"onUpdate:modelValue":i[1]||=e=>K.value=e,type:`checkbox`,value:e,class:`rounded border-stroke-subtle dark:border-stroke/20 text-primary focus:ring-primary/50`},null,8,j),[[S,K.value]]),h(`span`,M,p(e.replace(`_`,` `)),1)])),64))]))]),h(`div`,N,[i[13]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},` Recency `,-1),h(`div`,P,[(_(!0),g(m,null,e(t(o),e=>(_(),g(`label`,{key:e.label,class:`flex items-center gap-2`},[s(h(`input`,{"onUpdate:modelValue":i[2]||=e=>q.value=e,type:`radio`,value:e.value,class:`border-stroke-subtle dark:border-stroke/20 text-primary focus:ring-primary/50`},null,8,F),[[C,q.value]]),h(`span`,ee,p(e.label),1)]))),128))]),h(`div`,te,[h(`label`,ne,[s(h(`input`,{"onUpdate:modelValue":i[3]||=e=>q.value=e,type:`radio`,value:`custom`,class:`border-stroke-subtle dark:border-stroke/20 text-primary focus:ring-primary/50`},null,512),[[C,q.value]]),i[12]||=h(`span`,{class:`text-content-primary dark:text-content-primary text-sm`},`Custom:`,-1)]),q.value===`custom`?s((_(),g(`input`,{key:0,ref_key:`customHoursInputRef`,ref:X,"onUpdate:modelValue":i[4]||=e=>J.value=e,type:`number`,min:`1`,placeholder:`e.g. 48`,class:`w-24 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-3 py-1.5 text-content-primary dark:text-content-primary text-sm placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50`},null,512)),[[b,J.value,void 0,{number:!0}]]):l(``,!0),q.value===`custom`?(_(),g(`span`,I,`hours`)):l(``,!0)])]),h(`div`,L,[i[16]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},` Max contacts (optional) `,-1),h(`div`,R,[i[14]||=h(`span`,{class:`text-content-muted dark:text-content-muted text-sm`},`Import at most`,-1),s(h(`input`,{"onUpdate:modelValue":i[5]||=e=>Y.value=e,type:`number`,inputmode:`numeric`,min:`1`,placeholder:`No limit`,class:`w-32 bg-white dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg px-3 py-2 text-content-primary dark:text-content-primary placeholder-gray-500 dark:placeholder-white/40 focus:outline-none focus:border-primary/50`},null,512),[[b,Y.value,void 0,{number:!0}]]),i[15]||=h(`span`,{class:`text-content-muted dark:text-content-muted text-sm`},`contacts`,-1)]),i[17]||=h(`p`,{class:`text-content-muted dark:text-content-muted text-xs mt-1`},` Leave empty for no cap. Server caps at companion max. `,-1)]),W.value?(_(),g(`div`,z,p(W.value),1)):l(``,!0),W.value?l(``,!0):(_(),g(`p`,B,p(ie.value),1)),h(`div`,V,[h(`button`,{type:`button`,disabled:T.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 transition-colors disabled:opacity-50`,onClick:i[6]||=e=>w(`close`)},` Cancel `,8,H),h(`button`,{type:`button`,disabled:T.value,class:`px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors disabled:opacity-50`,onClick:oe},p(T.value?`Importing…`:`Import`),9,U)])])],32)):l(``,!0)}}),G={class:`p-6 space-y-6`},K={key:0,class:`grid grid-cols-1 md:grid-cols-2 gap-4`},q={class:`group relative overflow-hidden glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-5`},J={class:`relative flex items-center justify-between`},Y={class:`text-3xl font-bold text-content-primary dark:text-content-primary`},X={class:`glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6`},Z={key:0,class:`flex items-center justify-center py-12`},Q={key:1,class:`flex items-center justify-center py-12`},$={class:`text-center`},re={class:`text-content-secondary dark:text-content-muted text-sm mb-4`},ie={key:2,class:`space-y-4`},ae={class:`relative flex items-start justify-between`},oe={class:`flex-1`},se={class:`flex items-center gap-3 mb-4`},ce={class:`relative`},le={key:0,class:`absolute inset-0 bg-accent-green/50 rounded-full animate-ping`},ue={class:`text-xl font-bold text-content-primary dark:text-content-primary`},de={key:0,class:`text-content-muted dark:text-content-muted text-sm`},fe={class:`grid grid-cols-1 md:grid-cols-2 gap-3 text-sm mb-3`},pe={class:`text-content-primary dark:text-content-primary/90 ml-2`},me={class:`text-content-primary dark:text-content-primary/90 ml-2`},he={class:`text-content-primary dark:text-content-primary/90 ml-2`},ge={class:`flex items-center gap-2`},_e={key:0,class:`text-content-primary dark:text-content-primary/90 font-mono ml-2 text-xs`},ve={key:1,class:`text-content-muted dark:text-content-muted ml-2 text-xs`},ye=[`onClick`],be={class:`text-xs text-content-muted dark:text-content-muted`},xe={key:0,class:`ml-2 font-mono text-content-primary dark:text-content-primary/90 break-all`},Se={key:1,class:`ml-2 text-content-muted dark:text-content-muted`},Ce={class:`ml-4 flex flex-wrap gap-2`},we=[`onClick`],Te=[`onClick`],Ee=[`onClick`],De={key:3,class:`text-center py-12 text-content-secondary dark:text-content-muted`},Oe={key:1,class:`fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4`},ke={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`},Ae={class:`space-y-4`},je={class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},Me={key:0},Ne={key:1,class:`text-content-secondary dark:text-content-muted text-sm`},Pe={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`},Ie={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`},Le={class:`space-y-4`},Re=[`value`],ze={class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},Be={key:0},Ve={class:`grid grid-cols-2 gap-4`},He=5050,Ue=1,We=65535,Ge=o({name:`CompanionsView`,__name:`Companions`,setup(t){let r=v(!1),o=v(null),c=v(null),d=v(!1),x=v(!1),S=v(null),C=v(!1),E=v(!1),D=v(new Set),O=v(!1),k=v(``),A=v(!1),j=v(``),M=v(!1),N=v({message:``,variant:`success`}),P=v({name:``,identity_key:``,type:`companion`,settings:{node_name:``,tcp_port:5e3,bind_address:`0.0.0.0`}});n(async()=>{await F()});async function F(){r.value=!0,o.value=null;try{let e=await y.getIdentities();e.success?c.value=e.data:o.value=e.error||`Failed to load identities`}catch(e){o.value=e instanceof Error?e.message:`Failed to load identities`}finally{r.value=!1}}async function ee(){try{let e=await y.createIdentity({...P.value,settings:{node_name:P.value.settings.node_name||P.value.name,tcp_port:P.value.settings.tcp_port??5e3,bind_address:P.value.settings.bind_address||`0.0.0.0`}});e.success?(d.value=!1,z(),await F(),L(e.message||`Companion created successfully!`,`success`)):L(`Failed to create companion: ${e.error}`,`error`)}catch(e){L(`Error creating companion: ${e}`,`error`)}}async function te(){try{let e=await y.updateIdentity({name:S.value.name,new_name:S.value.new_name,identity_key:S.value.identity_key,type:`companion`,settings:{node_name:S.value.settings?.node_name,tcp_port:S.value.settings?.tcp_port,bind_address:S.value.settings?.bind_address}});e.success?(x.value=!1,S.value=null,await F(),L(e.message||`Companion updated successfully!`,`success`)):L(`Failed to update companion: ${e.error}`,`error`)}catch(e){L(`Error updating companion: ${e}`,`error`)}}function ne(e){k.value=e,O.value=!0}async function I(){let e=k.value;O.value=!1;try{let t=await y.deleteIdentity(e,`companion`);t.success?(await F(),L(t.message||`Companion deleted successfully!`,`success`)):L(`Failed to delete companion: ${t.error}`,`error`)}catch(e){L(`Error deleting companion: ${e}`,`error`)}finally{k.value=``}}function L(e,t){N.value={message:e,variant:t},M.value=!0}function R(e){S.value=JSON.parse(JSON.stringify(e)),S.value.settings||(S.value.settings={node_name:``,tcp_port:5e3,bind_address:`0.0.0.0`}),S.value.new_name=``,E.value=!1,x.value=!0}function z(){P.value={name:``,identity_key:``,type:`companion`,settings:{node_name:``,tcp_port:5e3,bind_address:`0.0.0.0`}},C.value=!1}function B(){d.value=!1,x.value=!1,S.value=null,C.value=!1,E.value=!1,z()}function V(e){D.value.has(e)?D.value.delete(e):D.value.add(e)}let H=()=>c.value?.configured_companions??[],U=()=>c.value?.total_configured_companions??0;function Ge(){let e=H();if(e.length===0)return He;let t=e.map(e=>e.settings?.tcp_port??5e3),n=Math.max(...t)+1;return Math.min(We,Math.max(Ue,n))}function Ke(){z(),P.value.settings.tcp_port=Ge(),d.value=!0}function qe(e){j.value=e,A.value=!0}function Je(){A.value=!1,j.value=``}function Ye(e){L(`Imported ${e} contact${e===1?``:`s`}.`,`success`),Je()}return(t,n)=>(_(),g(m,null,[h(`div`,G,[h(`div`,{class:`relative overflow-hidden rounded-[20px] p-6 mb-6 glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10`},[n[16]||=h(`div`,{class:`absolute inset-0 bg-gradient-to-br from-primary/20 via-secondary/10 to-accent-purple/20 opacity-50`},null,-1),n[17]||=h(`div`,{class:`absolute inset-0 bg-gradient-to-tl from-accent-green/10 via-transparent to-primary/10 animate-pulse`},null,-1),h(`div`,{class:`relative flex items-center justify-between`},[n[15]||=a(`

Companions

Manage companion identities (TCP frame server)

`,1),h(`button`,{onClick:Ke,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`},[...n[14]||=[h(`span`,{class:`flex items-center gap-2`},[h(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[h(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 4v16m8-8H4`})]),f(` Add Companion `)],-1)]])])]),c.value&&U()>0?(_(),g(`div`,K,[h(`div`,q,[h(`div`,J,[h(`div`,null,[n[18]||=h(`div`,{class:`text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide`},` Total Configured `,-1),h(`div`,Y,p(U()),1)])])])])):l(``,!0),h(`div`,X,[r.value?(_(),g(`div`,Z,[...n[19]||=[h(`div`,{class:`text-center`},[h(`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`}),h(`div`,{class:`text-content-secondary dark:text-content-primary/70`},` Loading companions... `)],-1)]])):o.value?(_(),g(`div`,Q,[h(`div`,$,[n[20]||=h(`div`,{class:`text-red-600 dark:text-red-400 mb-2`},`Failed to load companions`,-1),h(`div`,re,p(o.value),1),h(`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 `)])])):c.value&&H().length>0?(_(),g(`div`,ie,[(_(!0),g(m,null,e(H(),e=>(_(),g(`div`,{key:e.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`},[h(`div`,ae,[h(`div`,oe,[h(`div`,se,[h(`div`,ce,[e.registered?(_(),g(`div`,le)):l(``,!0),h(`div`,{class:i([`relative w-3 h-3 rounded-full`,e.registered?`bg-accent-green`:`bg-accent-red`])},null,2)]),h(`h3`,ue,p(e.name),1),h(`span`,{class:i([`px-3 py-1 text-xs font-semibold rounded-full`,e.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`])},p(e.registered?`● Active`:`○ Inactive`),3),e.hash?(_(),g(`span`,de,p(e.hash),1)):l(``,!0)]),h(`div`,fe,[h(`div`,null,[n[21]||=h(`span`,{class:`text-content-muted dark:text-content-muted`},`Node Name:`,-1),h(`span`,pe,p(e.settings?.node_name||e.name),1)]),h(`div`,null,[n[22]||=h(`span`,{class:`text-content-muted dark:text-content-muted`},`TCP Port:`,-1),h(`span`,me,p(e.settings?.tcp_port??5e3),1)]),h(`div`,null,[n[23]||=h(`span`,{class:`text-content-muted dark:text-content-muted`},`Bind Address:`,-1),h(`span`,he,p(e.settings?.bind_address||`0.0.0.0`),1)]),h(`div`,ge,[n[24]||=h(`span`,{class:`text-content-muted dark:text-content-muted`},`Identity Key:`,-1),D.value.has(e.name)?(_(),g(`span`,_e,p(e.identity_key),1)):(_(),g(`span`,ve,`••••••••••••••••`)),h(`button`,{onClick:t=>V(e.name),class:`text-primary/70 hover:text-primary text-xs underline`},p(D.value.has(e.name)?`Hide`:`Show`),9,ye)])]),h(`div`,be,[n[25]||=h(`span`,{class:`text-content-muted dark:text-content-muted`},`Public Key:`,-1),e.public_key?(_(),g(`span`,xe,p(e.public_key),1)):(_(),g(`span`,Se,`—`))])]),h(`div`,Ce,[h(`button`,{onClick:t=>qe(e.name),class:`px-3 py-1 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors`},` Import contacts `,8,we),h(`button`,{onClick:t=>R(e),class:`px-3 py-1 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors`},` Edit `,8,Te),h(`button`,{onClick:t=>ne(e.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,Ee)])])]))),128))])):(_(),g(`div`,De,[n[26]||=h(`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`},[h(`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),n[27]||=h(`p`,{class:`text-lg mb-2`},`No companions configured`,-1),n[28]||=h(`p`,{class:`text-sm mb-4`},` Add a companion to run a TCP frame server for firmware or other clients `,-1),h(`button`,{onClick:Ke,class:`px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors`},` + Add Companion `)]))]),d.value?(_(),g(`div`,Oe,[h(`div`,ke,[n[35]||=h(`h2`,{class:`text-xl font-bold text-content-primary dark:text-content-primary mb-4`},` Add Companion `,-1),h(`div`,Ae,[h(`div`,null,[n[29]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Name *`,-1),s(h(`input`,{"onUpdate:modelValue":n[0]||=e=>P.value.name=e,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),[[b,P.value.name]])]),h(`div`,null,[h(`label`,je,[n[30]||=f(` Identity Key (Optional) `,-1),h(`button`,{onClick:n[1]||=e=>C.value=!C.value,type:`button`,class:`ml-2 text-primary/70 hover:text-primary text-xs underline`},p(C.value?`Hide`:`Show/Edit`),1)]),C.value?(_(),g(`div`,Me,[s(h(`input`,{"onUpdate:modelValue":n[2]||=e=>P.value.identity_key=e,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),[[b,P.value.identity_key]]),n[31]||=h(`p`,{class:`text-content-secondary dark:text-content-muted text-xs mt-1`},` 32 or 64 bytes hex. Leave empty to auto-generate. `,-1)])):(_(),g(`div`,Ne,` Will be auto-generated if not provided `))]),h(`div`,null,[n[32]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Node Name`,-1),s(h(`input`,{"onUpdate:modelValue":n[3]||=e=>P.value.settings.node_name=e,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),[[b,P.value.settings.node_name]])]),h(`div`,Pe,[h(`div`,null,[n[33]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`TCP Port`,-1),s(h(`input`,{"onUpdate:modelValue":n[4]||=e=>P.value.settings.tcp_port=e,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),[[b,P.value.settings.tcp_port,void 0,{number:!0}]])]),h(`div`,null,[n[34]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Bind Address`,-1),s(h(`input`,{"onUpdate:modelValue":n[5]||=e=>P.value.settings.bind_address=e,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),[[b,P.value.settings.bind_address]])])])]),h(`div`,{class:`flex justify-end gap-3 mt-6`},[h(`button`,{onClick:B,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 `),h(`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 `)])])])):l(``,!0),x.value&&S.value?(_(),g(`div`,Fe,[h(`div`,Ie,[n[42]||=h(`h2`,{class:`text-xl font-bold text-content-primary dark:text-content-primary mb-4`},` Edit Companion `,-1),h(`div`,Le,[h(`div`,null,[n[36]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Current Name`,-1),h(`input`,{value:S.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,Re)]),h(`div`,null,[n[37]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`New Name (optional)`,-1),s(h(`input`,{"onUpdate:modelValue":n[6]||=e=>S.value.new_name=e,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,S.value.new_name]])]),h(`div`,null,[h(`label`,ze,[n[38]||=f(` Identity Key (Optional) `,-1),h(`button`,{onClick:n[7]||=e=>E.value=!E.value,type:`button`,class:`ml-2 text-primary/70 hover:text-primary text-xs underline`},p(E.value?`Hide`:`Show/Edit`),1)]),E.value?(_(),g(`div`,Be,[s(h(`input`,{"onUpdate:modelValue":n[8]||=e=>S.value.identity_key=e,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,S.value.identity_key]])])):l(``,!0)]),h(`div`,null,[n[39]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Node Name`,-1),s(h(`input`,{"onUpdate:modelValue":n[9]||=e=>S.value.settings.node_name=e,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,S.value.settings.node_name]])]),h(`div`,Ve,[h(`div`,null,[n[40]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`TCP Port`,-1),s(h(`input`,{"onUpdate:modelValue":n[10]||=e=>S.value.settings.tcp_port=e,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),[[b,S.value.settings.tcp_port,void 0,{number:!0}]])]),h(`div`,null,[n[41]||=h(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Bind Address`,-1),s(h(`input`,{"onUpdate:modelValue":n[11]||=e=>S.value.settings.bind_address=e,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,S.value.settings.bind_address]])])])]),h(`div`,{class:`flex justify-end gap-3 mt-6`},[h(`button`,{onClick:B,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 `),h(`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 `)])])])):l(``,!0)]),u(W,{"is-open":A.value,"companion-name":j.value,onClose:Je,onImported:Ye},null,8,[`is-open`,`companion-name`]),u(w,{show:O.value,title:`Delete Companion`,message:`Are you sure you want to delete '${k.value}'? Restart required to fully remove.`,"confirm-text":`Delete`,"cancel-text":`Cancel`,variant:`danger`,onClose:n[12]||=e=>O.value=!1,onConfirm:I},null,8,[`show`,`message`]),u(T,{show:M.value,message:N.value.message,variant:N.value.variant,onClose:n[13]||=e=>M.value=!1},null,8,[`show`,`message`,`variant`])],64))}});export{Ge as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Configuration-BoG9PyTQ.js b/repeater/web/html/assets/Configuration-BoG9PyTQ.js new file mode 100644 index 0000000..9cfa047 --- /dev/null +++ b/repeater/web/html/assets/Configuration-BoG9PyTQ.js @@ -0,0 +1,2 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/leaflet-src-PYB8oVmQ.js","assets/chunk-DECur_0Z.js"])))=>i.map(i=>d[i]); +import{r as e}from"./chunk-DECur_0Z.js";import{A as t,C as n,D as r,E as i,K as a,R as o,S as s,b as c,c as l,dt as u,f as d,g as f,i as p,j as m,k as h,l as g,m as _,o as v,p as y,pt as b,r as x,s as S,u as C,w,x as T,z as E}from"./runtime-core.esm-bundler-HnidnMFy.js";import{o as D}from"./vue-router-Cr0wB7EX.js";import{a as O,n as k,t as A}from"./api-CbM6k1ZB.js";import{t as j}from"./system-BH4r-ii6.js";import{t as M}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{c as N,d as P,f as F,h as I,l as L,m as R,p as z,u as B}from"./index-BFltqMtv.js";import{t as V}from"./ConfirmDialog-PLW-eI8u.js";/* empty css */import{n as ee,t as H}from"./preferences-Bv8i60GL.js";var U={class:`space-y-4`},W={key:0,class:`bg-green-100 dark:bg-green-500/20 border border-green-500/50 rounded-lg p-3`},G={class:`text-green-600 dark:text-green-400 text-sm`},te={key:1,class:`bg-red-100 dark:bg-red-500/20 border border-red-500/50 rounded-lg p-3`},K={class:`text-red-600 dark:text-red-400 text-sm`},ne={class:`flex justify-end gap-2`},q=[`disabled`],J=[`disabled`],Y={class:`bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3`},X={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`},Z={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Q={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`},$={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},ie={key:1},ae=[`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`},se={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},ce={key:1},le=[`value`],ue={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`},fe={key:1,class:`flex items-center gap-2`},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`},me={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},he={key:1},ge={class:`flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 gap-1`},_e={class:`text-content-primary dark:text-content-primary font-mono text-sm`},ve={key:2,class:`bg-yellow-500/10 dark:bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3`},ye=f({__name:`RadioSettings`,setup(e){let t=j(),n=v(()=>t.stats?.config?.radio||{}),r=E(!1),a=E(!1),o=E(null),s=E(null),c=E(0),l=E(0),u=E(0),d=E(0),f=E(0),p=E(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`}];h(n,e=>{e&&!r.value&&(c.value=e.frequency?Number((e.frequency/1e6).toFixed(3)):0,l.value=e.spreading_factor??0,u.value=e.bandwidth?Number((e.bandwidth/1e3).toFixed(1)):0,d.value=e.tx_power??0,f.value=e.coding_rate??0,p.value=e.preamble_length??0)},{immediate:!0});let T=v(()=>{let e=n.value.frequency;return e?(e/1e6).toFixed(3)+` MHz`:`Not set`}),D=v(()=>{let e=n.value.bandwidth;return e?(e/1e3).toFixed(1)+` kHz`:`Not set`}),O=v(()=>{let e=n.value.tx_power;return e===void 0?`Not set`:e+` dBm`}),k=v(()=>{let e=n.value.coding_rate;return e?`4/`+e:`Not set`}),M=v(()=>{let e=n.value.preamble_length;return e?e+` symbols`:`Not set`}),N=v(()=>n.value.spreading_factor??`Not set`),I=()=>{r.value=!0,o.value=null,s.value=null},L=()=>{r.value=!1,o.value=null;let e=n.value;c.value=e.frequency?Number((e.frequency/1e6).toFixed(3)):0,l.value=e.spreading_factor??0,u.value=e.bandwidth?Number((e.bandwidth/1e3).toFixed(1)):0,d.value=e.tx_power??0,f.value=e.coding_rate??0,p.value=e.preamble_length??0},R=async()=>{a.value=!0,o.value=null,s.value=null;try{let e={};c.value&&(e.frequency=c.value*1e6),l.value&&(e.spreading_factor=l.value),u.value&&(e.bandwidth=u.value*1e3),d.value&&(e.tx_power=d.value),f.value&&(e.coding_rate=f.value);let n=(await A.post(`/update_radio_config`,e)).data;n.message||n.persisted?(s.value=n.message||`Settings saved successfully`,r.value=!1,await t.fetchStats(),setTimeout(()=>{s.value=null},3e3)):n.error?o.value=n.error:o.value=`Unknown response from server`}catch(e){console.error(`Failed to update radio settings:`,e),o.value=e.response?.data?.error||`Failed to update settings`}finally{a.value=!1}};return(e,t)=>(w(),C(`div`,U,[s.value?(w(),C(`div`,W,[S(`p`,G,b(s.value),1)])):g(``,!0),o.value?(w(),C(`div`,te,[S(`p`,K,b(o.value),1)])):g(``,!0),S(`div`,ne,[r.value?(w(),C(x,{key:1},[S(`button`,{onClick:L,disabled:a.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,q),S(`button`,{onClick:R,disabled:a.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(a.value?`Saving...`:`Save Changes`),9,J)],64)):(w(),C(`button`,{key:0,onClick:I,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 `))]),S(`div`,Y,[S(`div`,X,[t[6]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Frequency`,-1),r.value?(w(),C(`div`,Q,[m(S(`input`,{"onUpdate:modelValue":t[0]||=e=>c.value=e,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),[[F,c.value,void 0,{number:!0}]]),t[5]||=S(`span`,{class:`text-content-muted dark:text-content-muted text-sm`},`MHz`,-1)])):(w(),C(`div`,Z,b(T.value),1))]),S(`div`,re,[t[7]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Spreading Factor`,-1),r.value?(w(),C(`div`,ie,[m(S(`select`,{"onUpdate:modelValue":t[1]||=e=>l.value=e,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`},[(w(),C(x,null,i([5,6,7,8,9,10,11,12],e=>S(`option`,{key:e,value:e},b(e),9,ae)),64))],512),[[P,l.value,void 0,{number:!0}]])])):(w(),C(`div`,$,b(N.value),1))]),S(`div`,oe,[t[8]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Bandwidth`,-1),r.value?(w(),C(`div`,ce,[m(S(`select`,{"onUpdate:modelValue":t[2]||=e=>u.value=e,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`},[(w(),C(x,null,i(_,e=>S(`option`,{key:e.value,value:e.value},b(e.label),9,le)),64))],512),[[P,u.value,void 0,{number:!0}]])])):(w(),C(`div`,se,b(D.value),1))]),S(`div`,ue,[t[10]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`TX Power`,-1),r.value?(w(),C(`div`,fe,[m(S(`input`,{"onUpdate:modelValue":t[3]||=e=>d.value=e,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),[[F,d.value,void 0,{number:!0}]]),t[9]||=S(`span`,{class:`text-content-muted dark:text-content-muted text-sm`},`dBm`,-1)])):(w(),C(`div`,de,b(O.value),1))]),S(`div`,pe,[t[12]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Coding Rate`,-1),r.value?(w(),C(`div`,he,[m(S(`select`,{"onUpdate:modelValue":t[4]||=e=>f.value=e,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`},[...t[11]||=[S(`option`,{value:5},`4/5`,-1),S(`option`,{value:6},`4/6`,-1),S(`option`,{value:7},`4/7`,-1),S(`option`,{value:8},`4/8`,-1)]],512),[[P,f.value,void 0,{number:!0}]])])):(w(),C(`div`,me,b(k.value),1))]),S(`div`,ge,[t[13]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Preamble Length`,-1),S(`span`,_e,b(M.value),1)])]),r.value?(w(),C(`div`,ve,[...t[14]||=[S(`p`,{class:`text-yellow-700 dark:text-yellow-400 text-xs`},[S(`strong`,null,`Note:`),y(` Radio hardware changes (frequency, bandwidth, spreading factor, coding rate) may require a service restart to apply. `)],-1)]])):g(``,!0)]))}}),be={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`},xe={class:`flex-1 relative min-h-[400px]`},Se={class:`p-6 border-t border-stroke-subtle dark:border-stroke/10 space-y-4`},Ce={class:`grid grid-cols-2 gap-4`},we=M(f({__name:`LocationPicker`,props:{isOpen:{type:Boolean},latitude:{},longitude:{}},emits:[`close`,`select`],setup(t,{emit:r}){let i=t,a=r,o=E(null),s=E(i.latitude||0),l=E(i.longitude||0),u=null,d=null,f=async()=>{if(o.value){p();try{let t=(await O(async()=>{let{default:t}=await import(`./leaflet-src-PYB8oVmQ.js`).then(t=>e(t.t(),1));return{default:t}},__vite__mapDeps([0,1]))).default;delete t.Icon.Default.prototype._getIconUrl,t.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 c();let n=s.value||0,r=l.value||0,i=n===0&&r===0?2:13;u=t.map(o.value).setView([n,r],i);try{let e=t.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==`}),n=t.tileLayer(`https://{s}.basemaps.cartocdn.com/dark_only_labels/{z}/{x}/{y}{r}.png`,{maxZoom:19,attribution:``,errorTileUrl:`data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==`});e.addTo(u),n.addTo(u)}catch(e){console.warn(`Error loading tiles:`,e)}(n!==0||r!==0)&&(d=t.marker([n,r]).addTo(u)),u.on(`click`,e=>{s.value=e.latlng.lat,l.value=e.latlng.lng,d?d.setLatLng(e.latlng):d=t.marker(e.latlng).addTo(u)}),setTimeout(()=>{u?.invalidateSize()},200)}catch(e){console.error(`Failed to initialize map:`,e)}}},p=()=>{u&&(u.remove(),u=null,d=null)};h(()=>i.isOpen,async e=>{e?(await c(),await f()):p()}),h(()=>[i.latitude,i.longitude],([e,t])=>{s.value=e,l.value=t});let _=()=>{a(`select`,{latitude:s.value,longitude:l.value}),a(`close`)},v=()=>{a(`close`)},b=()=>{navigator.geolocation?navigator.geolocation.getCurrentPosition(async t=>{if(s.value=t.coords.latitude,l.value=t.coords.longitude,u){u.setView([s.value,l.value],13);let t=(await O(async()=>{let{default:t}=await import(`./leaflet-src-PYB8oVmQ.js`).then(t=>e(t.t(),1));return{default:t}},__vite__mapDeps([0,1]))).default;d?d.setLatLng([s.value,l.value]):d=t.marker([s.value,l.value]).addTo(u)}},e=>{console.error(`Error getting location:`,e),alert(`Unable to get current location. Please check browser permissions.`)}):alert(`Geolocation is not supported by this browser.`)};return n(()=>{p()}),(e,n)=>t.isOpen?(w(),C(`div`,{key:0,class:`fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm`,onClick:I(v,[`self`])},[S(`div`,be,[S(`div`,{class:`flex items-center justify-between p-6 border-b border-stroke-subtle dark:border-stroke/10`},[n[3]||=S(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` Select Location `,-1),S(`button`,{onClick:v,class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...n[2]||=[S(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),S(`div`,xe,[S(`div`,{ref_key:`mapContainer`,ref:o,class:`absolute inset-0 rounded-b-[15px] overflow-hidden`},null,512)]),S(`div`,Se,[S(`div`,Ce,[S(`div`,null,[n[4]||=S(`label`,{class:`block text-sm font-medium text-content-secondary dark:text-content-muted mb-2`},`Latitude`,-1),m(S(`input`,{"onUpdate:modelValue":n[0]||=e=>s.value=e,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),[[F,s.value,void 0,{number:!0}]])]),S(`div`,null,[n[5]||=S(`label`,{class:`block text-sm font-medium text-content-secondary dark:text-content-muted mb-2`},`Longitude`,-1),m(S(`input`,{"onUpdate:modelValue":n[1]||=e=>l.value=e,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),[[F,l.value,void 0,{number:!0}]])])]),S(`div`,{class:`flex gap-3`},[S(`button`,{onClick:b,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`},[...n[6]||=[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`}),S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M15 11a3 3 0 11-6 0 3 3 0 016 0z`})],-1),y(` Use Current Location `,-1)]]),S(`button`,{onClick:v,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 `),S(`button`,{onClick:_,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 `)]),n[7]||=S(`p`,{class:`text-content-muted dark:text-content-muted text-xs text-center`},` Click on the map to select a location `,-1)])])])):g(``,!0)}}),[[`__scopeId`,`data-v-fd94857e`]]),Te={class:`space-y-4`},Ee={key:0,class:`bg-green-100 dark:bg-green-500/10 border border-green-300 dark:border-green-500/30 rounded-lg p-3`},De={class:`text-green-700 dark:text-green-400 text-sm`},Oe={key:1,class:`bg-red-100 dark:bg-red-500/10 border border-red-300 dark:border-red-500/30 rounded-lg p-3`},ke={class:`text-red-700 dark:text-red-400 text-sm`},Ae={class:`flex justify-end gap-2`},je=[`disabled`],Me=[`disabled`],Ne={class:`bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3`},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`},Fe={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm break-all`},Ie={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`},Le={class:`text-content-primary dark:text-content-primary font-mono text-xs break-all`},Re={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`},ze={class:`flex flex-col items-end gap-1`},Be={class:`text-content-primary dark:text-content-primary font-mono text-xs break-all sm:text-right sm:max-w-xs`},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`},He={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Ue={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`},We={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Ge={key:0,class:`flex justify-end`},Ke={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={class:`text-content-primary dark:text-content-primary font-mono text-sm`},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`},Ye={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Xe={class:`flex flex-col py-2 gap-2`},Ze={class:`flex flex-col sm:flex-row sm:justify-between sm:items-start gap-1`},Qe={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm sm:ml-4`},$e={key:1,class:`flex items-center gap-2`},et={class:`bg-surface dark:bg-surface-elevated border border-stroke-subtle dark:border-stroke/20 rounded-[15px] shadow-2xl w-full max-w-md p-6 space-y-4`},tt={class:`block text-sm font-medium text-content-secondary dark:text-content-muted mb-2`},nt=[`maxlength`,`disabled`],rt={key:0,class:`text-red-500 text-xs mt-1`},it={key:1,class:`text-content-muted dark:text-content-muted text-xs mt-1`},at=[`disabled`],ot={key:0,class:`mt-2 bg-amber-500/10 border border-amber-500/30 rounded-lg p-3`},st={key:0,class:`flex items-center gap-3 bg-blue-500/10 border border-blue-500/30 rounded-lg p-3`},ct={class:`text-blue-700 dark:text-blue-400 text-xs font-medium`},lt={class:`text-blue-600 dark:text-blue-500 text-xs mt-0.5`},ut={key:1,class:`bg-red-500/10 border border-red-500/30 rounded-lg p-3`},dt={class:`text-red-600 dark:text-red-400 text-sm`},ft={key:2,class:`bg-green-500/10 border border-green-600/40 dark:border-green-500/30 rounded-lg p-3 space-y-2`},pt={class:`text-green-600 dark:text-green-400 text-sm font-medium`},mt={class:`font-mono text-xs break-all text-content-primary dark:text-content-primary`},ht={key:3,class:`bg-amber-500/10 border border-amber-500/30 rounded-lg p-3`},gt={class:`flex gap-2 mt-3`},_t=[`disabled`],vt=[`disabled`],yt={class:`flex justify-end gap-3 mt-6`},bt=[`disabled`],xt=[`disabled`],St=f({__name:`RepeaterSettings`,setup(e){let t=j(),n=v(()=>t.stats?.config||{}),r=v(()=>n.value.repeater||{}),i=v(()=>t.stats),a=E(!1),o=E(!1),s=E(null),c=E(null),d=E(!1),f=E(``),D=E(0),O=E(0),k=E(0),M=E(1),N=v(()=>n.value.mesh||{});h([n,r,N],()=>{if(!a.value){f.value=n.value.node_name||``,D.value=r.value.latitude||0,O.value=r.value.longitude||0,k.value=r.value.send_advert_interval_hours||0;let e=N.value.path_hash_mode;M.value=e===0||e===1||e===2?e+1:1}},{immediate:!0});let L=v(()=>n.value.node_name||`Not set`),R=v(()=>i.value?.local_hash||`Not available`),z=v(()=>{let e=i.value?.public_key;return!e||e===`Not set`?`Not set`:e}),B=v(()=>{let e=r.value.latitude;return e&&e!==0?e.toFixed(6):`Not set`}),V=v(()=>{let e=r.value.longitude;return e&&e!==0?e.toFixed(6):`Not set`}),ee=v(()=>{let e=r.value.mode;return e?e===`no_tx`?`No TX`:e.charAt(0).toUpperCase()+e.slice(1):`Not set`}),H=v(()=>{let e=r.value.send_advert_interval_hours;return e===void 0?`Not set`:e===0?`Disabled`:e+` hour`+(e===1?``:`s`)}),U=v(()=>{let e=N.value.path_hash_mode;return e===0||e===1||e===2?e+1+(e===0?` byte`:` bytes`):`Not set`}),W=()=>{a.value=!0,s.value=null,c.value=null},G=()=>{a.value=!1,s.value=null,f.value=n.value.node_name||``,D.value=r.value.latitude||0,O.value=r.value.longitude||0,k.value=r.value.send_advert_interval_hours||0;let e=N.value.path_hash_mode;M.value=e===0||e===1||e===2?e+1:1},te=async()=>{o.value=!0,s.value=null,c.value=null;try{let e={};f.value&&(e.node_name=f.value),e.latitude=D.value,e.longitude=O.value,e.flood_advert_interval_hours=k.value,e.path_hash_mode=M.value-1;let n=(await A.post(`/update_radio_config`,e)).data;n.message||n.persisted?(c.value=n.message||`Settings saved successfully`,a.value=!1,await t.fetchStats(),setTimeout(()=>{c.value=null},3e3)):n.error?s.value=n.error:s.value=`Unknown response from server`}catch(e){console.error(`Failed to update repeater settings:`,e),s.value=e.response?.data?.error||`Failed to update settings`}finally{o.value=!1}},K=()=>{d.value=!0},ne=e=>{D.value=e.latitude,O.value=e.longitude},q=E(!1),J=E(``),Y=E(!1),X=E(null),Z=E(null),Q=E(!1),re=E(!1),$=E(!1),ie=E(0),ae=null,oe=v(()=>$.value?8:4),se=v(()=>{let e=J.value.trim();return!e||e.length>oe.value?!1:/^[0-9a-fA-F]+$/.test(e)}),ce=v(()=>{let e=J.value.trim().length;return e===0?``:e===1?`Very fast — ~16 attempts on average`:e===2?`Fast — ~256 attempts on average`:e===3?`Moderate — ~4,096 attempts, a few seconds`:e===4?`Slow — ~65,536 attempts, may take 10-30 seconds`:e===5?`Very slow — ~1 million attempts, could take minutes`:e===6?`Extremely slow — ~16 million attempts, could take a very long time`:e===7?`Extreme — ~268 million attempts, may not complete`:`Extreme — ~4 billion attempts, extremely unlikely to complete`}),le=()=>{ie.value=0,ae=setInterval(()=>{ie.value++},1e3)},ue=()=>{ae&&=(clearInterval(ae),null)};T(()=>ue());let de=()=>{J.value=``,X.value=null,Z.value=null,Q.value=!1,$.value=!1,q.value=!0},fe=async()=>{Y.value=!0,Z.value=null,X.value=null,le();try{let e=await A.generateVanityKey(J.value.trim());e.success&&e.data?X.value=e.data:Z.value=e.error||`Generation failed`}catch(e){let t=e;Z.value=t.response?.data?.error||t.message||`Generation failed`}finally{ue(),Y.value=!1}},pe=async()=>{if(X.value){re.value=!0,Z.value=null;try{let e=await A.generateVanityKey(J.value.trim(),!0);e.success&&e.data?(X.value=e.data,Q.value=!1,q.value=!1,c.value=`New identity key applied. Restart the repeater for the change to take effect.`,await t.fetchStats(),setTimeout(()=>{c.value=null},8e3)):Z.value=e.error||`Failed to apply key`}catch(e){let t=e;Z.value=t.response?.data?.error||t.message||`Failed to apply key`}finally{re.value=!1}}};return(e,t)=>(w(),C(`div`,Te,[c.value?(w(),C(`div`,Ee,[S(`p`,De,b(c.value),1)])):g(``,!0),s.value?(w(),C(`div`,Oe,[S(`p`,ke,b(s.value),1)])):g(``,!0),S(`div`,Ae,[a.value?(w(),C(x,{key:1},[S(`button`,{onClick:G,disabled:o.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,je),S(`button`,{onClick:te,disabled:o.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(o.value?`Saving...`:`Save Changes`),9,Me)],64)):(w(),C(`button`,{key:0,onClick:W,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 `))]),S(`div`,Ne,[S(`div`,Pe,[t[13]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Node Name`,-1),a.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[0]||=e=>f.value=e,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)),[[F,f.value]]):(w(),C(`div`,Fe,b(L.value),1))]),S(`div`,Ie,[t[14]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Local Hash`,-1),S(`span`,Le,b(R.value),1)]),S(`div`,Re,[t[15]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm flex-shrink-0`},`Public Key`,-1),S(`div`,ze,[S(`span`,Be,b(z.value),1),S(`button`,{onClick:de,class:`px-2 py-1 text-xs bg-primary/10 hover:bg-primary/20 text-content-secondary dark:text-content-muted rounded border border-primary/30 transition-colors`},` Generate New Key `)])]),S(`div`,Ve,[t[16]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Latitude`,-1),a.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[1]||=e=>D.value=e,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)),[[F,D.value,void 0,{number:!0}]]):(w(),C(`div`,He,b(B.value),1))]),S(`div`,Ue,[t[17]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Longitude`,-1),a.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[2]||=e=>O.value=e,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)),[[F,O.value,void 0,{number:!0}]]):(w(),C(`div`,We,b(V.value),1))]),a.value?(w(),C(`div`,Ge,[S(`button`,{onClick:K,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`},[...t[18]||=[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`}),S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M15 11a3 3 0 11-6 0 3 3 0 016 0z`})],-1),y(` Pick Location on Map `,-1)]])])):g(``,!0),S(`div`,Ke,[t[19]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Mode`,-1),S(`span`,qe,b(ee.value),1)]),S(`div`,Je,[t[21]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Path hash length`,-1),a.value?m((w(),C(`select`,{key:1,"onUpdate:modelValue":t[3]||=e=>M.value=e,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[20]||=[S(`option`,{value:1},`1 byte`,-1),S(`option`,{value:2},`2 bytes`,-1),S(`option`,{value:3},`3 bytes`,-1)]],512)),[[P,M.value,void 0,{number:!0}]]):(w(),C(`div`,Ye,b(U.value),1))]),S(`div`,Xe,[S(`div`,Ze,[t[23]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Periodic Advertisement Interval`,-1),a.value?(w(),C(`div`,$e,[m(S(`input`,{"onUpdate:modelValue":t[4]||=e=>k.value=e,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),[[F,k.value,void 0,{number:!0}]]),t[22]||=S(`span`,{class:`text-content-muted dark:text-content-muted text-sm`},`hours`,-1)])):(w(),C(`div`,Qe,b(H.value),1))]),t[24]||=S(`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)])]),_(we,{"is-open":d.value,latitude:D.value,longitude:O.value,onClose:t[5]||=e=>d.value=!1,onSelect:ne},null,8,[`is-open`,`latitude`,`longitude`]),(w(),l(p,{to:`body`},[q.value?(w(),C(`div`,{key:0,class:`fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm`,onClick:t[12]||=I(e=>q.value=!1,[`self`])},[S(`div`,et,[t[32]||=S(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` Generate Vanity Identity Key `,-1),t[33]||=S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},` Generate a new Ed25519 identity key whose public key starts with your chosen hex prefix (0-9, A-F). Longer prefixes take more time to find. `,-1),S(`div`,null,[S(`label`,tt,`Hex Prefix (1-`+b(oe.value)+` characters)`,1),m(S(`input`,{"onUpdate:modelValue":t[6]||=e=>J.value=e,type:`text`,maxlength:oe.value,placeholder:`e.g. F8A1`,disabled:Y.value,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 font-mono text-sm uppercase focus:outline-none focus:border-primary transition-colors disabled:opacity-50`},null,8,nt),[[F,J.value]]),J.value&&!se.value?(w(),C(`p`,rt,` Enter 1-`+b(oe.value)+` valid hex characters (0-9, A-F) `,1)):ce.value?(w(),C(`p`,it,b(ce.value),1)):g(``,!0)]),S(`div`,null,[S(`button`,{onClick:t[7]||=e=>$.value=!$.value,disabled:Y.value,class:`text-xs text-content-muted dark:text-content-muted hover:text-content-secondary dark:hover:text-content-secondary transition-colors disabled:opacity-50 flex items-center gap-1`},[(w(),C(`svg`,{class:u([`w-3 h-3 transition-transform`,{"rotate-90":$.value}]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...t[25]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M9 5l7 7-7 7`},null,-1)]],2)),t[26]||=y(` Advanced `,-1)],8,at),$.value?(w(),C(`div`,ot,[...t[27]||=[S(`p`,{class:`text-amber-600 dark:text-amber-400 text-xs font-medium`},` Extended prefix mode (up to 8 characters) `,-1),S(`p`,{class:`text-amber-600 dark:text-amber-500 text-xs mt-1`},` Prefixes longer than 4 characters require exponentially more attempts and can take a very long time or may not complete at all. The request may time out. `,-1)]])):g(``,!0)]),Y.value?(w(),C(`div`,st,[t[28]||=S(`svg`,{class:`animate-spin h-5 w-5 text-blue-500 flex-shrink-0`,xmlns:`http://www.w3.org/2000/svg`,fill:`none`,viewBox:`0 0 24 24`},[S(`circle`,{class:`opacity-25`,cx:`12`,cy:`12`,r:`10`,stroke:`currentColor`,"stroke-width":`4`}),S(`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`})],-1),S(`div`,null,[S(`p`,ct,` Searching for key with prefix "`+b(J.value.toUpperCase())+`"... `,1),S(`p`,lt,` Elapsed: `+b(ie.value)+`s `,1)])])):g(``,!0),Z.value?(w(),C(`div`,ut,[S(`p`,dt,b(Z.value),1)])):g(``,!0),X.value?(w(),C(`div`,ft,[S(`p`,pt,` Key found in `+b(X.value.attempts.toLocaleString())+` attempts `,1),S(`div`,null,[t[29]||=S(`span`,{class:`text-xs text-content-muted dark:text-content-muted`},`Public Key:`,-1),S(`p`,mt,b(X.value.public_hex),1)])])):g(``,!0),Q.value&&X.value?(w(),C(`div`,ht,[t[30]||=S(`p`,{class:`text-amber-600 dark:text-amber-400 text-sm font-medium`},` Warning: This will replace your current identity key. `,-1),t[31]||=S(`p`,{class:`text-amber-600 dark:text-amber-500 text-xs mt-1`},` Your node address and public key will change. Other nodes will need to re-discover you. This cannot be undone unless you have a backup. `,-1),S(`div`,gt,[S(`button`,{onClick:pe,disabled:re.value,class:`px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs transition-colors disabled:opacity-50`},b(re.value?`Applying...`:`Confirm Replace Key`),9,_t),S(`button`,{onClick:t[8]||=e=>Q.value=!1,disabled:re.value,class:`px-3 py-1.5 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 text-xs transition-colors`},` Cancel `,8,vt)])])):g(``,!0),S(`div`,yt,[S(`button`,{onClick:t[9]||=e=>q.value=!1,disabled:Y.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`},` Close `,8,bt),X.value?(w(),C(x,{key:1},[S(`button`,{onClick:t[10]||=e=>{X.value=null,Z.value=null},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 text-sm transition-colors`},` Try Again `),Q.value?g(``,!0):(w(),C(`button`,{key:0,onClick:t[11]||=e=>Q.value=!0,class:`px-4 py-2 bg-red-600/20 hover:bg-red-600/30 text-red-600 dark:text-red-400 rounded-lg border border-red-500/50 text-sm transition-colors`},` Apply Key `))],64)):(w(),C(`button`,{key:0,onClick:fe,disabled:!se.value||Y.value,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 text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed`},b(Y.value?`Generating...`:`Generate`),9,xt))])])])):g(``,!0)]))]))}}),Ct={class:`space-y-4`},wt={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`},Et={class:`flex justify-end gap-2`},Dt=[`disabled`],Ot=[`disabled`],kt={class:`bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3`},At={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`},Mt={class:`flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 gap-1`},Nt={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Pt=f({__name:`DutyCycle`,setup(e){let t=j(),n=v(()=>t.stats?.config?.duty_cycle||{}),r=v(()=>{let e=n.value.max_airtime_percent;return typeof e==`number`?e.toFixed(1)+`%`:e&&typeof e==`object`&&`parsedValue`in e?(e.parsedValue||0).toFixed(1)+`%`:`Not set`}),i=v(()=>n.value.enforcement_enabled?`Enabled`:`Disabled`),a=E(!1),o=E(!1),s=E(``),c=E(``),l=E(0),u=E(!0),d=()=>{let e=n.value.max_airtime_percent;typeof e==`number`?l.value=e:e&&typeof e==`object`&&`parsedValue`in e?l.value=e.parsedValue||0:l.value=6,u.value=n.value.enforcement_enabled!==!1,a.value=!0,s.value=``,c.value=``},f=()=>{a.value=!1,s.value=``,c.value=``},p=async()=>{o.value=!0,c.value=``,s.value=``;try{let e=(await k.post(`/api/update_duty_cycle_config`,{max_airtime_percent:l.value,enforcement_enabled:u.value})).data;e.message||e.persisted?(s.value=e.message||`Settings saved successfully`,a.value=!1,await t.fetchStats(),setTimeout(()=>{s.value=``},3e3)):c.value=`Failed to save settings`}catch(e){console.error(`Failed to save duty cycle settings:`,e),c.value=e.response?.data?.error||`Failed to save settings`}finally{o.value=!1}};return(e,t)=>(w(),C(`div`,Ct,[s.value?(w(),C(`div`,wt,b(s.value),1)):g(``,!0),c.value?(w(),C(`div`,Tt,b(c.value),1)):g(``,!0),S(`div`,Et,[a.value?(w(),C(x,{key:1},[S(`button`,{onClick:f,disabled:o.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),S(`button`,{onClick:p,disabled:o.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(o.value?`Saving...`:`Save Changes`),9,Ot)],64)):(w(),C(`button`,{key:0,onClick:d,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 `))]),S(`div`,kt,[S(`div`,At,[t[2]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Max Airtime %`,-1),a.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[0]||=e=>l.value=e,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)),[[F,l.value,void 0,{number:!0}]]):(w(),C(`div`,jt,b(r.value),1))]),S(`div`,Mt,[t[4]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Enforcement`,-1),a.value?m((w(),C(`select`,{key:1,"onUpdate:modelValue":t[1]||=e=>u.value=e,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]||=[S(`option`,{value:!0},`Enabled`,-1),S(`option`,{value:!1},`Disabled`,-1)]],512)),[[P,u.value]]):(w(),C(`div`,Nt,b(i.value),1))])])]))}}),Ft={class:`space-y-4`},It={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`},Lt={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`},Rt={class:`flex justify-end gap-2`},zt=[`disabled`],Bt=[`disabled`],Vt={class:`bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3`},Ht={class:`flex flex-col py-2 border-b border-stroke-subtle dark:border-stroke/10 gap-2`},Ut={class:`flex flex-col sm:flex-row sm:justify-between sm:items-start gap-1`},Wt={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm sm:ml-4`},Gt={class:`flex flex-col py-2 gap-2`},Kt={class:`flex flex-col sm:flex-row sm:justify-between sm:items-start gap-1`},qt={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm sm:ml-4`},Jt=f({__name:`TransmissionDelays`,setup(e){let t=j(),n=v(()=>t.stats?.config?.delays||{}),r=v(()=>{let e=n.value.tx_delay_factor;if(e&&typeof e==`object`&&e&&`parsedValue`in e){let t=e.parsedValue;if(typeof t==`number`)return t.toFixed(2)+`x`}return`Not set`}),i=v(()=>{let e=n.value.direct_tx_delay_factor;return typeof e==`number`?e.toFixed(2)+`s`:`Not set`}),a=E(!1),o=E(!1),s=E(``),c=E(``),l=E(0),u=E(0),d=()=>{let e=n.value.tx_delay_factor;e&&typeof e==`object`&&`parsedValue`in e?l.value=e.parsedValue||1:typeof e==`number`?l.value=e:l.value=1;let t=n.value.direct_tx_delay_factor;u.value=typeof t==`number`?t:.5,a.value=!0,s.value=``,c.value=``},f=()=>{a.value=!1,s.value=``,c.value=``},p=async()=>{o.value=!0,c.value=``,s.value=``;try{let e=(await k.post(`/api/update_radio_config`,{tx_delay_factor:l.value,direct_tx_delay_factor:u.value})).data;e.message||e.persisted?(s.value=e.message||`Settings saved successfully`,a.value=!1,await t.fetchStats(),setTimeout(()=>{s.value=``},3e3)):c.value=`Failed to save settings`}catch(e){console.error(`Failed to save delay settings:`,e),c.value=e.response?.data?.error||`Failed to save settings`}finally{o.value=!1}};return(e,t)=>(w(),C(`div`,Ft,[s.value?(w(),C(`div`,It,b(s.value),1)):g(``,!0),c.value?(w(),C(`div`,Lt,b(c.value),1)):g(``,!0),S(`div`,Rt,[a.value?(w(),C(x,{key:1},[S(`button`,{onClick:f,disabled:o.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,zt),S(`button`,{onClick:p,disabled:o.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(o.value?`Saving...`:`Save Changes`),9,Bt)],64)):(w(),C(`button`,{key:0,onClick:d,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 `))]),S(`div`,Vt,[S(`div`,Ht,[S(`div`,Ut,[t[2]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Flood TX Delay Factor`,-1),a.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[0]||=e=>l.value=e,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)),[[F,l.value,void 0,{number:!0}]]):(w(),C(`div`,Wt,b(r.value),1))]),t[3]||=S(`span`,{class:`text-content-muted dark:text-content-muted text-xs`},`Multiplier for flood packet transmission delays (collision avoidance)`,-1)]),S(`div`,Gt,[S(`div`,Kt,[t[4]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Direct TX Delay Factor`,-1),a.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[1]||=e=>u.value=e,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)),[[F,u.value,void 0,{number:!0}]]):(w(),C(`div`,qt,b(i.value),1))]),t[5]||=S(`span`,{class:`text-content-muted dark:text-content-muted text-xs`},`Base delay for direct-routed packet transmission (seconds)`,-1)])])]))}}),Yt=D(`treeState`,()=>{let e=o(new Set),t=o({value:null}),n=t=>{e.add(t)},r=t=>{e.delete(t)};return{expandedNodes:e,selectedNodeId:t,addExpandedNode:n,removeExpandedNode:r,isNodeExpanded:t=>e.has(t),setSelectedNode:e=>{t.value=e},toggleExpanded:t=>{e.has(t)?r(t):n(t)}}}),Xt={class:`select-none`},Zt={class:`flex-shrink-0`},Qt={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`},$t={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`},en={key:0,class:`hidden sm:flex items-center gap-1 ml-2`},tn={class:`relative group`},nn=[`title`],rn={key:0,class:`text-xs font-mono text-white/50 bg-white/5 px-1.5 py-0.5 rounded border border-white/10`},an={class:`flex justify-between items-start mb-4`},on={class:`bg-black/20 border border-white/10 rounded-md p-4 mb-4`},sn={class:`text-sm font-mono text-white/80 break-all leading-relaxed`},cn={class:`flex items-center gap-1 sm:gap-2 ml-auto flex-shrink-0`},ln={key:0,class:`hidden sm:flex items-center gap-1`},un=[`title`],dn={key:1,class:`hidden sm:flex items-center gap-1`},fn={key:2,class:`hidden sm:inline-block px-2 py-1 bg-white/10 text-white/60 text-xs rounded-full ml-1`},pn={key:0,class:`space-y-1`},mn=M(f({__name:`TreeNode`,props:{node:{},selectedNodeId:{},level:{},disabled:{type:Boolean}},emits:[`select`],setup(e,{emit:n}){let a=e,o=n,s=Yt(),c=E(!1),d=v({get:()=>s.isNodeExpanded(a.node.id),set:e=>{e?s.addExpandedNode(a.node.id):s.removeExpandedNode(a.node.id)}}),f=v(()=>a.node.children.length>0);function p(e){if(!e)return`Never`;let t=new Date().getTime()-e.getTime(),n=Math.floor(t/(1e3*60)),r=Math.floor(t/(1e3*60*60)),i=Math.floor(t/(1e3*60*60*24)),a=Math.floor(i/365);return n<60?`${n}m ago`:r<24?`${r}h ago`:i<365?`${i}d ago`:`${a}y ago`}function m(e){return e?e.length<=16?e:`${e.slice(0,8)}...${e.slice(-8)}`:`No key`}function h(){f.value&&(d.value=!d.value)}function T(){o(`select`,a.node.id)}function D(e){o(`select`,e)}function O(e){e.stopPropagation(),c.value=!c.value}function k(e){e.stopPropagation(),a.node.transport_key&&window.navigator?.clipboard&&window.navigator.clipboard.writeText(a.node.transport_key)}return(n,o)=>{let s=r(`TreeNode`,!0);return w(),C(`div`,Xt,[S(`div`,{class:u([`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`,a.disabled?`opacity-50 cursor-not-allowed`:`hover:bg-white/5`,e.selectedNodeId===e.node.id&&!a.disabled?`bg-primary/20 text-primary`:`text-white/80 hover:text-white`,`ml-${e.level*4}`]),onClick:o[3]||=e=>!a.disabled&&T()},[S(`div`,{class:`flex-shrink-0 w-3 h-3 sm:w-4 sm:h-4 flex items-center justify-center`,onClick:I(h,[`stop`])},[f.value?(w(),C(`svg`,{key:0,class:u([`w-2.5 h-2.5 sm:w-3 sm:h-3 transition-transform duration-200`,d.value?`rotate-90`:`rotate-0`]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...o[4]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M9 5l7 7-7 7`},null,-1)]],2)):g(``,!0)]),S(`div`,Zt,[a.node.name.startsWith(`#`)?(w(),C(`svg`,Qt,[...o[5]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M7 20l4-16m2 16l4-16M6 9h14M4 15h14`},null,-1)]])):(w(),C(`svg`,$t,[...o[6]||=[S(`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)]]))]),S(`span`,{class:u([`font-mono text-xs sm:text-sm transition-colors duration-200 break-all`,e.selectedNodeId===e.node.id?`text-primary font-medium`:``])},b(e.node.name),3),e.node.transport_key?(w(),C(`div`,en,[S(`div`,tn,[S(`button`,{onClick:O,class:`p-1 rounded hover:bg-white/10 transition-colors`,title:c.value?`Hide full key`:`Show full key`},[...o[7]||=[S(`svg`,{class:`w-3 h-3 text-white/60 hover:text-white/80`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M15 12a3 3 0 11-6 0 3 3 0 016 0z`}),S(`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,nn),c.value?g(``,!0):(w(),C(`span`,rn,b(m(e.node.transport_key)),1)),c.value?(w(),C(`div`,{key:1,class:`fixed inset-0 z-[9998] flex items-center justify-center bg-black/70 backdrop-blur-md`,onClick:o[2]||=e=>c.value=!1},[S(`div`,{class:`bg-black/20 border border-white/20 rounded-lg shadow-lg p-6 max-w-2xl w-full mx-4`,onClick:o[1]||=I(()=>{},[`stop`])},[S(`div`,an,[o[9]||=S(`h3`,{class:`text-lg font-semibold text-white`},`Transport Key`,-1),S(`button`,{onClick:o[0]||=e=>c.value=!1,class:`text-white/60 hover:text-white transition-colors`},[...o[8]||=[S(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),S(`div`,on,[S(`div`,sn,b(e.node.transport_key),1)]),S(`div`,{class:`flex justify-end`},[S(`button`,{onClick:k,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`},[...o[10]||=[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),y(` Copy Key `,-1)]])])])])):g(``,!0)])])):g(``,!0),S(`div`,cn,[e.node.last_used?(w(),C(`div`,ln,[o[11]||=S(`svg`,{class:`w-3 h-3 text-white/40`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),S(`span`,{class:`text-xs text-white/50`,title:e.node.last_used.toLocaleString()},b(p(e.node.last_used)),9,un)])):(w(),C(`div`,dn,[...o[12]||=[S(`svg`,{class:`w-3 h-3 text-white/30`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),S(`span`,{class:`text-xs text-white/30 italic`},`Never`,-1)]])),S(`span`,{class:u([`px-1.5 sm:px-2 py-0.5 text-[10px] sm:text-xs font-medium rounded-md transition-colors`,e.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(e.node.floodPolicy===`allow`?`ALLOW`:`DENY`),3),f.value?(w(),C(`span`,fn,` > `+b(e.node.children.length),1)):g(``,!0)])],2),_(N,{"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:t(()=>[d.value&&e.node.children.length>0?(w(),C(`div`,pn,[(w(!0),C(x,null,i(e.node.children,t=>(w(),l(s,{key:t.id,node:t,"selected-node-id":e.selectedNodeId,level:e.level+1,disabled:a.disabled,onSelect:D},null,8,[`node`,`selected-node-id`,`level`,`disabled`]))),128))])):g(``,!0)]),_:1})])}}}),[[`__scopeId`,`data-v-ed9c8a11`]]),hn={class:`flex items-center justify-between mb-6`},gn={class:`text-content-secondary dark:text-content-muted text-sm mt-1`},_n={key:0},vn={class:`text-primary font-mono`},yn={key:1},bn={for:`keyName`,class:`block text-sm font-medium text-white mb-2`},xn={class:`flex items-center gap-2`},Sn={key:0,class:`w-4 h-4 text-secondary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Cn={key:1,class:`w-4 h-4 text-accent-green`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},wn={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4`},Tn={class:`flex items-center gap-3 mb-2`},En={class:`flex items-center gap-2`},Dn={key:0,class:`w-5 h-5 text-secondary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},On={key:1,class:`w-5 h-5 text-accent-green`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},kn={class:`text-content-secondary dark:text-content-muted text-sm`},An={class:`grid grid-cols-2 gap-3`},jn={class:`relative cursor-pointer group`},Mn={class:`relative cursor-pointer group`},Nn={class:`flex gap-3 pt-4`},Pn=[`disabled`],Fn=f({__name:`AddKeyModal`,props:{show:{type:Boolean},selectedNodeName:{},selectedNodeId:{}},emits:[`close`,`add`],setup(e,{emit:t}){let n=e,r=t,i=E(``),a=E(``),o=E(`allow`),s=v(()=>i.value.startsWith(`#`)),c=v(()=>({type:s.value?`Region`:`Private Key`,description:s.value?`Regional organizational key`:`Individual assigned key`}));h(s,e=>{e?a.value=`This will create a new region for organizing keys`:a.value=`This will create a new private key entry`},{immediate:!0});let l=v(()=>i.value.trim().length>0),f=()=>{l.value&&(r(`add`,{name:i.value.trim(),floodPolicy:o.value,parentId:n.selectedNodeId}),i.value=``,a.value=``,o.value=`allow`)},p=()=>{i.value=``,a.value=``,o.value=`allow`,r(`close`)},_=e=>{e.target===e.currentTarget&&p()};return(t,r)=>e.show?(w(),C(`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`}},[S(`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:r[3]||=I(()=>{},[`stop`])},[S(`div`,hn,[S(`div`,null,[r[5]||=S(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` Add New Entry `,-1),S(`p`,gn,[n.selectedNodeName?(w(),C(`span`,_n,[r[4]||=y(` Add to: `,-1),S(`span`,vn,b(n.selectedNodeName),1)])):(w(),C(`span`,yn,` Add to root level (#uk) `))])]),S(`button`,{onClick:p,class:`text-white/60 hover:text-white transition-colors`},[...r[6]||=[S(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),S(`form`,{onSubmit:I(f,[`prevent`]),class:`space-y-4`},[S(`div`,null,[S(`label`,bn,[S(`div`,xn,[s.value?(w(),C(`svg`,Sn,[...r[7]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M7 20l4-16m2 16l4-16M6 9h14M4 15h14`},null,-1)]])):(w(),C(`svg`,Cn,[...r[8]||=[S(`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)]])),r[9]||=y(` Region/Key Name `,-1)])]),m(S(`input`,{id:`keyName`,"onUpdate:modelValue":r[0]||=e=>i.value=e,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),[[F,i.value]])]),S(`div`,wn,[S(`div`,Tn,[S(`div`,En,[s.value?(w(),C(`svg`,Dn,[...r[10]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M7 20l4-16m2 16l4-16M6 9h14M4 15h14`},null,-1)]])):(w(),C(`svg`,On,[...r[11]||=[S(`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)]])),S(`span`,{class:u([s.value?`text-secondary`:`text-accent-green`,`font-medium`])},b(c.value.type),3)]),S(`div`,{class:u([`flex-1 h-px`,s.value?`bg-secondary/20`:`bg-accent-green/20`])},null,2)]),S(`p`,kn,b(c.value.description),1)]),S(`div`,null,[r[14]||=S(`label`,{class:`block text-sm font-medium text-content-primary dark:text-content-primary mb-3`},[S(`div`,{class:`flex items-center gap-2`},[S(`svg`,{class:`w-4 h-4 text-primary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`})]),y(` Flood Policy `)])],-1),S(`div`,An,[S(`label`,jn,[m(S(`input`,{type:`radio`,"onUpdate:modelValue":r[1]||=e=>o.value=e,value:`allow`,class:`sr-only`},null,512),[[B,o.value]]),r[12]||=d(`
Allow

Permit flooding

`,1)]),S(`label`,Mn,[m(S(`input`,{type:`radio`,"onUpdate:modelValue":r[2]||=e=>o.value=e,value:`deny`,class:`sr-only`},null,512),[[B,o.value]]),r[13]||=d(`
Deny

Block flooding

`,1)])])]),S(`div`,Nn,[S(`button`,{type:`button`,onClick:p,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(`button`,{type:`submit`,disabled:!l.value,class:u([`flex-1 px-4 py-3 rounded-lg transition-colors font-medium`,l.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,Pn)])],32)])])):g(``,!0)}}),In={class:`flex items-center justify-between mb-6`},Ln={class:`text-content-secondary dark:text-content-muted text-sm mt-1`},Rn={class:`text-primary font-mono`},zn={for:`keyName`,class:`block text-sm font-medium text-content-secondary dark:text-content-primary mb-2`},Bn={class:`flex items-center gap-2`},Vn={key:0,class:`w-4 h-4 text-secondary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Hn={key:1,class:`w-4 h-4 text-accent-green`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Un={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4`},Wn={class:`flex items-center gap-3 mb-2`},Gn={class:`flex items-center gap-2`},Kn={key:0,class:`w-5 h-5 text-secondary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},qn={key:1,class:`w-5 h-5 text-accent-green`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Jn={class:`text-content-secondary dark:text-content-muted text-sm`},Yn={key:0,class:`space-y-4`},Xn={key:0,class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4`},Zn={class:`bg-background-mute dark:bg-black/20 border border-stroke-subtle dark:border-stroke/10 rounded-md p-3`},Qn={class:`text-xs font-mono text-content-primary dark:text-content-primary/80 break-all`},$n={key:1,class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4`},er={class:`flex items-center justify-between`},tr={class:`text-sm text-content-secondary dark:text-content-muted`},nr={class:`text-xs text-content-muted dark:text-content-muted`},rr={class:`grid grid-cols-2 gap-3`},ir={class:`relative cursor-pointer group`},ar={class:`relative cursor-pointer group`},or={class:`flex gap-3 pt-4`},sr=[`disabled`],cr=f({__name:`EditKeyModal`,props:{show:{type:Boolean},node:{}},emits:[`close`,`save`,`request-delete`],setup(e,{emit:t}){let n=e,r=t,i=E(``),a=E(`allow`),o=v(()=>i.value.startsWith(`#`)),s=v(()=>({type:o.value?`Region`:`Private Key`,description:o.value?`Regional organizational key`:`Individual assigned key`}));h(()=>n.node,e=>{e?(i.value=e.name,a.value=e.floodPolicy):(i.value=``,a.value=`allow`)},{immediate:!0});let c=v(()=>i.value.trim().length>0&&n.node),l=e=>{let t=new Date().getTime()-e.getTime(),n=Math.floor(t/(1e3*60)),r=Math.floor(t/(1e3*60*60)),i=Math.floor(t/(1e3*60*60*24)),a=Math.floor(i/365);return n<60?`${n}m ago`:r<24?`${r}h ago`:i<365?`${i}d ago`:`${a}y ago`},f=e=>{window.navigator?.clipboard&&window.navigator.clipboard.writeText(e)},p=()=>{!c.value||!n.node||(r(`save`,{id:n.node.id,name:i.value.trim(),floodPolicy:a.value}),x())},_=()=>{n.node&&(r(`request-delete`,n.node),x())},x=()=>{r(`close`)},T=e=>{e.target===e.currentTarget&&x()};return(t,n)=>e.show?(w(),C(`div`,{key:0,onClick:T,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`}},[S(`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]||=I(()=>{},[`stop`])},[S(`div`,In,[S(`div`,null,[n[6]||=S(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` Edit Entry `,-1),S(`p`,Ln,[n[5]||=y(` Modify `,-1),S(`span`,Rn,b(e.node?.name),1)])]),S(`button`,{onClick:x,class:`text-white/60 hover:text-white transition-colors`},[...n[7]||=[S(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),S(`form`,{onSubmit:I(p,[`prevent`]),class:`space-y-4`},[S(`div`,null,[S(`label`,zn,[S(`div`,Bn,[o.value?(w(),C(`svg`,Vn,[...n[8]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M7 20l4-16m2 16l4-16M6 9h14M4 15h14`},null,-1)]])):(w(),C(`svg`,Hn,[...n[9]||=[S(`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]||=y(` Region/Key Name `,-1)])]),m(S(`input`,{id:`keyName`,"onUpdate:modelValue":n[0]||=e=>i.value=e,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),[[F,i.value]])]),S(`div`,Un,[S(`div`,Wn,[S(`div`,Gn,[o.value?(w(),C(`svg`,Kn,[...n[11]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M7 20l4-16m2 16l4-16M6 9h14M4 15h14`},null,-1)]])):(w(),C(`svg`,qn,[...n[12]||=[S(`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)]])),S(`span`,{class:u([o.value?`text-secondary`:`text-accent-green`,`font-medium`])},b(s.value.type),3)]),S(`div`,{class:u([`flex-1 h-px`,o.value?`bg-secondary/20`:`bg-accent-green/20`])},null,2)]),S(`p`,Jn,b(s.value.description),1)]),e.node?(w(),C(`div`,Yn,[e.node.transport_key?(w(),C(`div`,Xn,[n[14]||=d(`
Transport Key
`,1),S(`div`,Zn,[S(`div`,Qn,b(e.node.transport_key),1),S(`button`,{onClick:n[1]||=t=>f(e.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]||=[S(`svg`,{class:`w-3 h-3`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),y(` Copy Key `,-1)]])])])):g(``,!0),e.node.last_used?(w(),C(`div`,$n,[n[15]||=S(`div`,{class:`flex items-center gap-2 mb-3`},[S(`svg`,{class:`w-4 h-4 text-primary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`})]),S(`span`,{class:`text-sm font-medium text-content-primary dark:text-content-primary`},`Last Used`)],-1),S(`div`,er,[S(`div`,tr,b(e.node.last_used.toLocaleDateString())+` at `+b(e.node.last_used.toLocaleTimeString()),1),S(`div`,nr,b(l(e.node.last_used)),1)])])):g(``,!0)])):g(``,!0),S(`div`,null,[n[18]||=S(`label`,{class:`block text-sm font-medium text-content-secondary dark:text-content-primary mb-3`},[S(`div`,{class:`flex items-center gap-2`},[S(`svg`,{class:`w-4 h-4 text-primary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`})]),y(` Flood Policy `)])],-1),S(`div`,rr,[S(`label`,ir,[m(S(`input`,{type:`radio`,"onUpdate:modelValue":n[2]||=e=>a.value=e,value:`allow`,class:`sr-only`},null,512),[[B,a.value]]),n[16]||=d(`
Allow

Permit flooding

`,1)]),S(`label`,ar,[m(S(`input`,{type:`radio`,"onUpdate:modelValue":n[3]||=e=>a.value=e,value:`deny`,class:`sr-only`},null,512),[[B,a.value]]),n[17]||=d(`
Deny

Block flooding

`,1)])])]),S(`div`,or,[S(`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 `),S(`button`,{type:`button`,onClick:x,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(`button`,{type:`submit`,disabled:!c.value,class:u([`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,sr)])],32)])])):g(``,!0)}}),lr={class:`flex items-center gap-3 mb-6`},ur={class:`text-content-secondary dark:text-content-muted text-sm mt-1`},dr={class:`text-accent-red font-mono`},fr={key:0,class:`bg-accent-red/10 border border-accent-red/30 rounded-lg p-4 mb-6`},pr={class:`flex items-start gap-3`},mr={class:`flex-1`},hr={class:`text-accent-red font-medium text-sm mb-2`},gr={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`},vr={key:1,class:`w-3 h-3 text-accent-green`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},yr={class:`font-mono`},br={key:0,class:`text-content-secondary dark:text-content-muted text-xs`},xr={key:1,class:`mb-6`},Sr={class:`mb-3`},Cr={class:`relative`},wr={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`},Er={class:`relative`},Dr=[`value`],Or={class:`flex items-center gap-2 flex-1`},kr={class:`text-content-primary dark:text-content-primary font-mono text-sm`},Ar={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`},jr={class:`flex gap-3`},Mr=f({__name:`DeleteConfirmModal`,props:{show:{type:Boolean},node:{},allNodes:{}},emits:[`close`,`delete-all`,`move-children`],setup(e,{emit:t}){let n=e,r=t,a=E(null),o=E(``),s=e=>{let t=[],n=e=>{for(let r of e.children)t.push(r),n(r)};return n(e),t},c=v(()=>n.node?s(n.node):[]),l=v(()=>{if(!n.node)return[];let e=new Set([n.node.id,...c.value.map(e=>e.id)]),t=n=>{let r=[];for(let i of n)i.name.startsWith(`#`)&&!e.has(i.id)&&r.push(i),i.children.length>0&&r.push(...t(i.children));return r};return t(n.allNodes)}),d=v(()=>{if(!o.value.trim())return l.value;let e=o.value.toLowerCase();return l.value.filter(t=>t.name.toLowerCase().includes(e))}),f=()=>{n.node&&(r(`delete-all`,n.node.id),h())},p=()=>{!n.node||!a.value||(r(`move-children`,{nodeId:n.node.id,targetParentId:a.value}),h())},h=()=>{a.value=null,o.value=``,r(`close`)},_=e=>{e.target===e.currentTarget&&h()};return(t,n)=>e.show&&e.node?(w(),C(`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`}},[S(`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[2]||=I(()=>{},[`stop`])},[S(`div`,lr,[n[6]||=S(`svg`,{class:`w-6 h-6 text-accent-red`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),S(`div`,null,[n[4]||=S(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` Confirm Deletion `,-1),S(`p`,ur,[n[3]||=y(` Deleting `,-1),S(`span`,dr,b(e.node?.name),1)])]),S(`button`,{onClick:h,class:`ml-auto text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...n[5]||=[S(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),c.value.length>0?(w(),C(`div`,fr,[S(`div`,pr,[n[9]||=S(`svg`,{class:`w-5 h-5 text-accent-red flex-shrink-0 mt-0.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),S(`div`,mr,[S(`h4`,hr,` This will affect `+b(c.value.length)+` child `+b(c.value.length===1?`entry`:`entries`)+`: `,1),S(`div`,gr,[(w(!0),C(x,null,i(c.value.slice(0,10),e=>(w(),C(`div`,{key:e.id,class:`flex items-center gap-2 text-xs text-content-secondary dark:text-content-primary/80`},[e.name.startsWith(`#`)?(w(),C(`svg`,_r,[...n[7]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M7 20l4-16m2 16l4-16M6 9h14M4 15h14`},null,-1)]])):(w(),C(`svg`,vr,[...n[8]||=[S(`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)]])),S(`span`,yr,b(e.name),1),S(`span`,{class:u([`px-1 py-0.5 text-xs rounded`,e.floodPolicy===`allow`?`bg-accent-green/20 text-accent-green`:`bg-accent-red/20 text-accent-red`])},b(e.floodPolicy),3)]))),128)),c.value.length>10?(w(),C(`div`,br,` ...and `+b(c.value.length-10)+` more `,1)):g(``,!0)])])])])):g(``,!0),c.value.length>0&&l.value.length>0?(w(),C(`div`,xr,[n[13]||=S(`h4`,{class:`text-content-primary dark:text-content-primary font-medium text-sm mb-3`},` Move children to another region: `,-1),S(`div`,Sr,[S(`div`,Cr,[n[10]||=S(`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`},[S(`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),m(S(`input`,{"onUpdate:modelValue":n[0]||=e=>o.value=e,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),[[F,o.value]])])]),S(`div`,wr,[d.value.length===0?(w(),C(`div`,Tr,b(o.value?`No regions match your search`:`No available regions`),1)):g(``,!0),(w(!0),C(x,null,i(d.value,e=>(w(),C(`label`,{key:e.id,class:`flex items-center gap-3 p-2 rounded cursor-pointer hover:bg-stroke-subtle dark:hover:bg-white/10 transition-colors group`},[S(`div`,Er,[m(S(`input`,{type:`radio`,value:e.id,"onUpdate:modelValue":n[1]||=e=>a.value=e,class:`sr-only peer`},null,8,Dr),[[B,a.value]]),n[11]||=S(`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`},[S(`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)]),S(`div`,Or,[n[12]||=S(`svg`,{class:`w-4 h-4 text-secondary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M7 20l4-16m2 16l4-16M6 9h14M4 15h14`})],-1),S(`span`,kr,b(e.name),1),e.children.length>0?(w(),C(`span`,Ar,b(e.children.length),1)):g(``,!0)])]))),128))])])):g(``,!0),S(`div`,jr,[S(`button`,{onClick:h,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 `),c.value.length>0&&a.value?(w(),C(`button`,{key:0,onClick:p,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 `)):g(``,!0),S(`button`,{onClick:f,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(c.value.length>0?`Delete All`:`Delete`),1)])])])):g(``,!0)}}),Nr={class:`space-y-4 sm:space-y-6`},Pr={class:`flex flex-col sm:flex-row sm:justify-between sm:items-start gap-3`},Fr={class:`flex gap-2 flex-wrap`},Ir=[`disabled`],Lr=[`disabled`],Rr={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`},zr={class:`flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3`},Br={class:`flex items-center gap-2 sm:gap-3`},Vr={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`},Hr={class:`glass-card rounded-[15px] p-3 sm:p-6 border border-stroke-subtle dark:border-stroke/10`},Ur={key:0,class:`flex items-center justify-center py-8`},Wr={key:1,class:`text-center py-8`},Gr={class:`text-content-secondary dark:text-content-muted text-sm`},Kr={key:2,class:`text-center py-8`},qr={key:3,class:`space-y-2`},Jr=f({name:`TransportKeys`,__name:`TransportKeys`,setup(e){let t=Yt(),n=j(),r=E(!1),o=E(!1),c=E(!1),d=E(null),f=E(null),p=E(`deny`);h(v(()=>n.stats?.config?.mesh?.unscoped_flood_allow??null),e=>{e!==null&&(p.value=e?`allow`:`deny`)},{immediate:!0});let m=E([]),g=E(!1),T=E(null),D=e=>{let t=new Map,n=[];return e.forEach(e=>{let n={id:e.id,name:e.name,floodPolicy:e.flood_policy,transport_key:e.transport_key,last_used:e.last_used?new Date(e.last_used*1e3):void 0,parent_id:e.parent_id,children:[]};t.set(e.id,n)}),t.forEach(e=>{e.parent_id&&t.has(e.parent_id)?t.get(e.parent_id).children.push(e):n.push(e)}),n},O=async()=>{try{g.value=!0,T.value=null;let e=await A.getTransportKeys();e.success&&e.data?m.value=D(e.data):T.value=e.error||`Failed to load transport keys`}catch(e){T.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error loading transport keys:`,e)}finally{g.value=!1}};s(()=>{O()});function k(e,t){for(let n of e){if(n.id===t)return n;if(n.children){let e=k(n.children,t);if(e)return e}}return null}function M(){let e=t.selectedNodeId.value;if(e)return k(m.value,e)?.name}function N(e){t.setSelectedNode(e)}function P(){r.value=!0}function F(){if(t.selectedNodeId.value){let e=k(m.value,t.selectedNodeId.value);e&&(f.value=e,c.value=!0)}}function I(){if(t.selectedNodeId.value){let e=k(m.value,t.selectedNodeId.value);e&&(d.value=e,o.value=!0)}}let L=async e=>{try{let t=await A.createTransportKey(e.name,e.floodPolicy,void 0,e.parentId,void 0);t.success?await O():(console.error(`Failed to add transport key:`,t.error),T.value=t.error||`Failed to add transport key`)}catch(e){console.error(`Error adding transport key:`,e),T.value=e instanceof Error?e.message:`Unknown error occurred`}finally{r.value=!1}};function R(){r.value=!1}async function z(e){try{let t=e===`allow`,r=await A.updateUnscopedFloodPolicy(t);r.success?(p.value=e,await n.fetchStats()):(console.error(`Failed to update unscoped flood policy:`,r.error),T.value=r.error||`Failed to update unscoped flood policy`)}catch(e){console.error(`Error updating unscoped flood policy:`,e),T.value=e instanceof Error?e.message:`Failed to update unscoped flood policy`}}function B(){o.value=!1,d.value=null}async function V(e){try{let t=await A.updateTransportKey(e.id,e.name,e.floodPolicy);t.success?await O():(console.error(`Failed to update transport key:`,t.error),T.value=t.error||`Failed to update transport key`)}catch(e){console.error(`Error updating transport key:`,e),T.value=e instanceof Error?e.message:`Unknown error occurred`}finally{B()}}function ee(e){o.value=!1,d.value=null,f.value=e,c.value=!0}function H(){c.value=!1,f.value=null}async function U(e){try{let n=await A.deleteTransportKey(e);n.success?(await O(),t.setSelectedNode(null)):(console.error(`Failed to delete transport key:`,n.error),T.value=n.error||`Failed to delete transport key`)}catch(e){console.error(`Error deleting transport key:`,e),T.value=e instanceof Error?e.message:`Unknown error occurred`}finally{H()}}async function W(e){try{let n=await A.deleteTransportKey(e.nodeId);n.success?(await O(),t.setSelectedNode(null)):(console.error(`Failed to delete transport key:`,n.error),T.value=n.error||`Failed to delete transport key`)}catch(e){console.error(`Error deleting transport key:`,e),T.value=e instanceof Error?e.message:`Unknown error occurred`}finally{H()}}return(e,n)=>(w(),C(`div`,Nr,[S(`div`,Pr,[n[3]||=S(`div`,null,[S(`h3`,{class:`text-base sm:text-lg font-semibold text-content-primary dark:text-content-primary mb-1 sm:mb-2`},` Regions/Keys `),S(`p`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},` Manage regional key hierarchy `)],-1),S(`div`,Fr,[S(`button`,{onClick:P,class:`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 bg-accent-green/10 hover:bg-accent-green/20 text-accent-green border-accent-green/30`},[...n[2]||=[S(`svg`,{class:`w-3.5 h-3.5 sm:w-4 sm:h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 4v16m8-8H4`})],-1),y(` Add `,-1)]]),S(`button`,{onClick:I,disabled:!a(t).selectedNodeId.value,class:u([`px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg border transition-colors text-xs sm:text-sm`,a(t).selectedNodeId.value?`bg-accent-green/20 hover:bg-accent-green/30 text-accent-green border-accent-green/50`:`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`])},` Edit `,10,Ir),S(`button`,{onClick:F,disabled:!a(t).selectedNodeId.value,class:u([`px-2.5 sm:px-4 py-1.5 sm:py-2 rounded-lg border transition-colors text-xs sm:text-sm`,a(t).selectedNodeId.value?`bg-accent-red/20 hover:bg-accent-red/30 text-accent-red border-accent-red/50`:`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`])},` Delete `,10,Lr)])]),S(`div`,Rr,[S(`div`,zr,[n[4]||=S(`div`,null,[S(`h4`,{class:`text-xs sm:text-sm font-medium text-content-primary dark:text-content-primary mb-1`},` Unscoped Flood Policy (*) `),S(`p`,{class:`text-content-secondary dark:text-content-muted text-[10px] sm:text-xs`},` Allow or Deny unscoped flood packets `)],-1),S(`div`,Br,[S(`div`,Vr,[S(`button`,{onClick:n[0]||=e=>z(`deny`),class:u([`px-2 sm:px-3 py-1 text-[10px] sm:text-xs font-medium rounded transition-colors`,p.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),S(`button`,{onClick:n[1]||=e=>z(`allow`),class:u([`px-2 sm:px-3 py-1 text-[10px] sm:text-xs font-medium rounded transition-colors`,p.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)])])])]),S(`div`,Hr,[g.value?(w(),C(`div`,Ur,[...n[5]||=[S(`div`,{class:`animate-spin rounded-full h-8 w-8 border-b-2 border-accent-green`},null,-1),S(`span`,{class:`ml-2 text-content-secondary dark:text-content-muted`},`Loading transport keys...`,-1)]])):T.value?(w(),C(`div`,Wr,[n[6]||=S(`div`,{class:`text-accent-red mb-2`},`⚠️ Error loading transport keys`,-1),S(`div`,Gr,b(T.value),1),S(`button`,{onClick:O,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 `)])):m.value.length===0?(w(),C(`div`,Kr,[...n[7]||=[S(`div`,{class:`text-content-muted dark:text-content-muted mb-2`},` 📝 No transport keys found `,-1),S(`div`,{class:`text-content-muted dark:text-content-muted/60 text-sm`},` Add your first transport key to get started `,-1)]])):(w(),C(`div`,qr,[(w(!0),C(x,null,i(m.value,e=>(w(),l(mn,{key:e.id,node:e,"selected-node-id":a(t).selectedNodeId.value,level:0,onSelect:N},null,8,[`node`,`selected-node-id`]))),128))]))]),_(Fn,{show:r.value,"selected-node-name":M(),"selected-node-id":a(t).selectedNodeId.value||void 0,onClose:R,onAdd:L},null,8,[`show`,`selected-node-name`,`selected-node-id`]),_(cr,{show:o.value,node:d.value,onClose:B,onSave:V,onRequestDelete:ee},null,8,[`show`,`node`]),_(Mr,{show:c.value,node:f.value,"all-nodes":m.value,onClose:H,onDeleteAll:U,onMoveChildren:W},null,8,[`show`,`node`,`all-nodes`])]))}}),Yr={class:`space-y-4 sm:space-y-6`},Xr={class:`flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3`},Zr={key:0,class:`bg-red-500/10 border border-red-500/30 rounded-lg p-4`},Qr={class:`flex items-center gap-2 text-red-600 dark:text-red-400`},$r={key:1,class:`flex items-center justify-center py-12`},ei={key:2,class:`space-y-3`},ti={class:`flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3`},ni={class:`flex-1`},ri={class:`flex items-center gap-2 sm:gap-3`},ii={class:`min-w-0 flex-1`},ai={class:`text-content-primary dark:text-content-primary font-medium text-sm sm:text-base break-all`},oi={class:`flex flex-col sm:flex-row sm:items-center sm:gap-4 mt-1 text-xs text-content-secondary dark:text-content-muted`},si={class:`truncate`},ci={class:`truncate`},li=[`onClick`,`disabled`],ui={key:3,class:`text-center py-12`},di={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`},fi={class:`space-y-4`},pi={class:`flex justify-end gap-3 mt-6`},mi=[`disabled`],hi=[`disabled`],gi={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`},_i={class:`space-y-4`},vi={class:`flex gap-2`},yi=[`value`],bi={class:`bg-blue-500/10 border border-blue-500/30 rounded-lg p-4`},xi={class:`block bg-blue-500/20 px-3 py-2 rounded text-xs text-blue-100 font-mono overflow-x-auto`},Si=f({name:`APITokens`,__name:`APITokens`,setup(e){let t=E([]),n=E(!1),r=E(null),a=E(!1),o=E(``),c=E(null),l=E(!1),u=E(!1),f=E(null),p=async()=>{n.value=!0,r.value=null;try{let e=await A.get(`/auth/tokens`);t.value=(e.data||e).tokens||[]}catch(e){console.error(`Failed to fetch API tokens:`,e),r.value=e instanceof Error?e.message:`Failed to fetch tokens`}finally{n.value=!1}},h=async()=>{if(!o.value.trim()){r.value=`Token name is required`;return}n.value=!0,r.value=null;try{let e=await A.post(`/auth/tokens`,{name:o.value.trim()});c.value=(e.data||e).token||null,a.value=!1,l.value=!0,o.value=``,await p()}catch(e){console.error(`Failed to create API token:`,e),r.value=e instanceof Error?e.message:`Failed to create token`}finally{n.value=!1}},T=(e,t)=>{f.value={id:e,name:t},u.value=!0},D=async()=>{if(f.value){n.value=!0,r.value=null;try{await A.delete(`/auth/tokens/${f.value.id}`),await p(),u.value=!1,f.value=null}catch(e){console.error(`Failed to revoke API token:`,e),r.value=e instanceof Error?e.message:`Failed to revoke token`}finally{n.value=!1}}},O=()=>{a.value=!1,o.value=``,r.value=null},k=()=>{l.value=!1,c.value=null},j=()=>{c.value&&navigator.clipboard.writeText(c.value)},M=e=>e?new Date(e*1e3).toLocaleString():`Never`,N=v(()=>`${window.location.origin}/api/stats`);return s(()=>{p()}),(e,s)=>(w(),C(x,null,[S(`div`,Yr,[S(`div`,Xr,[s[5]||=S(`div`,null,[S(`h2`,{class:`text-lg sm:text-xl font-semibold text-content-primary dark:text-content-primary`},` API Tokens `),S(`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),S(`button`,{onClick:s[0]||=e=>a.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`},[...s[4]||=[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 4v16m8-8H4`})],-1),y(` Create Token `,-1)]])]),s[20]||=d(`

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),r.value?(w(),C(`div`,Zr,[S(`div`,Qr,[s[6]||=S(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),y(` `+b(r.value),1)])])):g(``,!0),n.value&&t.value.length===0?(w(),C(`div`,$r,[...s[7]||=[S(`div`,{class:`text-center`},[S(`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`}),S(`div`,{class:`text-content-secondary dark:text-content-muted`},`Loading tokens...`)],-1)]])):t.value.length>0?(w(),C(`div`,ei,[(w(!0),C(x,null,i(t.value,e=>(w(),C(`div`,{key:e.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`},[S(`div`,ti,[S(`div`,ni,[S(`div`,ri,[s[8]||=S(`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`},[S(`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),S(`div`,ii,[S(`h3`,ai,b(e.name),1),S(`div`,oi,[S(`span`,si,`Created: `+b(M(e.created_at)),1),S(`span`,ci,`Last used: `+b(M(e.last_used)),1)])])])]),S(`button`,{onClick:t=>T(e.id,e.name),disabled:n.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,li)])]))),128))])):(w(),C(`div`,ui,[s[9]||=S(`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`},[S(`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),s[10]||=S(`h3`,{class:`text-content-primary dark:text-content-primary font-medium mb-2`},`No API Tokens`,-1),s[11]||=S(`p`,{class:`text-content-secondary dark:text-content-muted text-sm mb-4`},` Create a token to enable API access `,-1),S(`button`,{onClick:s[1]||=e=>a.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 `)])),a.value?(w(),C(`div`,{key:4,class:`fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm`,onClick:I(O,[`self`])},[S(`div`,di,[s[14]||=S(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary mb-4`},` Create API Token `,-1),S(`div`,fi,[S(`div`,null,[s[12]||=S(`label`,{class:`block text-sm font-medium text-content-secondary dark:text-content-muted mb-2`},`Token Name`,-1),m(S(`input`,{"onUpdate:modelValue":s[2]||=e=>o.value=e,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:R(h,[`enter`])},null,544),[[F,o.value]]),s[13]||=S(`p`,{class:`text-xs text-content-muted dark:text-content-muted mt-1`},` Give your token a descriptive name to identify its purpose `,-1)]),S(`div`,pi,[S(`button`,{onClick:O,disabled:n.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,mi),S(`button`,{onClick:h,disabled:n.value||!o.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(n.value?`Creating...`:`Create Token`),9,hi)])])])])):g(``,!0),l.value&&c.value?(w(),C(`div`,{key:5,class:`fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm`,onClick:I(k,[`self`])},[S(`div`,gi,[s[19]||=S(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary mb-4`},` Token Created Successfully `,-1),S(`div`,_i,[s[18]||=d(`
Save this token now! For security reasons, it will not be shown again.
`,1),S(`div`,null,[s[16]||=S(`label`,{class:`block text-sm font-medium text-content-secondary dark:text-content-muted mb-2`},`Your API Token`,-1),S(`div`,vi,[S(`input`,{value:c.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,yi),S(`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 flex items-center gap-2`,title:`Copy to clipboard`},[...s[15]||=[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),y(` Copy `,-1)]])])]),S(`div`,bi,[s[17]||=S(`p`,{class:`text-sm text-blue-200 mb-2`},[S(`strong`,null,`Usage Example:`)],-1),S(`code`,xi,` curl -H "X-API-Key: `+b(c.value)+`" `+b(N.value),1)]),S(`div`,{class:`flex justify-end mt-6`},[S(`button`,{onClick:k,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 `)])])])])):g(``,!0)]),_(V,{show:u.value,title:`Revoke API Token`,message:`Are you sure you want to revoke the token '${f.value?.name}'? This action cannot be undone.`,"confirm-text":`Revoke`,"cancel-text":`Cancel`,variant:`danger`,onConfirm:D,onClose:s[3]||=e=>u.value=!1},null,8,[`show`,`message`])],64))}}),Ci={class:`space-y-6`},wi={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},Ti={class:`space-y-4`},Ei={class:`flex items-center justify-between`},Di=[`disabled`],Oi={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},ki={class:`space-y-4`},Ai={class:`space-y-3`},ji=[`checked`,`disabled`],Mi=[`checked`,`disabled`],Ni={class:`flex items-start gap-3`},Pi={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`},Fi={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`},Ii={class:`flex-1`},Li={class:`text-sm font-medium text-content-primary dark:text-content-primary`},Ri={key:0,class:`text-xs text-green-600 dark:text-green-400 mt-1`},zi={key:1,class:`p-4 bg-amber-500/10 border border-amber-500/30 rounded-lg`},Bi={class:`flex items-start justify-between gap-3`},Vi=[`disabled`],Hi={key:0,class:`animate-spin h-4 w-4`,fill:`none`,viewBox:`0 0 24 24`},Ui={key:1,class:`w-4 h-4`,fill:`none`,viewBox:`0 0 24 24`,stroke:`currentColor`},Wi={class:`flex items-center space-x-2`},Gi={key:0,class:`w-5 h-5 text-green-600 dark:text-green-400`,fill:`none`,viewBox:`0 0 24 24`,stroke:`currentColor`},Ki={key:1,class:`w-5 h-5 text-red-600 dark:text-red-400`,fill:`none`,viewBox:`0 0 24 24`,stroke:`currentColor`},qi=f({name:`WebSettings`,__name:`WebSettings`,setup(e){let t=E(!1),n=E(``),r=E(!1),i=E(!1),a=E(!1),c=E(!1),l=E(!0),f=o({cors_enabled:!1,use_default_frontend:!0}),p=v(()=>r.value?`bg-green-500/10 border-green-600/40 dark:border-green-500/30`:`bg-red-500/10 border-red-500/30`);async function m(){try{l.value=!0;let e=await A.get(`/check_pymc_console`);e.success&&e.data&&(c.value=e.data.exists,console.log(`PyMC Console exists:`,c.value))}catch(e){console.error(`Failed to check PyMC Console:`,e),c.value=!1}finally{l.value=!1}}async function h(){try{let e=await A.get(`/stats`);console.log(`WebSettings: Full response:`,e);let t=null;if(e.success&&e.data?t=e.data:e&&`version`in e&&(t=e),t){let e=t.config?.web||{};console.log(`WebSettings: webConfig:`,e),f.cors_enabled=e.cors_enabled===!0,console.log(`WebSettings: Set cors_enabled to:`,f.cors_enabled);let n=e.web_path;f.use_default_frontend=!n||n===``,console.log(`WebSettings: Set use_default_frontend to:`,f.use_default_frontend,`from web_path:`,n)}}catch(e){console.error(`Failed to load web settings:`,e),k(`Failed to load settings`,!1)}}async function _(){t.value=!0,n.value=``;try{let e={web:{cors_enabled:f.cors_enabled}};f.use_default_frontend?e.web.web_path=null:e.web.web_path=`/opt/pymc_console/web/html`;let t=await A.post(`/update_web_config`,e);t.success?(k(`Settings saved successfully`,!0),i.value=!0):k(t.error||`Failed to save settings`,!1)}catch(e){console.error(`Failed to save web settings:`,e),k(e.message||`Failed to save settings`,!1)}finally{t.value=!1}}async function T(){f.cors_enabled=!f.cors_enabled,await _()}async function D(){f.use_default_frontend=!0,await _()}async function O(){f.use_default_frontend=!1,await _()}function k(e,t){n.value=e,r.value=t,setTimeout(()=>{n.value=``},5e3)}async function j(){a.value=!0,n.value=``;try{let e=await A.post(`/restart_service`,{});e.success?(k(`Service restart initiated. Page will reload...`,!0),i.value=!1,setTimeout(()=>{window.location.reload()},2e3)):k(e.error||`Failed to restart service`,!1)}catch(e){e.code===`ERR_NETWORK`||e.message?.includes(`Network error`)?(k(`Service restarting... Page will reload`,!0),i.value=!1,setTimeout(()=>{window.location.reload()},3e3)):(console.error(`Failed to restart service:`,e),k(e.message||`Failed to restart service`,!1))}finally{a.value=!1}}return s(()=>{h(),m()}),(e,o)=>(w(),C(`div`,Ci,[S(`div`,wi,[o[1]||=S(`div`,{class:`flex items-start justify-between mb-4`},[S(`div`,null,[S(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-1`},` CORS Settings `),S(`p`,{class:`text-sm text-content-secondary dark:text-content-muted`},` Control cross-origin resource sharing for API access `)])],-1),S(`div`,Ti,[S(`div`,Ei,[o[0]||=S(`div`,null,[S(`label`,{class:`text-sm font-medium text-content-primary dark:text-content-primary`},`Enable CORS`),S(`p`,{class:`text-xs text-content-secondary dark:text-content-muted mt-1`},` Allow web frontends from different origins to access the API `)],-1),S(`button`,{onClick:T,disabled:t.value,class:u([`relative inline-flex h-6 w-11 items-center rounded-full transition-colors border-2`,f.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`,t.value?`opacity-50 cursor-not-allowed`:`cursor-pointer`])},[S(`span`,{class:u([`inline-block h-4 w-4 transform rounded-full bg-white transition-transform shadow-lg`,f.cors_enabled?`translate-x-5`:`translate-x-0.5`])},null,2)],10,Di)])])]),S(`div`,Oi,[o[11]||=S(`div`,{class:`flex items-start justify-between mb-4`},[S(`div`,null,[S(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-1`},` Web Frontend `),S(`p`,{class:`text-sm text-content-secondary dark:text-content-muted`},` Choose which web interface to use `)])],-1),S(`div`,ki,[S(`div`,Ai,[S(`label`,{class:u([`flex items-start space-x-3 p-4 bg-background-mute dark:bg-background/30 rounded-lg border-2 cursor-pointer transition-all`,f.use_default_frontend?`border-accent-cyan bg-accent-cyan/10`:`border-stroke-subtle dark:border-stroke/10 hover:border-accent-cyan/50`])},[S(`input`,{type:`radio`,name:`frontend`,checked:f.use_default_frontend,onChange:D,disabled:t.value,class:`mt-1 h-4 w-4 text-accent-cyan focus:ring-accent-cyan focus:ring-offset-background`},null,40,ji),o[2]||=S(`div`,{class:`flex-1`},[S(`div`,{class:`text-sm font-medium text-content-primary dark:text-content-primary`},` Default Frontend `),S(`div`,{class:`text-xs text-content-secondary dark:text-content-muted mt-1`},` Built-in pyMC Repeater web interface `),S(`div`,{class:`text-xs text-content-muted dark:text-content-muted/60 mt-1 font-mono`},` Built-in `)],-1)],2),S(`label`,{class:u([`flex items-start space-x-3 p-4 bg-background-mute dark:bg-background/30 rounded-lg border-2 cursor-pointer transition-all`,f.use_default_frontend?`border-stroke-subtle dark:border-stroke/10 hover:border-accent-cyan/50`:`border-accent-cyan bg-accent-cyan/10`])},[S(`input`,{type:`radio`,name:`frontend`,checked:!f.use_default_frontend,onChange:O,disabled:t.value,class:`mt-1 h-4 w-4 text-accent-cyan focus:ring-accent-cyan focus:ring-offset-background`},null,40,Mi),o[3]||=d(`
PyMC Console
@Treehouse⚡
Alternative web interface for pyMC Repeater
/opt/pymc_console/web/html
`,1)],2)]),l.value?g(``,!0):(w(),C(`div`,{key:0,class:u([`p-4 rounded-lg border`,c.value?`bg-green-500/5 border-green-500/20`:`bg-accent-cyan/5 border-accent-cyan/20`])},[S(`div`,Ni,[c.value?(w(),C(`svg`,Pi,[...o[4]||=[S(`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)]])):(w(),C(`svg`,Fi,[...o[5]||=[S(`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)]])),S(`div`,Ii,[S(`h4`,Li,b(c.value?`PyMC Console has been detected`:`PyMC Console Not Installed`),1),c.value?(w(),C(`p`,Ri,[...o[6]||=[y(` PyMC Console is installed at `,-1),S(`code`,{class:`text-green-700 dark:text-green-300`},`/opt/pymc_console/web/html`,-1)]])):(w(),C(x,{key:1},[o[7]||=d(`

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

PyMC Console Install Instructions `,2)],64))])])],2)),i.value?(w(),C(`div`,zi,[S(`div`,Bi,[o[10]||=d(`

Service restart required

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

`,1),S(`button`,{onClick:j,disabled:a.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`},[a.value?(w(),C(`svg`,Hi,[...o[8]||=[S(`circle`,{class:`opacity-25`,cx:`12`,cy:`12`,r:`10`,stroke:`currentColor`,"stroke-width":`4`},null,-1),S(`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)]])):(w(),C(`svg`,Ui,[...o[9]||=[S(`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)]])),y(` `+b(a.value?`Restarting...`:`Restart Now`),1)],8,Vi)])])):g(``,!0)])]),n.value?(w(),C(`div`,{key:0,class:u([`p-4 rounded-lg border`,p.value])},[S(`div`,Wi,[r.value?(w(),C(`svg`,Gi,[...o[12]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):(w(),C(`svg`,Ki,[...o[13]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])),S(`span`,{class:u(r.value?`text-green-600 dark:text-green-400`:`text-red-600 dark:text-red-400`)},b(n.value),3)])],2)):g(``,!0)]))}}),Ji={class:`space-y-4`},Yi={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`},Xi={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`},Zi={class:`flex justify-between items-center`},Qi={class:`flex gap-2`},$i=[`disabled`],ea={class:`flex gap-2`},ta=[`disabled`],na=[`disabled`],ra={class:`bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3`},ia={key:0,class:`flex items-center justify-center py-4`},aa={key:1,class:`text-center py-4`},oa={class:`grid grid-cols-2 sm:grid-cols-4 gap-3`},sa={class:`text-center p-2 bg-white dark:bg-white/5 rounded-lg`},ca={class:`text-center p-2 bg-white dark:bg-white/5 rounded-lg`},la={class:`text-lg font-mono text-content-primary dark:text-content-primary`},ua={class:`text-center p-2 bg-white dark:bg-white/5 rounded-lg`},da={class:`text-lg font-mono text-green-600 dark:text-green-400`},fa={class:`text-center p-2 bg-white dark:bg-white/5 rounded-lg`},pa={class:`text-lg font-mono text-red-600 dark:text-red-400`},ma={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`},ha={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`},ga={class:`font-medium`},_a={class:`font-mono text-[10px] opacity-70`},va={class:`text-[10px]`},ya={class:`bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3`},ba={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`},xa={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Sa={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`},Ca={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},wa={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`},Ta={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Ea={key:1,class:`flex items-center gap-2`},Da={class:`flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 gap-1`},Oa={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},ka={key:1,class:`flex items-center gap-2`},Aa={class:`bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3`},ja={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`},Ma={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Na={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`},Pa={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Fa={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`},La={key:1,class:`flex items-center gap-2`},Ra={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`},za={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Ba={key:1,class:`flex items-center gap-2`},Va={class:`flex flex-col sm:flex-row sm:justify-between sm:items-center py-2 gap-1`},Ha={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Ua={key:1,class:`flex items-center gap-2`},Wa={class:`bg-background-mute dark:bg-white/5 rounded-lg p-3 sm:p-4 space-y-3`},Ga={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`},Ka={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},qa={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`},Ja={key:0,class:`text-content-primary dark:text-content-primary font-mono text-sm`},Ya={key:1,class:`flex items-center gap-2`},Xa={class:`py-2`},Za={class:`grid grid-cols-3 gap-2 mt-2`},Qa={class:`text-center p-2 bg-white dark:bg-white/5 rounded-lg`},$a={key:0,class:`font-mono text-sm text-content-primary dark:text-content-primary`},eo={class:`text-center p-2 bg-white dark:bg-white/5 rounded-lg`},to={key:0,class:`font-mono text-sm text-content-primary dark:text-content-primary`},no={class:`text-center p-2 bg-white dark:bg-white/5 rounded-lg`},ro={key:0,class:`font-mono text-sm text-content-primary dark:text-content-primary`},io={class:`p-6 space-y-4`},ao={class:`flex justify-between items-start`},oo={class:`flex justify-end pt-4 border-t border-stroke-subtle dark:border-stroke/20`},so=f({__name:`AdvertSettings`,setup(e){let t=j(),n=v(()=>t.stats?.config?.repeater||{}),r=v(()=>n.value.advert_rate_limit||{}),a=v(()=>n.value.advert_penalty_box||{}),o=v(()=>n.value.advert_adaptive||{}),l=v(()=>o.value.thresholds||{}),f=E(!1),p=E(!1),_=E(``),T=E(``),D=E(!1),O=E(!1),A=E(null),M=E(!0),N=E(2),L=E(1),R=E(10),z=E(60),B=E(!0),V=E(2),ee=E(12),H=E(6),U=E(2),W=E(24),G=E(!0),te=E(.1),K=E(5),ne=E(.05),q=E(.2),J=E(.5),Y=async()=>{O.value=!0;try{let e=await k.get(`/api/advert_rate_limit_stats`);e.data?.success&&(A.value=e.data.data)}catch(e){console.error(`Failed to fetch rate limit stats:`,e)}finally{O.value=!1}};h([r,a,o],()=>{f.value||(M.value=r.value.enabled??!1,N.value=r.value.bucket_capacity??2,L.value=r.value.refill_tokens??1,R.value=Math.round((r.value.refill_interval_seconds??36e3)/3600),z.value=Math.round((r.value.min_interval_seconds??0)/60),B.value=a.value.enabled??!1,V.value=a.value.violation_threshold??2,ee.value=Math.round((a.value.violation_decay_seconds??43200)/3600),H.value=Math.round((a.value.base_penalty_seconds??21600)/3600),U.value=a.value.penalty_multiplier??2,W.value=Math.round((a.value.max_penalty_seconds??86400)/3600),G.value=o.value.enabled??!1,te.value=o.value.ewma_alpha??.1,K.value=Math.round((o.value.hysteresis_seconds??300)/60),ne.value=l.value.quiet_max??.05,q.value=l.value.normal_max??.2,J.value=l.value.busy_max??.5)},{immediate:!0}),s(()=>{Y()});let X=()=>{M.value=r.value.enabled??!1,N.value=r.value.bucket_capacity??2,L.value=r.value.refill_tokens??1,R.value=Math.round((r.value.refill_interval_seconds??36e3)/3600),z.value=Math.round((r.value.min_interval_seconds??0)/60),B.value=a.value.enabled??!1,V.value=a.value.violation_threshold??2,ee.value=Math.round((a.value.violation_decay_seconds??43200)/3600),H.value=Math.round((a.value.base_penalty_seconds??21600)/3600),U.value=a.value.penalty_multiplier??2,W.value=Math.round((a.value.max_penalty_seconds??86400)/3600),G.value=o.value.enabled??!1,te.value=o.value.ewma_alpha??.1,K.value=Math.round((o.value.hysteresis_seconds??300)/60),ne.value=l.value.quiet_max??.05,q.value=l.value.normal_max??.2,J.value=l.value.busy_max??.5},Z=()=>{f.value=!0,_.value=``,T.value=``},Q=()=>{f.value=!1,_.value=``,T.value=``,X()},re=async()=>{p.value=!0,T.value=``,_.value=``;try{let e={rate_limit_enabled:M.value,bucket_capacity:N.value,refill_tokens:L.value,refill_interval_seconds:R.value*3600,min_interval_seconds:z.value*60,penalty_enabled:B.value,violation_threshold:V.value,violation_decay_seconds:ee.value*3600,base_penalty_seconds:H.value*3600,penalty_multiplier:U.value,max_penalty_seconds:W.value*3600,adaptive_enabled:G.value,ewma_alpha:te.value,hysteresis_seconds:K.value*60,quiet_max:ne.value,normal_max:q.value,busy_max:J.value},n=(await k.post(`/api/update_advert_rate_limit_config`,e)).data;n.success?(_.value=n.data?.message||`Settings saved successfully`,await t.fetchStats(),await Y(),await c(),X(),f.value=!1,setTimeout(()=>{_.value=``},3e3)):(T.value=n.error||`Failed to save settings`,console.error(`[AdvertSettings] Save failed:`,n.error))}catch(e){console.error(`Failed to save advert settings:`,e),T.value=e.response?.data?.error||`Failed to save settings`}finally{p.value=!1}},$=v(()=>A.value?.adaptive?.current_tier||`unknown`),ie=v(()=>{switch($.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(e,t)=>(w(),C(`div`,Ji,[_.value?(w(),C(`div`,Yi,b(_.value),1)):g(``,!0),T.value?(w(),C(`div`,Xi,b(T.value),1)):g(``,!0),S(`div`,Zi,[S(`div`,Qi,[S(`button`,{onClick:Y,disabled:O.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`},b(O.value?`Loading...`:`Refresh Stats`),9,$i),S(`button`,{onClick:t[0]||=e=>D.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`},[...t[19]||=[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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)]])]),S(`div`,ea,[f.value?(w(),C(x,{key:1},[S(`button`,{onClick:Q,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,ta),S(`button`,{onClick:re,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`},b(p.value?`Saving...`:`Save Changes`),9,na)],64)):(w(),C(`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 `))])]),S(`div`,ra,[t[28]||=S(`h3`,{class:`text-sm font-medium text-content-primary dark:text-content-primary`},` Current Status `,-1),O.value&&!A.value?(w(),C(`div`,ia,[...t[20]||=[S(`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),S(`span`,{class:`ml-2 text-sm text-content-muted`},`Loading stats...`,-1)]])):A.value?(w(),C(x,{key:2},[S(`div`,oa,[S(`div`,sa,[t[22]||=S(`div`,{class:`text-xs text-content-muted dark:text-content-muted`},`Mesh Tier`,-1),S(`div`,{class:u([`mt-1 px-2 py-0.5 rounded border text-xs font-medium inline-block`,ie.value])},b($.value.toUpperCase()),3)]),S(`div`,ca,[t[23]||=S(`div`,{class:`text-xs text-content-muted dark:text-content-muted`},`Adverts/min`,-1),S(`div`,la,b(A.value.metrics?.adverts_per_min_ewma?.toFixed(2)||`0.00`),1)]),S(`div`,ua,[t[24]||=S(`div`,{class:`text-xs text-content-muted dark:text-content-muted`},`Allowed`,-1),S(`div`,da,b(A.value.stats?.adverts_allowed||0),1)]),S(`div`,fa,[t[25]||=S(`div`,{class:`text-xs text-content-muted dark:text-content-muted`},`Dropped`,-1),S(`div`,pa,b(A.value.stats?.adverts_dropped||0),1)])]),Object.keys(A.value.active_penalties||{}).length>0?(w(),C(`div`,ma,[t[26]||=S(`div`,{class:`text-xs font-medium text-red-700 dark:text-red-400 mb-1`},` Active Penalties `,-1),(w(!0),C(x,null,i(A.value.active_penalties,(e,t)=>(w(),C(`div`,{key:t,class:`text-xs font-mono text-red-600 dark:text-red-400`},b(t)+`... - `+b(Math.round(e))+`s remaining `,1))),128))])):g(``,!0),A.value.recent_drops&&A.value.recent_drops.length>0?(w(),C(`div`,ha,[t[27]||=S(`div`,{class:`text-xs font-medium text-orange-700 dark:text-orange-400 mb-1`},` Recently Dropped Adverts `,-1),(w(!0),C(x,null,i(A.value.recent_drops,(e,t)=>(w(),C(`div`,{key:t,class:`text-xs text-orange-600 dark:text-orange-400 py-0.5`},[S(`span`,ga,b(e.name),1),S(`span`,_a,`(`+b(e.pubkey)+`...)`,1),S(`span`,va,` - `+b(e.reason)+` (`+b(e.seconds_ago)+`s ago)`,1)]))),128))])):g(``,!0)],64)):(w(),C(`div`,aa,[...t[21]||=[S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},` Stats not available. Click "Refresh Stats" to load. `,-1)]]))]),S(`div`,ya,[t[36]||=S(`h3`,{class:`text-sm font-medium text-content-primary dark:text-content-primary flex items-center gap-2`},[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`})]),y(` Token Bucket Rate Limiting `)],-1),t[37]||=S(`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),S(`div`,ba,[t[30]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Rate Limiting`,-1),f.value?m((w(),C(`select`,{key:1,"onUpdate:modelValue":t[1]||=e=>M.value=e,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[29]||=[S(`option`,{value:!0},`Enabled`,-1),S(`option`,{value:!1},`Disabled`,-1)]],512)),[[P,M.value]]):(w(),C(`div`,xa,b(M.value?`Enabled`:`Disabled`),1))]),S(`div`,Sa,[t[31]||=S(`div`,null,[S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Bucket Capacity`),S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},`Max burst size (adverts)`)],-1),f.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[2]||=e=>N.value=e,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)),[[F,N.value,void 0,{number:!0}]]):(w(),C(`div`,Ca,b(N.value),1))]),S(`div`,wa,[t[33]||=S(`div`,null,[S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Refill Interval`),S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},` Time between token refills `)],-1),f.value?(w(),C(`div`,Ea,[m(S(`input`,{"onUpdate:modelValue":t[3]||=e=>R.value=e,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),[[F,R.value,void 0,{number:!0}]]),t[32]||=S(`span`,{class:`text-content-muted text-sm`},`hours`,-1)])):(w(),C(`div`,Ta,b(R.value)+` hours `,1))]),S(`div`,Da,[t[35]||=S(`div`,null,[S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Minimum Interval`),S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},` Hard minimum between adverts `)],-1),f.value?(w(),C(`div`,ka,[m(S(`input`,{"onUpdate:modelValue":t[4]||=e=>z.value=e,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),[[F,z.value,void 0,{number:!0}]]),t[34]||=S(`span`,{class:`text-content-muted text-sm`},`min`,-1)])):(w(),C(`div`,Oa,b(z.value)+` min `,1))])]),S(`div`,Aa,[t[47]||=S(`h3`,{class:`text-sm font-medium text-content-primary dark:text-content-primary flex items-center gap-2`},[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`})]),y(` Penalty Box (Repeat Offenders) `)],-1),t[48]||=S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},` Applies escalating cooldowns to pubkeys that repeatedly violate limits. `,-1),S(`div`,ja,[t[39]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Penalty Box`,-1),f.value?m((w(),C(`select`,{key:1,"onUpdate:modelValue":t[5]||=e=>B.value=e,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[38]||=[S(`option`,{value:!0},`Enabled`,-1),S(`option`,{value:!1},`Disabled`,-1)]],512)),[[P,B.value]]):(w(),C(`div`,Ma,b(B.value?`Enabled`:`Disabled`),1))]),S(`div`,Na,[t[40]||=S(`div`,null,[S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Violation Threshold`),S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},` Violations before penalty `)],-1),f.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[6]||=e=>V.value=e,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)),[[F,V.value,void 0,{number:!0}]]):(w(),C(`div`,Pa,b(V.value),1))]),S(`div`,Fa,[t[42]||=S(`div`,null,[S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Base Penalty Duration`),S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},`First penalty duration`)],-1),f.value?(w(),C(`div`,La,[m(S(`input`,{"onUpdate:modelValue":t[7]||=e=>H.value=e,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),[[F,H.value,void 0,{number:!0}]]),t[41]||=S(`span`,{class:`text-content-muted text-sm`},`hours`,-1)])):(w(),C(`div`,Ia,b(H.value)+` hours `,1))]),S(`div`,Ra,[t[44]||=S(`div`,null,[S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Penalty Multiplier`),S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},`Escalation factor`)],-1),f.value?(w(),C(`div`,Ba,[m(S(`input`,{"onUpdate:modelValue":t[8]||=e=>U.value=e,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),[[F,U.value,void 0,{number:!0}]]),t[43]||=S(`span`,{class:`text-content-muted text-sm`},`x`,-1)])):(w(),C(`div`,za,b(U.value)+`x `,1))]),S(`div`,Va,[t[46]||=S(`div`,null,[S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Max Penalty Duration`),S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},`Maximum cooldown cap`)],-1),f.value?(w(),C(`div`,Ua,[m(S(`input`,{"onUpdate:modelValue":t[9]||=e=>W.value=e,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),[[F,W.value,void 0,{number:!0}]]),t[45]||=S(`span`,{class:`text-content-muted text-sm`},`hours`,-1)])):(w(),C(`div`,Ha,b(W.value)+` hours `,1))])]),S(`div`,Wa,[t[58]||=d(`

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),S(`div`,Ga,[t[50]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Adaptive Mode`,-1),f.value?m((w(),C(`select`,{key:1,"onUpdate:modelValue":t[10]||=e=>G.value=e,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[49]||=[S(`option`,{value:!0},`Enabled`,-1),S(`option`,{value:!1},`Disabled`,-1)]],512)),[[P,G.value]]):(w(),C(`div`,Ka,b(G.value?`Enabled`:`Disabled`),1))]),S(`div`,qa,[t[52]||=S(`div`,null,[S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Tier Change Delay`),S(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},`Prevents tier flapping`)],-1),f.value?(w(),C(`div`,Ya,[m(S(`input`,{"onUpdate:modelValue":t[11]||=e=>K.value=e,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),[[F,K.value,void 0,{number:!0}]]),t[51]||=S(`span`,{class:`text-content-muted text-sm`},`min`,-1)])):(w(),C(`div`,Ja,b(K.value)+` min `,1))]),S(`div`,Xa,[t[56]||=S(`span`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm mb-2 block`},`Activity Tier Thresholds (adverts/min)`,-1),S(`div`,Za,[S(`div`,Qa,[t[53]||=S(`div`,{class:`text-xs text-green-600 dark:text-green-400 mb-1`},`Quiet Max`,-1),f.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[12]||=e=>ne.value=e,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)),[[F,ne.value,void 0,{number:!0}]]):(w(),C(`div`,$a,b(ne.value),1))]),S(`div`,eo,[t[54]||=S(`div`,{class:`text-xs text-blue-600 dark:text-blue-400 mb-1`},`Normal Max`,-1),f.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[13]||=e=>q.value=e,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)),[[F,q.value,void 0,{number:!0}]]):(w(),C(`div`,to,b(q.value),1))]),S(`div`,no,[t[55]||=S(`div`,{class:`text-xs text-yellow-600 dark:text-yellow-400 mb-1`},`Busy Max`,-1),f.value?m((w(),C(`input`,{key:1,"onUpdate:modelValue":t[14]||=e=>J.value=e,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)),[[F,J.value,void 0,{number:!0}]]):(w(),C(`div`,ro,b(J.value),1))])]),t[57]||=S(`p`,{class:`text-xs text-content-muted dark:text-content-muted mt-2`},` Above Busy Max = Congested tier (strictest limiting) `,-1)])]),D.value?(w(),C(`div`,{key:2,class:`fixed inset-0 bg-black/50 flex items-start justify-center z-50 p-4 overflow-y-auto`,onClick:t[18]||=I(e=>D.value=!1,[`self`])},[S(`div`,{class:`bg-background dark:bg-background-dark rounded-lg shadow-xl max-w-3xl w-full my-8`,onClick:t[17]||=I(()=>{},[`stop`])},[S(`div`,io,[S(`div`,ao,[t[60]||=S(`h2`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` How Advert Rate Limiting Works `,-1),S(`button`,{onClick:t[15]||=e=>D.value=!1,class:`text-content-muted hover:text-content-primary dark:text-content-muted dark:hover:text-content-primary`},[...t[59]||=[S(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),t[61]||=d(`

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),S(`div`,oo,[S(`button`,{onClick:t[16]||=e=>D.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! `)])])])])):g(``,!0)]))}}),co=[{name:`US West only (US v1)`,website:`letsmesh.net`,brokers:[{enabled:!0,name:`MeshMapper`,host:`mqtt-us-v1.letsmesh.net`,port:443,audience:`mqtt-us-v1.letsmesh.net`,use_jwt_auth:!0,format:`letsmesh`,transport:`websockets`,retain_status:!1,tls:{enabled:!0,insecure:!1}}]},{name:`Europe only (EU v1)`,website:`letsmesh.net`,brokers:[{enabled:!0,name:`MeshMapper`,host:`mqtt-eu-v1.letsmesh.net`,port:443,audience:`mqtt-eu-v1.letsmesh.net`,use_jwt_auth:!0,format:`letsmesh`,transport:`websockets`,retain_status:!1,tls:{enabled:!0,insecure:!1}}]},{name:`MeshMapper`,website:`https://meshmapper.net`,brokers:[{enabled:!0,name:`MeshMapper`,host:`mqtt.meshmapper.cc`,port:443,audience:`mqtt.meshmapper.cc`,use_jwt_auth:!0,format:`letsmesh`,transport:`websockets`,retain_status:!1,tls:{enabled:!0,insecure:!1}}]}],lo={class:`space-y-6`},uo={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},fo={class:`flex items-center justify-between mb-4`},po=[`disabled`],mo={key:0},ho={key:1},go={key:0,class:`text-sm text-content-secondary dark:text-content-muted`},_o={key:1,class:`space-y-3`},vo={class:`flex items-center gap-2`},yo={key:0,class:`space-y-2`},bo=[`title`],xo={key:1,class:`text-sm text-content-muted dark:text-content-muted/60 italic`},So={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},Co={class:`flex items-start justify-between mb-6`},wo={key:0,class:`space-y-4`},To={class:`grid grid-cols-1 sm:grid-cols-2 gap-x-8 gap-y-3`},Eo={class:`mt-1 text-sm text-content-primary dark:text-content-primary font-mono`},Do={class:`mt-1 text-sm text-content-primary dark:text-content-primary`},Oo={class:`mt-1 text-sm text-content-primary dark:text-content-primary`},ko={class:`mt-1 text-sm text-content-primary dark:text-content-primary`},Ao={key:0,class:`mt-2 text-sm text-content-muted dark:text-content-muted/60 italic`},jo={key:1,class:`mt-2 space-y-1.5`},Mo={class:`min-w-0 flex-1`},No={class:u([`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium`])},Po={key:0,class:`w-4 h-4 text-green-600 dark:text-green-500`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Fo={class:`text-sm font-medium text-content-primary dark:text-content-primary`},Io={class:`text-xs text-content-secondary dark:text-content-muted ml-2 font-mono`},Lo=[`title`],Ro={key:1,class:`space-y-5`},zo={class:`grid grid-cols-1 sm:grid-cols-2 gap-4`},Bo={class:`grid grid-cols-1 sm:grid-cols-2 gap-4`},Vo={class:`grid grid-cols-1 sm:grid-cols-2 gap-4`},Ho={class:`flex items-start justify-between mb-3 gap-3`},Uo={class:`flex items-center gap-2 flex-shrink-0`},Wo={class:`relative`},Go={key:0,class:`absolute right-0 top-full mt-1 z-20 w-64 rounded-lg shadow-lg border border-stroke-subtle dark:border-stroke/20 bg-white dark:bg-[var(--color-surface)] overflow-hidden`},Ko={class:`py-1`},qo=[`onClick`],Jo={class:`min-w-0 flex-1`},Yo={class:`text-sm font-medium text-content-primary dark:text-content-primary group-hover:text-cyan-700 dark:group-hover:text-primary transition-colors`},Xo={class:`text-xs text-content-secondary dark:text-content-muted`},Zo=[`href`],Qo={key:0,class:`flex flex-col items-center justify-center py-7 rounded-lg border-2 border-dashed border-stroke-subtle dark:border-stroke/20 text-content-secondary dark:text-content-muted`},$o={key:1,class:`space-y-2`},es={key:0,class:`flex items-center gap-3 px-4 py-2.5`},ts={class:`min-w-0 flex-1`},ns={class:`text-sm font-medium text-content-primary dark:text-content-primary`},rs={class:`text-xs font-mono text-content-secondary dark:text-content-muted ml-2`},is={key:0,class:`ml-2 text-xs text-red-500 dark:text-red-400`},as={class:`flex items-center gap-0.5 flex-shrink-0`},os=[`onClick`],ss=[`onClick`],cs={key:1,class:`p-4 space-y-3 bg-background-mute/60 dark:bg-background/20`},ls={class:`grid grid-cols-1 sm:grid-cols-2 gap-3`},us={class:`grid grid-cols-1 sm:grid-cols-2 gap-3`},ds={class:`sm:col-span-2`},fs={key:0},ps={key:1},ms={class:`grid grid-cols-1 sm:grid-cols-2 gap-3`},hs={class:`sm:col-span-2`},gs={class:`sm:col-span-2`},_s={key:2},vs={class:`col-span-full`},ys={class:`flex flex-wrap gap-2`},bs=[`onClick`],xs={class:`flex items-center gap-2 pt-1`},Ss=[`disabled`],Cs=[`onClick`],ws={key:0,class:`p-3 rounded-lg bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700/30 text-green-700 dark:text-green-400 text-sm`},Ts={key:1,class:`p-3 rounded-lg bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700/30 text-red-700 dark:text-red-400 text-sm`},Es={class:`flex items-center gap-3 pt-2`},Ds=[`disabled`],Os={key:0},ks={key:1},As=[`disabled`],js=M(f({__name:`LetsMeshSettings`,setup(e){let n=j(),r=v(()=>n.stats?.config?.mqtt_brokers||{}),o=[`REQ`,`RESPONSE`,`TXT_MSG`,`ACK`,`ADVERT`,`GRP_TXT`,`GRP_DATA`,`ANON_REQ`,`PATH`,`TRACE`,`RAW_CUSTOM`],c=co,l=E(!1),d=E(!1),f=E(``),p=E(``),T=E(``),D=E(300),O=E(``),A=E(``),M=E([]),R=E(null),z=E({_id:0,enabled:!0,name:``,host:``,port:443,format:`letsmesh`,use_jwt_auth:!1,transport:`websockets`,disallowedInput:[],retain_status:!1,tls:{enabled:!0,insecure:!1}}),B=E(!1);function V(e){B.value=!1,R.value!==null&&K(),e.brokers.forEach(e=>{let t=H(e);M.value.push(t)})}let ee=1;function H(e={}){return{_id:ee++,enabled:e.enabled??!0,name:e.name??``,host:e.host??``,port:e.port??0,audience:e.audience??``,format:e.format??`letsmesh`,use_jwt_auth:e.use_jwt_auth??!1,username:e.username??``,password:e.password??``,transport:e.transport??`websockets`,disallowedInput:Array.isArray(e.disallowedInput)?[...e.disallowedInput]:[],retain_status:e.retain_status??!1,base_topic:e.base_topic??``,tls:{enabled:e.tls?.enabled??!1,insecure:e.tls?.insecure??!1}}}function U(){let e=H();M.value.push(e),z.value={...e},R.value=e._id}function W(e){M.value=M.value.filter(t=>t._id!==e),R.value===e&&(R.value=null)}function G(e){z.value={...e},R.value=e._id}function te(){R.value=null}function K(){let e=z.value;if(!e.name.trim()||!e.host.trim()||e.use_jwt_auth&&!e.audience?.trim())return;let t=M.value.findIndex(t=>t._id===e._id);t!==-1&&M.value.splice(t,1,{...e}),R.value=null}function ne(){let e=z.value;(!e.audience||e.audience===e.host)&&(e.audience=e.host)}let q=v(()=>{let e={};return M.value.forEach(t=>{t.name.trim()?t.host.trim()?t.use_jwt_auth&&!t.audience?.trim()?e[t._id]=`Audience required for JWT auth`:(t.port<1||t.port>65535)&&(e[t._id]=`Port must be 1–65535`):e[t._id]=`Host required`:e[t._id]=`Name required`}),e}),J=v(()=>Object.keys(q.value).length>0),Y=E(null),X=E(!1);async function Z(){X.value=!0;try{let e=await k.get(`/api/mqtt_status`);e.data?.success&&(Y.value=e.data.data)}catch{}finally{X.value=!1}}function Q(){let e=r.value;T.value=e.iata_code??``,D.value=e.status_interval??300,O.value=e.owner??``,A.value=e.email??``,M.value=Array.isArray(e.brokers)?e.brokers.map(e=>H(e)):[]}h(r,()=>{l.value||Q()},{immediate:!0});function re(){Q(),R.value=null,l.value=!0,f.value=``,p.value=``}function $(){R.value=null,l.value=!1,f.value=``,p.value=``}function ie(e,t){e.disallowedInput||=[];let n=e.disallowedInput.indexOf(t);n===-1?e.disallowedInput.push(t):e.disallowedInput.splice(n,1)}async function ae(){if(R.value!==null&&K(),J.value){p.value=`Please fix broker errors before saving.`;return}d.value=!0,p.value=``,f.value=``;try{let e=(await k.post(`/api/update_mqtt_config`,{iata_code:T.value,status_interval:D.value,owner:O.value,email:A.value,brokers:M.value.map(e=>{let t={name:e.name,enabled:e.enabled,transport:e.transport,host:e.host,port:e.port,use_jwt_auth:e.use_jwt_auth,format:e.format,disallowed_packet_types:e.disallowedInput,base_topic:e.base_topic,retain_status:e.retain_status,tls:{enabled:e.tls?.enabled??!1,insecure:e.tls?.insecure??!1}};return e.use_jwt_auth?{...t,audience:e.audience}:{...t,username:e.username,password:e.password}})})).data;e?.success?(f.value=e.data?.message||`Settings saved`,l.value=!1,await n.fetchStats(),await Z(),setTimeout(()=>{f.value=``},5e3)):p.value=e?.error||`Save failed`}catch(e){let t=e;p.value=t?.response?.data?.error||t?.message||`Request failed`}finally{d.value=!1}}return s(Z),(e,n)=>(w(),C(`div`,lo,[S(`div`,uo,[S(`div`,fo,[n[21]||=S(`div`,null,[S(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-1`},` Observer Status `),S(`p`,{class:`text-sm text-content-secondary dark:text-content-muted`},` Live LetsMesh broker connection state `)],-1),S(`button`,{onClick:Z,disabled:X.value,class:`px-3 py-1.5 text-xs rounded-lg bg-cyan-500/10 dark:bg-primary/10 hover:bg-cyan-500/20 dark:hover:bg-primary/20 text-cyan-700 dark:text-primary border border-cyan-400/30 dark:border-primary/30 transition-colors disabled:opacity-50`},[X.value?(w(),C(`span`,mo,`Refreshing…`)):(w(),C(`span`,ho,`↻ Refresh`))],8,po)]),Y.value?(w(),C(`div`,_o,[S(`div`,vo,[n[22]||=S(`span`,{class:`text-sm text-content-secondary dark:text-content-muted w-36`},`Handler`,-1),S(`span`,{class:u([`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium`,Y.value.handler_active?`bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400`:`bg-gray-100 dark:bg-gray-800/50 text-gray-500 dark:text-gray-400`])},[S(`span`,{class:u([`w-1.5 h-1.5 rounded-full`,Y.value.handler_active?`bg-green-500`:`bg-gray-400`])},null,2),y(` `+b(Y.value.handler_active?`Active`:`Inactive`),1)],2)]),Y.value.brokers.length?(w(),C(`div`,yo,[(w(!0),C(x,null,i(Y.value.brokers,e=>(w(),C(`div`,{key:e.host,class:`flex items-center gap-2`},[S(`span`,{class:`text-sm text-content-secondary dark:text-content-muted w-36 truncate`,title:e.name},b(e.name),9,bo),S(`span`,{class:u([`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium`,e.status.connected?`bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400`:e.status.reconnecting?`bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400`:`bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400`])},[S(`span`,{class:u([`w-1.5 h-1.5 rounded-full`,e.status.connected?`bg-green-500`:e.status.reconnecting?`bg-amber-500`:`bg-red-500`])},null,2),y(` `+b(e.status.connected?`Connected`:e.status.reconnecting?`Reconnecting…`:`Disconnected`),1)],2)]))),128))])):(w(),C(`div`,xo,` No broker connections configured. `))])):(w(),C(`div`,go,` Status unavailable — service may not be running. `))]),S(`div`,So,[S(`div`,Co,[n[23]||=S(`div`,null,[S(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-1`},` Observer Configuration `),S(`p`,{class:`text-sm text-content-secondary dark:text-content-muted`},` Configure MQTT observer settings `)],-1),l.value?g(``,!0):(w(),C(`button`,{key:0,onClick:re,class:`px-4 py-2 text-sm rounded-lg bg-cyan-500/10 dark:bg-primary/10 hover:bg-cyan-500/20 dark:hover:bg-primary/20 text-cyan-700 dark:text-primary border border-cyan-400/30 dark:border-primary/30 transition-colors`},` Edit `))]),l.value?(w(),C(`div`,Ro,[S(`div`,zo,[S(`div`,null,[n[30]||=S(`label`,{class:`block text-sm font-medium text-content-primary dark:text-content-primary mb-1.5`},[y(` IATA Code `),S(`span`,{class:`text-content-secondary dark:text-content-muted font-normal text-xs ml-1`},`(e.g. SFO, LHR)`)],-1),m(S(`input`,{"onUpdate:modelValue":n[0]||=e=>T.value=e,type:`text`,maxlength:`10`,placeholder:`TEST`,class:`w-full px-3 py-2 text-sm rounded-lg bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40 font-mono`},null,512),[[F,T.value]])])]),S(`div`,Bo,[S(`div`,null,[n[31]||=S(`label`,{class:`block text-sm font-medium text-content-primary dark:text-content-primary mb-1.5`},`Owner / Callsign`,-1),m(S(`input`,{"onUpdate:modelValue":n[1]||=e=>O.value=e,type:`text`,placeholder:`Optional`,class:`w-full px-3 py-2 text-sm rounded-lg bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40`},null,512),[[F,O.value]])]),S(`div`,null,[n[32]||=S(`label`,{class:`block text-sm font-medium text-content-primary dark:text-content-primary mb-1.5`},`Email`,-1),m(S(`input`,{"onUpdate:modelValue":n[2]||=e=>A.value=e,type:`email`,placeholder:`Optional`,class:`w-full px-3 py-2 text-sm rounded-lg bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40`},null,512),[[F,A.value]])])]),S(`div`,Vo,[n[33]||=S(`label`,{class:`block text-sm font-medium text-content-primary dark:text-content-primary mb-1.5`},[y(` Status Heartbeat Interval `),S(`span`,{class:`text-content-secondary dark:text-content-muted font-normal text-xs ml-1`},`(seconds, min 60)`)],-1),m(S(`input`,{"onUpdate:modelValue":n[3]||=e=>D.value=e,type:`number`,min:`60`,max:`3600`,class:`w-32 px-3 py-2 text-sm rounded-lg bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40 font-mono`},null,512),[[F,D.value,void 0,{number:!0}]])]),S(`div`,null,[S(`div`,Ho,[n[40]||=S(`div`,{class:`min-w-0`},[S(`label`,{class:`text-sm font-medium text-content-primary dark:text-content-primary`},`Brokers`),S(`p`,{class:`text-xs text-content-secondary dark:text-content-muted mt-0.5`},` MQTT brokers `)],-1),S(`div`,Uo,[S(`div`,Wo,[S(`button`,{onClick:n[4]||=e=>B.value=!B.value,class:`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-background-mute dark:bg-background/30 hover:bg-stroke-subtle dark:hover:bg-stroke/10 text-content-secondary dark:text-content-muted border border-stroke-subtle dark:border-stroke/20 transition-colors`},[n[35]||=S(`svg`,{class:`w-3.5 h-3.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M19 11H5m14 0l-4-4m4 4l-4 4`})],-1),n[36]||=y(` From Template `,-1),(w(),C(`svg`,{class:u([`w-3 h-3 ml-0.5 transition-transform`,B.value?`rotate-180`:``]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...n[34]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M19 9l-7 7-7-7`},null,-1)]],2))]),_(N,{name:`dropdown`},{default:t(()=>[B.value?(w(),C(`div`,Go,[n[38]||=S(`div`,{class:`px-3 py-2 border-b border-stroke-subtle dark:border-stroke/10`},[S(`p`,{class:`text-xs font-medium text-content-secondary dark:text-content-muted uppercase tracking-wide`},` Known Networks `)],-1),S(`div`,Ko,[(w(!0),C(x,null,i(a(c),e=>(w(),C(`div`,{key:e.name,class:`flex items-center gap-2 px-3 py-2.5 hover:bg-background-mute dark:hover:bg-background/30 cursor-pointer group`,onClick:t=>V(e)},[S(`div`,Jo,[S(`p`,Yo,b(e.name),1),S(`p`,Xo,b(e.brokers.length)+` broker`+b(e.brokers.length===1?``:`s`),1)]),S(`a`,{href:e.website,target:`_blank`,rel:`noopener noreferrer`,title:`Visit website`,class:`flex-shrink-0 p-1 rounded hover:bg-cyan-500/10 dark:hover:bg-primary/10 text-content-secondary dark:text-content-muted hover:text-cyan-700 dark:hover:text-primary transition-colors`,onClick:n[5]||=I(()=>{},[`stop`])},[...n[37]||=[S(`svg`,{class:`w-3.5 h-3.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14`})],-1)]],8,Zo)],8,qo))),128))])])):g(``,!0)]),_:1}),B.value?(w(),C(`div`,{key:0,class:`fixed inset-0 z-10`,onClick:n[6]||=e=>B.value=!1})):g(``,!0)]),S(`button`,{onClick:U,class:`inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg bg-cyan-500/10 dark:bg-primary/10 hover:bg-cyan-500/20 dark:hover:bg-primary/20 text-cyan-700 dark:text-primary border border-cyan-400/30 dark:border-primary/30 transition-colors`},[...n[39]||=[S(`svg`,{class:`w-3.5 h-3.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 4v16m8-8H4`})],-1),y(` Add `,-1)]])])]),M.value.length?(w(),C(`div`,$o,[(w(!0),C(x,null,i(M.value,e=>(w(),C(`div`,{key:e._id,class:u([`rounded-lg border overflow-hidden transition-colors`,q.value[e._id]?`border-red-300 dark:border-red-700/50`:`border-stroke-subtle dark:border-stroke/10`])},[R.value===e._id?(w(),C(`div`,cs,[S(`div`,ls,[S(`div`,null,[n[44]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` Enabled `),S(`span`,{class:`text-red-500`},`*`)],-1),m(S(`input`,{"onUpdate:modelValue":n[7]||=e=>z.value.enabled=e,type:`checkbox`,class:`w-4 h-4 text-cyan-600 bg-background-mute border-stroke-subtle dark:border-stroke/20 focus:ring-cyan-500/40 dark:focus:ring-primary/40`},null,512),[[L,z.value.enabled]])]),S(`div`,null,[n[45]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` Retain Status`),S(`span`,{class:`text-red-500`},`*`),S(`span`,{class:`font-normal text-content-muted dark:text-content-muted/60 ml-1`},[y(` (Enable MQTT `),S(`a`,{href:`https://www.hivemq.com/blog/mqtt-essentials-part-8-retained-messages/`,class:`w-4 h-4 text-cyan-600 border-stroke-subtle dark:border-stroke/20 focus:ring-cyan-500/40 dark:focus:ring-primary/40`},`Retained`),y(` for status messages) `)])],-1),m(S(`input`,{"onUpdate:modelValue":n[8]||=e=>z.value.retain_status=e,type:`checkbox`,class:`w-4 h-4 text-cyan-600 bg-background-mute border-stroke-subtle dark:border-stroke/20 focus:ring-cyan-500/40 dark:focus:ring-primary/40`},null,512),[[L,z.value.retain_status]])]),S(`div`,null,[n[46]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` Name `),S(`span`,{class:`text-red-500`},`*`)],-1),m(S(`input`,{"onUpdate:modelValue":n[9]||=e=>z.value.name=e,type:`text`,placeholder:`Broker Name`,class:`w-full px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40`},null,512),[[F,z.value.name]])]),S(`div`,null,[n[48]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` Transport `),S(`span`,{class:`text-red-500`},`*`),S(`span`,{class:`font-normal text-content-muted dark:text-content-muted/60 ml-1`})],-1),m(S(`select`,{"onUpdate:modelValue":n[10]||=e=>z.value.transport=e,class:`w-full px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40`},[...n[47]||=[S(`option`,{value:`websockets`},`Websockets`,-1),S(`option`,{value:`tcp`},`TCP`,-1)]],512),[[P,z.value.transport]])]),S(`div`,null,[n[49]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` Host `),S(`span`,{class:`text-red-500`},`*`)],-1),m(S(`input`,{"onUpdate:modelValue":n[11]||=e=>z.value.host=e,type:`text`,placeholder:`mqtt.myserver.com`,onBlur:ne,class:`w-full px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40 font-mono`},null,544),[[F,z.value.host]])]),S(`div`,us,[S(`div`,ds,[n[50]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` Port `),S(`span`,{class:`text-red-500`},`*`),S(`span`,{class:`font-normal text-content-muted dark:text-content-muted/60 ml-1`},`(Usually 443 for Websockets, 1883 for TCP)`)],-1),m(S(`input`,{"onUpdate:modelValue":n[12]||=e=>z.value.port=e,type:`number`,min:`0`,max:`65535`,placeholder:`0`,class:`w-full px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40 font-mono`},null,512),[[F,z.value.port,void 0,{number:!0}]])]),S(`div`,null,[n[51]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` TLS - Enabled `),S(`span`,{class:`text-red-500`},`*`),S(`span`,{class:`font-normal text-content-muted dark:text-content-muted/60 ml-1`},`(Enable TLS)`)],-1),m(S(`input`,{"onUpdate:modelValue":n[13]||=e=>z.value.tls.enabled=e,type:`checkbox`,class:`px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40 font-mono`},null,512),[[L,z.value.tls.enabled,void 0,{number:!0}]])]),S(`div`,null,[n[52]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` TLS - Insecure`),S(`span`,{class:`text-red-500`},`*`),S(`span`,{class:`font-normal text-content-muted dark:text-content-muted/60 ml-1`},`(Allow insecure TLS connections)`)],-1),m(S(`input`,{"onUpdate:modelValue":n[14]||=e=>z.value.tls.insecure=e,type:`checkbox`,class:`px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40 font-mono`},null,512),[[L,z.value.tls.insecure,void 0,{number:!0}]])])]),S(`div`,null,[n[53]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` Use JWT Auth `),S(`span`,{class:`text-red-500`},`*`),S(`span`,{class:`font-normal text-content-muted dark:text-content-muted/60 ml-1`})],-1),m(S(`input`,{"onUpdate:modelValue":n[15]||=e=>z.value.use_jwt_auth=e,type:`checkbox`,placeholder:`true`,class:`px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40 font-mono`},null,512),[[L,z.value.use_jwt_auth]])]),z.value.use_jwt_auth?(w(),C(`div`,fs,[n[54]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` Audience `),S(`span`,{class:`text-red-500`},`*`),S(`span`,{class:`font-normal text-content-muted dark:text-content-muted/60 ml-1`},`(JWT aud — usually same as host)`)],-1),m(S(`input`,{"onUpdate:modelValue":n[16]||=e=>z.value.audience=e,type:`text`,placeholder:`mqtt.myserver.com`,class:`w-full px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40 font-mono`},null,512),[[F,z.value.audience]])])):(w(),C(`div`,ps,[S(`div`,ms,[n[57]||=S(`input`,{type:`text`,autocomplete:`username`,style:{display:`none`}},null,-1),n[58]||=S(`input`,{type:`password`,autocomplete:`current-password`,style:{display:`none`}},null,-1),S(`div`,hs,[n[55]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` Username `),S(`span`,{class:`font-normal text-content-muted dark:text-content-muted/60 ml-1`},` (Leave blank for anonymous auth) `)],-1),m(S(`input`,{autocomplete:`username`,"onUpdate:modelValue":n[17]||=e=>z.value.username=e,type:`text`,placeholder:`username`,class:`w-full px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40`},null,512),[[F,z.value.username]])]),S(`div`,gs,[n[56]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},` Password `,-1),m(S(`input`,{autocomplete:`new-password`,"onUpdate:modelValue":n[18]||=e=>z.value.password=e,type:`password`,placeholder:``,readonly:``,onfocus:`this.removeAttribute('readonly');`,onblur:`this.setAttribute('readonly', true);`,class:`w-full px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary placeholder-content-muted dark:placeholder-content-muted/50 focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40`},null,512),[[F,z.value.password]])])])])),S(`div`,null,[n[60]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` Format `),S(`span`,{class:`text-red-500`},`*`),S(`span`,{class:`font-normal text-content-muted dark:text-content-muted/60 ml-1`})],-1),m(S(`select`,{"onUpdate:modelValue":n[19]||=e=>z.value.format=e,class:`w-full px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40`},[...n[59]||=[S(`option`,{value:`letsmesh`},`LetsMesh MQTT format`,-1),S(`option`,{value:`mqtt`},`pyMC MQTT format`,-1)]],512),[[P,z.value.format]])]),z.value.format===`mqtt`?(w(),C(`div`,_s,[n[61]||=S(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},[y(` MQTT base topic `),S(`span`,{class:`font-normal text-content-muted dark:text-content-muted/60 ml-1`},`(Messages are sent to topics under this path. Example: /advert) `)],-1),m(S(`input`,{"onUpdate:modelValue":n[20]||=e=>z.value.base_topic=e,class:`w-full px-3 py-1.5 text-sm rounded-md bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/20 text-content-primary dark:text-content-primary focus:outline-none focus:ring-2 focus:ring-cyan-500/40 dark:focus:ring-primary/40`,placeholder:`meshcore/repeater`},null,512),[[F,z.value.base_topic]])])):g(``,!0),S(`div`,vs,[n[62]||=S(`label`,{class:`block text-sm font-medium text-content-primary dark:text-content-primary mb-2`},[y(` Block Packet Types `),S(`span`,{class:`text-content-secondary dark:text-content-muted font-normal text-xs ml-1`},` (prevent publishing to LetsMesh) `)],-1),S(`div`,ys,[(w(),C(x,null,i(o,e=>S(`button`,{key:e,onClick:t=>ie(z.value,e),class:u([`px-2.5 py-1 rounded text-xs font-mono font-medium border transition-colors`,z.value.disallowedInput?.includes(e)?`bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-700/50 text-red-700 dark:text-red-400`:`bg-background-mute dark:bg-background/30 border-stroke-subtle dark:border-stroke/20 text-content-secondary dark:text-content-muted hover:border-cyan-400/50 dark:hover:border-primary/40`])},b(e),11,bs)),64))]),n[63]||=S(`p`,{class:`mt-1.5 text-xs text-content-secondary dark:text-content-muted`},[S(`span`,{class:`text-red-600 dark:text-red-400 font-medium`},`Red = blocked.`),y(` Leave all unselected to publish all packet types. `)],-1)])]),S(`div`,xs,[S(`button`,{onClick:K,disabled:!z.value.name.trim()||!z.value.host.trim()||z.value.port<=0||z.value.port>65535||z.value.use_jwt_auth&&!z.value.audience?.trim(),class:`px-3 py-1.5 text-xs font-medium rounded-md bg-cyan-600 dark:bg-teal-600 hover:bg-cyan-700 dark:hover:bg-teal-700 text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed`},` Done `,8,Ss),S(`button`,{onClick:te,class:`px-3 py-1.5 text-xs rounded-md border border-stroke-subtle dark:border-stroke/20 hover:bg-stroke-subtle dark:hover:bg-stroke/10 text-content-secondary dark:text-content-muted transition-colors`},` Cancel `),S(`button`,{onClick:t=>W(e._id),class:`ml-auto px-3 py-1.5 text-xs rounded-md border border-red-300/60 dark:border-red-700/30 hover:bg-red-50 dark:hover:bg-red-900/20 text-red-600 dark:text-red-400 transition-colors`},` Remove `,8,Cs)])])):(w(),C(`div`,es,[S(`div`,ts,[S(`span`,{class:u([`inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium`,e.enabled?`bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400`:`bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400`])},[S(`span`,{class:u([`w-1.5 h-1.5 rounded-full`,e.enabled?`bg-green-500`:`bg-red-500`])},null,2),y(` `+b(e.enabled?`Enabled`:`Disabled`),1)],2),S(`span`,ns,b(e.name||`(unnamed)`),1),S(`span`,rs,b(e.host||`—`)+`:`+b(e.port),1),q.value[e._id]?(w(),C(`span`,is,b(q.value[e._id]),1)):g(``,!0)]),S(`div`,as,[S(`button`,{onClick:t=>G(e),title:`Edit`,class:`p-1.5 rounded hover:bg-cyan-500/10 dark:hover:bg-primary/10 text-content-secondary dark:text-content-muted hover:text-cyan-700 dark:hover:text-primary transition-colors`},[...n[42]||=[S(`svg`,{class:`w-3.5 h-3.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z`})],-1)]],8,os),S(`button`,{onClick:t=>W(e._id),title:`Remove`,class:`p-1.5 rounded hover:bg-red-500/10 dark:hover:bg-red-900/20 text-content-secondary dark:text-content-muted hover:text-red-600 dark:hover:text-red-400 transition-colors`},[...n[43]||=[S(`svg`,{class:`w-3.5 h-3.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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,ss)])]))],2))),128))])):(w(),C(`div`,Qo,[...n[41]||=[S(`svg`,{class:`w-7 h-7 mb-2 opacity-40`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`1.5`,d:`M5 12h14M5 12l4-4m-4 4l4 4`})],-1),S(`p`,{class:`text-sm`},`No brokers`,-1),S(`p`,{class:`text-xs mt-0.5 opacity-70`},`Add a MQTT broker`,-1)]]))]),n[64]||=S(`div`,{class:`flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-700/30 text-amber-700 dark:text-amber-400 text-xs`},[S(`svg`,{class:`w-4 h-4 mt-0.5 flex-shrink-0`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z`})]),y(` A service restart is required for MQTT broker changes to take effect. `)],-1),f.value?(w(),C(`div`,ws,b(f.value),1)):g(``,!0),p.value?(w(),C(`div`,Ts,b(p.value),1)):g(``,!0),S(`div`,Es,[S(`button`,{onClick:ae,disabled:d.value||J.value,class:`px-5 py-2 text-sm font-medium rounded-lg bg-cyan-600 dark:bg-teal-600 hover:bg-cyan-700 dark:hover:bg-teal-700 text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed`},[d.value?(w(),C(`span`,Os,`Saving…`)):(w(),C(`span`,ks,`Save Settings`))],8,Ds),S(`button`,{onClick:$,disabled:d.value,class:`px-4 py-2 text-sm rounded-lg bg-background-mute dark:bg-background/30 hover:bg-stroke-subtle dark:hover:bg-stroke/10 text-content-secondary dark:text-content-muted border border-stroke-subtle dark:border-stroke/20 transition-colors disabled:opacity-50`},` Cancel `,8,As)])])):(w(),C(`div`,wo,[S(`div`,To,[S(`div`,null,[n[24]||=S(`span`,{class:`text-xs font-medium text-content-secondary dark:text-content-muted uppercase tracking-wide`},`IATA Code`,-1),S(`p`,Eo,b(r.value.iata_code||`—`),1)]),S(`div`,null,[n[25]||=S(`span`,{class:`text-xs font-medium text-content-secondary dark:text-content-muted uppercase tracking-wide`},`Status Interval`,-1),S(`p`,Do,b(r.value.status_interval??300)+`s `,1)]),S(`div`,null,[n[26]||=S(`span`,{class:`text-xs font-medium text-content-secondary dark:text-content-muted uppercase tracking-wide`},`Owner`,-1),S(`p`,Oo,b(r.value.owner||`—`),1)]),S(`div`,null,[n[27]||=S(`span`,{class:`text-xs font-medium text-content-secondary dark:text-content-muted uppercase tracking-wide`},`Email`,-1),S(`p`,ko,b(r.value.email||`—`),1)])]),S(`div`,null,[n[29]||=S(`span`,{class:`text-xs font-medium text-content-secondary dark:text-content-muted uppercase tracking-wide`},`Brokers`,-1),r.value.brokers?.length?(w(),C(`div`,jo,[(w(!0),C(x,null,i(r.value.brokers,e=>(w(),C(`div`,{key:e.host,class:`flex items-center gap-3 px-3 py-2 rounded-lg bg-background-mute dark:bg-background/30 border border-stroke-subtle dark:border-stroke/10`},[S(`div`,Mo,[S(`span`,No,[e.enabled?(w(),C(`svg`,Po,[...n[28]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):g(``,!0)]),S(`span`,Fo,b(e.name),1),S(`span`,Io,`Host: `+b(e.host)+`:`+b(e.port),1)]),S(`span`,{class:`text-xs text-content-secondary dark:text-content-muted font-mono truncate max-w-[140px]`,title:e.format},b(e.format),9,Lo)]))),128))])):(w(),C(`div`,Ao,` None configured `))])]))])]))}}),[[`__scopeId`,`data-v-45835eb1`]]),Ms={class:`space-y-6`},Ns={key:0,class:`rounded-lg border-2 border-red-500/50 dark:border-red-400/40 bg-red-100 dark:bg-red-500/10 p-4`},Ps={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},Fs=[`disabled`],Is={key:0,class:`flex items-center gap-2`},Ls={key:1,class:`flex items-center gap-2`},Rs={key:0,class:`text-xs text-green-600 dark:text-green-400 mt-2`},zs={key:1,class:`text-xs text-red-500 dark:text-red-400 mt-2`},Bs={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},Vs={key:0},Hs={key:1,class:`rounded-lg border-2 border-red-500/50 dark:border-red-400/40 bg-red-50 dark:bg-red-500/10 p-4`},Us={class:`flex items-start gap-3`},Ws={class:`flex-1`},Gs={class:`text-xs text-red-600 dark:text-red-400/80 mt-1`},Ks={class:`flex gap-2 mt-3`},qs=[`disabled`],Js=[`disabled`],Ys={key:2,class:`text-xs text-green-600 dark:text-green-400 mt-2`},Xs={key:3,class:`text-xs text-red-500 dark:text-red-400 mt-2`},Zs={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},Qs={class:`space-y-3`},$s={class:`flex items-center gap-3 cursor-pointer px-4 py-3 bg-background-mute dark:bg-background/30 rounded-lg border-2 border-dashed border-stroke-subtle dark:border-stroke/20 hover:border-cyan-500/50 dark:hover:border-primary/50 transition-colors`},ec={class:`text-sm text-content-secondary dark:text-content-muted`},tc={key:0,class:`bg-background-mute dark:bg-background/30 rounded-lg p-4 border border-stroke-subtle dark:border-stroke/10`},nc={key:0,class:`text-xs text-content-secondary dark:text-content-muted space-y-1 mb-3`},rc={class:`font-mono`},ic={class:`font-mono`},ac={key:0,class:`text-amber-600 dark:text-amber-400 font-medium`},oc={key:1,class:`text-content-muted`},sc={class:`text-xs text-content-secondary dark:text-content-muted`},cc={class:`font-mono`},lc={key:1},uc={key:2,class:`rounded-lg border-2 border-amber-500/50 dark:border-amber-400/40 bg-amber-50 dark:bg-amber-500/10 p-4`},dc={class:`flex items-start gap-3`},fc={class:`flex-1`},pc={class:`text-xs text-amber-700 dark:text-amber-300/80 mt-1`},mc={class:`flex gap-2 mt-3`},hc=[`disabled`],gc=[`disabled`],_c={key:3,class:`text-xs text-green-600 dark:text-green-400 mt-2`},vc={key:4,class:`text-xs text-red-500 dark:text-red-400 mt-2`},yc={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},bc={key:0},xc={key:1,class:`rounded-lg border-2 border-red-500/50 dark:border-red-400/40 bg-red-50 dark:bg-red-500/10 p-4`},Sc={class:`flex items-start gap-3`},Cc={class:`flex-1`},wc={class:`text-xs text-red-600 dark:text-red-400/80 mt-1`},Tc={class:`flex gap-2 mt-3`},Ec=[`disabled`],Dc=[`disabled`],Oc={key:2,class:`bg-background-mute dark:bg-background/30 rounded-lg p-4 border border-stroke-subtle dark:border-stroke/10 space-y-2`},kc={class:`flex items-center justify-between`},Ac={class:`text-xs text-content-secondary dark:text-content-muted space-y-1`},jc={class:`font-mono`},Mc={key:0},Nc={class:`font-mono`},Pc={key:1},Fc={class:`font-mono text-[10px] break-all`},Ic={key:3,class:`text-xs text-red-500 dark:text-red-400 mt-2`},Lc=f({__name:`BackupRestore`,setup(e){let t=v(()=>window.location.protocol===`http:`),n=E(!1),r=E(``),i=E(``);async function a(){n.value=!0,r.value=``,i.value=``;try{let e=await A.exportConfig(!1);if(!e.success||!e.data){i.value=e.error||`Export failed`;return}let t=new Blob([JSON.stringify(e.data,null,2)],{type:`application/json`}),n=URL.createObjectURL(t),a=document.createElement(`a`);a.href=n,a.download=`pymc-repeater-settings-${(e.data.meta?.exported_at||new Date().toISOString()).replace(/[:.]/g,`-`)}.json`,document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(n),r.value=`Settings exported successfully (secrets redacted).`}catch(e){i.value=e instanceof Error?e.message:`Export failed`}finally{n.value=!1}}let o=E(!1),s=E(!1),c=E(``),l=E(``);async function u(){s.value=!0,c.value=``,l.value=``;try{let e=await A.exportConfig(!0);if(!e.success||!e.data){l.value=e.error||`Export failed`;return}let t=new Blob([JSON.stringify(e.data,null,2)],{type:`application/json`}),n=URL.createObjectURL(t),r=document.createElement(`a`);r.href=n,r.download=`pymc-repeater-full-backup-${(e.data.meta?.exported_at||new Date().toISOString()).replace(/[:.]/g,`-`)}.json`,document.body.appendChild(r),r.click(),document.body.removeChild(r),URL.revokeObjectURL(n),c.value=`Full backup exported (includes all secrets).`,o.value=!1}catch(e){l.value=e instanceof Error?e.message:`Export failed`}finally{s.value=!1}}let f=E(null),p=E(null),m=E(!1),h=E(!1),_=E(``),T=E(``),D=E(null),O=v(()=>p.value?.config?Object.keys(p.value.config).join(`, `):``),k=v(()=>{let e=p.value?.meta?.includes_secrets;return e===!0||e===`true`});function j(e){let t=e.target.files?.[0];if(!t)return;f.value=t,p.value=null,m.value=!1,_.value=``,T.value=``;let n=new FileReader;n.onload=e=>{try{let t=JSON.parse(e.target?.result);t.config&&typeof t.config==`object`?p.value={meta:t.meta,config:t.config}:typeof t==`object`&&!Array.isArray(t)?p.value={config:t}:T.value=`Invalid file format — expected a JSON config object.`}catch{T.value=`Invalid JSON file.`}},n.readAsText(t)}function M(){m.value=!1,p.value=null,f.value=null,D.value&&(D.value.value=``)}async function N(){if(p.value?.config){h.value=!0,_.value=``,T.value=``;try{let e=await A.importConfig(p.value.config);if(e.success){let t=e.data,n=e.message||t?.message||`Configuration imported.`;t?.restart_required&&(n+=` A service restart is required for radio changes to take effect.`),_.value=n,m.value=!1,p.value=null,f.value=null,D.value&&(D.value.value=``)}else T.value=e.error||`Import failed`}catch(e){T.value=e instanceof Error?e.message:`Import failed`}finally{h.value=!1}}}let P=E(!1),F=E(!1),I=E(null),L=E(``);async function R(){F.value=!0,L.value=``;try{let e=await A.exportIdentityKey();if(!e.success||!e.data){L.value=e.error||`Export failed`;return}I.value=e.data;let t=new Blob([e.data.identity_key_hex],{type:`text/plain`}),n=URL.createObjectURL(t),r=document.createElement(`a`);r.href=n,r.download=`pymc-identity-${e.data.node_address||`key`}.hex`,document.body.appendChild(r),r.click(),document.body.removeChild(r),URL.revokeObjectURL(n)}catch(e){L.value=e instanceof Error?e.message:`Export failed`}finally{F.value=!1}}return(e,v)=>(w(),C(`div`,Ms,[t.value?(w(),C(`div`,Ns,[...v[6]||=[d(`

Unencrypted Connection

This page is served over HTTP, not HTTPS. Exported data (including identity keys) will be transmitted in plain text. Only use these features on a trusted local network.

`,1)]])):g(``,!0),S(`div`,Ps,[v[9]||=S(`div`,{class:`flex items-start justify-between mb-4`},[S(`div`,null,[S(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-1`},` Export Settings `),S(`p`,{class:`text-sm text-content-secondary dark:text-content-muted`},[y(` Download the current configuration as a JSON file. Passwords, JWT secrets, and identity keys are `),S(`strong`,null,`redacted`),y(`. Safe to share or use as a template for other devices. `)])])],-1),S(`button`,{onClick:a,disabled:n.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-white rounded-lg border border-cyan-500/50 dark:border-primary/50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed text-sm`},[n.value?(w(),C(`span`,Is,[...v[7]||=[S(`span`,{class:`animate-spin w-4 h-4 border-2 border-current border-t-transparent rounded-full inline-block`},null,-1),y(` Exporting… `,-1)]])):(w(),C(`span`,Ls,[...v[8]||=[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4`})],-1),y(` Export Settings `,-1)]]))],8,Fs),r.value?(w(),C(`p`,Rs,b(r.value),1)):g(``,!0),i.value?(w(),C(`p`,zs,b(i.value),1)):g(``,!0)]),S(`div`,Bs,[v[15]||=d(`

Full Backup

Download a complete backup including all passwords, JWT secrets, and identity keys. Required for restoring to a new device or recovering from a failed SD card.

Contains sensitive data. The backup file will include plain-text passwords and private keys. Store it securely and never share it.

`,2),o.value?g(``,!0):(w(),C(`div`,Vs,[S(`button`,{onClick:v[0]||=e=>o.value=!0,class:`px-4 py-2 bg-red-500/20 dark:bg-red-400/20 hover:bg-red-500/30 dark:hover:bg-red-400/30 text-red-900 dark:text-red-200 rounded-lg border border-red-500/50 dark:border-red-400/40 transition-colors text-sm`},[...v[10]||=[S(`span`,{class:`flex items-center gap-2`},[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z`})]),y(` Full Backup `)],-1)]])])),o.value?(w(),C(`div`,Hs,[S(`div`,Us,[v[14]||=S(`svg`,{class:`w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),S(`div`,Ws,[v[13]||=S(`h4`,{class:`text-sm font-semibold text-red-700 dark:text-red-400`},` Confirm Full Backup `,-1),S(`p`,Gs,[v[11]||=y(` This will export `,-1),v[12]||=S(`strong`,null,`all secrets in plain text`,-1),y(` including admin/guest passwords, JWT secret, and your repeater's private identity key`+b(t.value?` over an unencrypted HTTP connection`:``)+`. `,1)]),S(`div`,Ks,[S(`button`,{onClick:u,disabled:s.value,class:`px-4 py-2 bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 text-white rounded-lg transition-colors text-sm disabled:opacity-50`},b(s.value?`Exporting…`:`Yes, Export Full Backup`),9,qs),S(`button`,{onClick:v[1]||=e=>o.value=!1,disabled:s.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 transition-colors text-sm`},` Cancel `,8,Js)])])])])):g(``,!0),c.value?(w(),C(`p`,Ys,b(c.value),1)):g(``,!0),l.value?(w(),C(`p`,Xs,b(l.value),1)):g(``,!0)]),S(`div`,Zs,[v[29]||=S(`div`,{class:`flex items-start justify-between mb-4`},[S(`div`,null,[S(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-1`},` Import Configuration `),S(`p`,{class:`text-sm text-content-secondary dark:text-content-muted`},[y(` Restore configuration from a previously exported JSON file. Importing a `),S(`strong`,null,`full backup`),y(` will also restore passwords and identity keys. Importing a `),S(`strong`,null,`settings export`),y(` will only update non-sensitive settings. `)])])],-1),S(`div`,Qs,[S(`label`,$s,[v[16]||=S(`svg`,{class:`w-5 h-5 text-content-secondary dark:text-content-muted`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12`})],-1),S(`span`,ec,b(f.value?f.value.name:`Choose a config JSON file…`),1),S(`input`,{ref_key:`fileInputRef`,ref:D,type:`file`,accept:`.json,application/json`,class:`hidden`,onChange:j},null,544)]),p.value?(w(),C(`div`,tc,[v[20]||=S(`h4`,{class:`text-sm font-medium text-content-primary dark:text-content-primary mb-2`},` Import Preview `,-1),p.value.meta?(w(),C(`div`,nc,[S(`p`,null,[v[17]||=y(` Exported: `,-1),S(`span`,rc,b(p.value.meta.exported_at),1)]),S(`p`,null,[v[18]||=y(` Version: `,-1),S(`span`,ic,b(p.value.meta.version),1)]),p.value.meta.includes_secrets===`true`||p.value.meta.includes_secrets===!0?(w(),C(`p`,ac,` ⚠ Full backup — will restore passwords and identity keys `)):(w(),C(`p`,oc,` Settings only — existing secrets will not be changed `))])):g(``,!0),S(`p`,sc,[v[19]||=y(` Sections: `,-1),S(`span`,cc,b(O.value),1)])])):g(``,!0),p.value&&!m.value?(w(),C(`div`,lc,[S(`button`,{onClick:v[2]||=e=>m.value=!0,class:`px-4 py-2 bg-amber-500/20 dark:bg-amber-400/20 hover:bg-amber-500/30 dark:hover:bg-amber-400/30 text-amber-900 dark:text-amber-200 rounded-lg border border-amber-500/50 dark:border-amber-400/40 transition-colors text-sm`},` Review & Import `)])):g(``,!0),m.value?(w(),C(`div`,uc,[S(`div`,dc,[v[28]||=S(`svg`,{class:`w-5 h-5 text-amber-600 dark:text-amber-400 shrink-0 mt-0.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),S(`div`,fc,[v[27]||=S(`h4`,{class:`text-sm font-semibold text-amber-800 dark:text-amber-300`},` Confirm Import `,-1),S(`p`,pc,[v[24]||=y(` This will overwrite current settings for: `,-1),S(`strong`,null,b(O.value),1),v[25]||=y(`. `,-1),k.value?(w(),C(x,{key:0},[v[21]||=y(` This is a full backup — `,-1),v[22]||=S(`strong`,null,`passwords, JWT secrets, and identity keys will also be overwritten`,-1),v[23]||=y(`. `,-1)],64)):(w(),C(x,{key:1},[y(` Passwords and identity keys will not be changed. `)],64)),v[26]||=y(` Some changes (radio settings) require a service restart. `,-1)]),S(`div`,mc,[S(`button`,{onClick:N,disabled:h.value,class:`px-4 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-500 dark:hover:bg-amber-600 text-white rounded-lg transition-colors text-sm disabled:opacity-50`},b(h.value?`Importing…`:`Yes, Import`),9,hc),S(`button`,{onClick:M,disabled:h.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 transition-colors text-sm`},` Cancel `,8,gc)])])])])):g(``,!0),_.value?(w(),C(`p`,_c,b(_.value),1)):g(``,!0),T.value?(w(),C(`p`,vc,b(T.value),1)):g(``,!0)])]),S(`div`,yc,[v[38]||=d(`

Export Identity Key

Download the repeater's private identity key for backup. This key determines the node's address and cryptographic identity on the mesh.

Sensitive data. The identity key is the repeater's private key. Anyone with this key can impersonate your node. Store the exported file securely and never share it.

`,2),P.value?g(``,!0):(w(),C(`div`,bc,[S(`button`,{onClick:v[3]||=e=>P.value=!0,class:`px-4 py-2 bg-red-500/20 dark:bg-red-400/20 hover:bg-red-500/30 dark:hover:bg-red-400/30 text-red-900 dark:text-red-200 rounded-lg border border-red-500/50 dark:border-red-400/40 transition-colors text-sm`},[...v[30]||=[S(`span`,{class:`flex items-center gap-2`},[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`})]),y(` Export Identity Key `)],-1)]])])),P.value&&!I.value?(w(),C(`div`,xc,[S(`div`,Sc,[v[32]||=S(`svg`,{class:`w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),S(`div`,Cc,[v[31]||=S(`h4`,{class:`text-sm font-semibold text-red-700 dark:text-red-400`},`Are you sure?`,-1),S(`p`,wc,` This will transmit your private key `+b(t.value?`over an unencrypted HTTP connection. `:``)+` and download it as a file. `,1),S(`div`,Tc,[S(`button`,{onClick:R,disabled:F.value,class:`px-4 py-2 bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 text-white rounded-lg transition-colors text-sm disabled:opacity-50`},b(F.value?`Exporting…`:`Yes, Export Key`),9,Ec),S(`button`,{onClick:v[4]||=e=>P.value=!1,disabled:F.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 transition-colors text-sm`},` Cancel `,8,Dc)])])])])):g(``,!0),I.value?(w(),C(`div`,Oc,[S(`div`,kc,[v[33]||=S(`h4`,{class:`text-sm font-medium text-content-primary dark:text-content-primary`},` Key Exported `,-1),S(`button`,{onClick:v[5]||=e=>{I.value=null,P.value=!1},class:`text-xs text-content-muted hover:text-content-secondary transition-colors`},` Dismiss `)]),S(`div`,Ac,[S(`p`,null,[v[34]||=y(` Key length: `,-1),S(`span`,jc,b(I.value.key_length_bytes)+` bytes`,1)]),I.value.node_address?(w(),C(`p`,Mc,[v[35]||=y(` Node address: `,-1),S(`span`,Nc,b(I.value.node_address),1)])):g(``,!0),I.value.public_key_hex?(w(),C(`p`,Pc,[v[36]||=y(` Public key: `,-1),S(`span`,Fc,b(I.value.public_key_hex),1)])):g(``,!0)]),v[37]||=S(`p`,{class:`text-xs text-green-600 dark:text-green-400`},`File downloaded successfully.`,-1)])):g(``,!0),L.value?(w(),C(`p`,Ic,b(L.value),1)):g(``,!0)])]))}}),Rc={class:`space-y-6`},zc={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},Bc={class:`flex items-start justify-between mb-4`},Vc=[`disabled`],Hc={key:0,class:`flex items-center gap-1.5`},Uc={key:1},Wc={key:0,class:`grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6`},Gc={class:`bg-background-mute dark:bg-background/30 rounded-lg p-3 border border-stroke-subtle dark:border-stroke/10`},Kc={class:`text-lg font-semibold text-content-primary dark:text-content-primary font-mono`},qc={class:`bg-background-mute dark:bg-background/30 rounded-lg p-3 border border-stroke-subtle dark:border-stroke/10`},Jc={class:`text-lg font-semibold text-content-primary dark:text-content-primary font-mono`},Yc={class:`bg-background-mute dark:bg-background/30 rounded-lg p-3 border border-stroke-subtle dark:border-stroke/10`},Xc={class:`text-lg font-semibold text-content-primary dark:text-content-primary font-mono`},Zc={class:`bg-background-mute dark:bg-background/30 rounded-lg p-3 border border-stroke-subtle dark:border-stroke/10`},Qc={class:`text-lg font-semibold text-content-primary dark:text-content-primary font-mono`},$c={key:1,class:`flex items-center justify-center py-12`},el={key:2,class:`rounded-lg border border-red-500/30 dark:border-red-400/30 bg-red-50 dark:bg-red-500/10 p-3 mb-4`},tl={class:`text-xs text-red-700 dark:text-red-400`},nl={key:3},rl={class:`overflow-x-auto`},il={class:`w-full text-sm`},al={class:`py-2.5 pr-4`},ol={class:`font-mono text-content-primary dark:text-content-primary`},sl={class:`py-2.5 pr-4 text-right`},cl={class:`font-mono text-content-secondary dark:text-content-muted`},ll={class:`py-2.5 pr-4 text-right hidden sm:table-cell`},ul={key:0,class:`text-xs text-content-muted`},dl={class:`text-content-muted/60 ml-1`},fl={key:1,class:`text-xs text-content-muted/50`},pl={key:2,class:`text-xs text-content-muted/50`},ml={class:`py-2.5 text-right`},hl=[`onClick`,`disabled`],gl={key:0,class:`flex items-center gap-1`},_l={key:1},vl={key:1,class:`text-xs text-content-muted/50`},yl={key:0,class:`glass-card rounded-lg border-2 border-red-500/50 dark:border-red-400/40 bg-red-50 dark:bg-red-500/10 p-6`},bl={class:`flex items-start gap-3`},xl={class:`flex-1`},Sl={class:`text-sm font-semibold text-red-700 dark:text-red-400`},Cl={class:`text-xs text-red-600 dark:text-red-400/80 mt-1`},wl={class:`flex gap-2 mt-3`},Tl=[`disabled`],El=[`disabled`],Dl={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},Ol={class:`flex flex-wrap gap-3`},kl=[`disabled`],Al=[`disabled`],jl={class:`flex items-center gap-2`},Ml={key:0,class:`text-xs text-green-600 dark:text-green-400 mt-3`},Nl={key:1,class:`text-xs text-green-600 dark:text-green-400 mt-3`},Pl=f({__name:`DatabaseManagement`,setup(e){let t=new Set([`packets`,`adverts`,`noise_floor`,`crc_errors`,`room_messages`,`room_client_sync`,`companion_contacts`,`companion_channels`,`companion_messages`,`companion_prefs`]),n=E(!1),r=E(``),a=E(null),o=E({}),c=E(null),l=E(``),u=E(!1),d=E(``),f=v(()=>a.value?a.value.tables.reduce((e,t)=>e+t.row_count,0):0);function p(e){return t.has(e)}function m(e){if(e===0)return`0 B`;let t=[`B`,`KB`,`MB`,`GB`],n=Math.min(Math.floor(Math.log(e)/Math.log(1024)),t.length-1),r=e/1024**n;return`${r<10?r.toFixed(1):Math.round(r)} ${t[n]}`}function h(e){return e?new Date(e*1e3).toLocaleDateString(void 0,{month:`short`,day:`numeric`,year:`numeric`}):`—`}function _(e,t){return!e||!t?0:Math.max(1,Math.round((t-e)/86400))}async function T(){n.value=!0,r.value=``;try{let e=await A.getDbStats();e.success&&e.data?a.value=e.data:r.value=e.error||`Failed to load database stats`}catch(e){r.value=e instanceof Error?e.message:`Failed to load database stats`}finally{n.value=!1}}function D(e,t){l.value=``,c.value={table:e,rowCount:t,executing:!1}}async function O(){if(!c.value)return;let{table:e}=c.value;c.value.executing=!0,l.value=``;try{let t=e===`all`?`all`:[e];e!==`all`&&(o.value[e]=!0);let n=await A.purgeTable(t);if(n.success){let t=n.data||{};l.value=`Deleted ${Object.values(t).reduce((e,t)=>e+(t.deleted||0),0).toLocaleString()} rows${e===`all`?` from all tables`:` from ${e}`}.`,c.value=null,await T()}else r.value=n.error||`Purge failed`,c.value=null}catch(e){r.value=e instanceof Error?e.message:`Purge failed`,c.value=null}finally{e!==`all`&&(o.value[e]=!1)}}async function k(){u.value=!0,d.value=``,r.value=``;try{let e=await A.vacuumDb();if(e.success&&e.data){let t=e.data.freed_bytes;d.value=t>0?`Compacted database — freed ${m(t)} (${m(e.data.size_before)} → ${m(e.data.size_after)}).`:`Database already compact (${m(e.data.size_after)}).`,await T()}else r.value=e.error||`Vacuum failed`}catch(e){r.value=e instanceof Error?e.message:`Vacuum failed`}finally{u.value=!1}}return s(T),(e,t)=>(w(),C(`div`,Rc,[S(`div`,zc,[S(`div`,Bc,[t[3]||=S(`div`,null,[S(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-1`},` Database Overview `),S(`p`,{class:`text-sm text-content-secondary dark:text-content-muted`},` Storage usage and table statistics for the repeater database. `)],-1),S(`button`,{onClick:T,disabled:n.value,class:`px-3 py-1.5 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 text-sm disabled:opacity-50`},[n.value?(w(),C(`span`,Hc,[...t[2]||=[S(`span`,{class:`animate-spin w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full inline-block`},null,-1),y(` Loading… `,-1)]])):(w(),C(`span`,Uc,`Refresh`))],8,Vc)]),a.value?(w(),C(`div`,Wc,[S(`div`,Gc,[t[4]||=S(`p`,{class:`text-xs text-content-muted mb-1`},`Database Size`,-1),S(`p`,Kc,b(m(a.value.database_size_bytes)),1)]),S(`div`,qc,[t[5]||=S(`p`,{class:`text-xs text-content-muted mb-1`},`RRD Metrics`,-1),S(`p`,Jc,b(m(a.value.rrd_size_bytes)),1)]),S(`div`,Yc,[t[6]||=S(`p`,{class:`text-xs text-content-muted mb-1`},`Total Size`,-1),S(`p`,Xc,b(m(a.value.database_size_bytes+a.value.rrd_size_bytes)),1)]),S(`div`,Zc,[t[7]||=S(`p`,{class:`text-xs text-content-muted mb-1`},`Total Rows`,-1),S(`p`,Qc,b(f.value.toLocaleString()),1)])])):g(``,!0),n.value&&!a.value?(w(),C(`div`,$c,[...t[8]||=[S(`div`,{class:`text-center`},[S(`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`}),S(`div`,{class:`text-content-secondary dark:text-content-muted`},`Loading database info…`)],-1)]])):g(``,!0),r.value?(w(),C(`div`,el,[S(`p`,tl,b(r.value),1)])):g(``,!0),a.value&&a.value.tables.length>0?(w(),C(`div`,nl,[S(`div`,rl,[S(`table`,il,[t[10]||=S(`thead`,null,[S(`tr`,{class:`border-b border-stroke-subtle dark:border-stroke/10`},[S(`th`,{class:`text-left py-2 pr-4 text-xs font-medium text-content-muted uppercase tracking-wider`},` Table `),S(`th`,{class:`text-right py-2 pr-4 text-xs font-medium text-content-muted uppercase tracking-wider`},` Rows `),S(`th`,{class:`text-right py-2 pr-4 text-xs font-medium text-content-muted uppercase tracking-wider hidden sm:table-cell`},` Date Range `),S(`th`,{class:`text-right py-2 text-xs font-medium text-content-muted uppercase tracking-wider`},` Actions `)])],-1),S(`tbody`,null,[(w(!0),C(x,null,i(a.value.tables,e=>(w(),C(`tr`,{key:e.name,class:`border-b border-stroke-subtle/50 dark:border-stroke/5`},[S(`td`,al,[S(`span`,ol,b(e.name),1)]),S(`td`,sl,[S(`span`,cl,b(e.row_count.toLocaleString()),1)]),S(`td`,ll,[e.has_timestamp&&e.row_count>0?(w(),C(`span`,ul,[y(b(h(e.oldest_timestamp))+` — `+b(h(e.newest_timestamp))+` `,1),S(`span`,dl,`(`+b(_(e.oldest_timestamp,e.newest_timestamp))+`d)`,1)])):e.row_count===0?(w(),C(`span`,fl,`—`)):(w(),C(`span`,pl,`n/a`))]),S(`td`,ml,[p(e.name)&&e.row_count>0?(w(),C(`button`,{key:0,onClick:t=>D(e.name,e.row_count),disabled:o.value[e.name],class:`px-2.5 py-1 bg-red-500/10 dark:bg-red-400/10 hover:bg-red-500/20 dark:hover:bg-red-400/20 text-red-700 dark:text-red-400 rounded border border-red-500/30 dark:border-red-400/20 transition-colors text-xs disabled:opacity-50`},[o.value[e.name]?(w(),C(`span`,gl,[...t[9]||=[S(`span`,{class:`animate-spin w-3 h-3 border border-current border-t-transparent rounded-full inline-block`},null,-1),y(` Purging… `,-1)]])):(w(),C(`span`,_l,`Empty`))],8,hl)):p(e.name)?g(``,!0):(w(),C(`span`,vl,`—`))])]))),128))])])])])):g(``,!0)]),c.value?(w(),C(`div`,yl,[S(`div`,bl,[t[16]||=S(`svg`,{class:`w-5 h-5 text-red-600 dark:text-red-400 shrink-0 mt-0.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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),S(`div`,xl,[S(`h4`,Sl,b(c.value.table===`all`?`Confirm Purge All Tables`:`Confirm Purge "${c.value.table}"`),1),S(`p`,Cl,[c.value.table===`all`?(w(),C(x,{key:0},[t[11]||=y(` This will permanently delete `,-1),t[12]||=S(`strong`,null,`all data`,-1),y(` from every data table (`+b(f.value.toLocaleString())+` rows total). This cannot be undone. `,1)],64)):(w(),C(x,{key:1},[t[13]||=y(` This will permanently delete `,-1),S(`strong`,null,b(c.value.rowCount.toLocaleString())+` rows`,1),t[14]||=y(` from `,-1),S(`strong`,null,b(c.value.table),1),t[15]||=y(`. This cannot be undone. `,-1)],64))]),S(`div`,wl,[S(`button`,{onClick:O,disabled:c.value.executing,class:`px-4 py-2 bg-red-600 hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-600 text-white rounded-lg transition-colors text-sm disabled:opacity-50`},b(c.value.executing?`Purging…`:`Yes, Delete Data`),9,Tl),S(`button`,{onClick:t[0]||=e=>c.value=null,disabled:c.value.executing,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 text-sm`},` Cancel `,8,El)])])])])):g(``,!0),S(`div`,Dl,[t[19]||=S(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-4`},` Maintenance `,-1),S(`div`,Ol,[S(`button`,{onClick:t[1]||=e=>D(`all`,f.value),disabled:!a.value||f.value===0,class:`px-4 py-2 bg-red-500/20 dark:bg-red-400/20 hover:bg-red-500/30 dark:hover:bg-red-400/30 text-red-900 dark:text-red-200 rounded-lg border border-red-500/50 dark:border-red-400/40 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed`},[...t[17]||=[S(`span`,{class:`flex items-center gap-2`},[S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`})]),y(` Purge All Data `)],-1)]],8,kl),S(`button`,{onClick:k,disabled:u.value||!a.value,class:`px-4 py-2 bg-amber-500/20 dark:bg-amber-400/20 hover:bg-amber-500/30 dark:hover:bg-amber-400/30 text-amber-900 dark:text-amber-200 rounded-lg border border-amber-500/50 dark:border-amber-400/40 transition-colors text-sm disabled:opacity-50 disabled:cursor-not-allowed`},[S(`span`,jl,[t[18]||=S(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`})],-1),y(` `+b(u.value?`Compacting…`:`Compact Database`),1)])],8,Al)]),d.value?(w(),C(`p`,Ml,b(d.value),1)):g(``,!0),l.value?(w(),C(`p`,Nl,b(l.value),1)):g(``,!0)])]))}}),Fl={class:`space-y-6`},Il={class:`glass-card rounded-lg border border-stroke-subtle dark:border-stroke/10 p-6`},Ll={class:`flex items-start justify-between mb-4`},Rl={class:`flex items-center gap-2`},zl=[`disabled`],Bl={key:0,class:`flex items-center gap-1.5`},Vl={key:1},Hl=[`disabled`],Ul={key:0,class:`flex items-center gap-1.5`},Wl={key:1},Gl={key:0,class:`mb-4 p-3 rounded-lg bg-red-500/10 border border-red-500/30 text-red-600 dark:text-red-400 text-sm`},Kl={key:1,class:`mb-4`},ql={class:`bg-background-mute dark:bg-background/30 rounded-lg p-3 border border-stroke-subtle dark:border-stroke/10 inline-block`},Jl={class:`text-lg font-semibold text-content-primary dark:text-content-primary font-mono`},Yl={key:2,class:`p-4 rounded-lg bg-cyan-500/10 dark:bg-primary/10 border border-cyan-400/30 dark:border-primary/30`},Xl={key:3},Zl=[`innerHTML`],Ql={class:`font-semibold text-sm`},$l={class:`text-sm mt-0.5 opacity-80`},eu={class:`grid grid-cols-2 sm:grid-cols-3 gap-3 mb-6`},tu={key:0,class:`bg-background-mute dark:bg-background/30 rounded-lg p-3 border border-stroke-subtle dark:border-stroke/10`},nu={class:`text-lg font-semibold text-content-primary dark:text-content-primary font-mono`},ru={key:1,class:`bg-background-mute dark:bg-background/30 rounded-lg p-3 border border-stroke-subtle dark:border-stroke/10`},iu={class:`text-lg font-semibold text-content-primary dark:text-content-primary font-mono`},au={key:2,class:`bg-background-mute dark:bg-background/30 rounded-lg p-3 border border-stroke-subtle dark:border-stroke/10`},ou={class:`text-lg font-semibold text-content-primary dark:text-content-primary font-mono`},su={key:0,class:`mb-6`},cu={class:`space-y-2`},lu={class:`flex items-start justify-between gap-3`},uu={class:`flex items-center gap-2 min-w-0`},du={class:`shrink-0 text-xs font-medium text-content-muted w-5 text-right`},fu={class:`text-right shrink-0`},pu={class:`text-xs text-content-muted`},mu={class:`mt-1.5 ml-7`},hu={class:`text-xs font-mono text-content-secondary dark:text-content-muted break-all`},gu={key:0,class:`text-xs text-content-muted mt-0.5`},_u={key:1,class:`mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/30 text-sm flex items-center gap-3`},vu={key:2},yu={key:0,class:`overflow-x-auto rounded-lg border border-stroke-subtle dark:border-stroke/10`},bu={class:`w-full text-sm`},xu={class:`px-3 py-2 text-content-muted font-mono text-xs`},Su={class:`px-3 py-2 text-content-primary dark:text-content-primary font-mono text-xs break-all`},Cu={class:`px-3 py-2 text-right font-mono text-xs text-content-secondary dark:text-content-muted whitespace-nowrap`},wu={class:`px-3 py-2 text-right font-mono text-xs text-content-secondary dark:text-content-muted`},Tu={key:4,class:`py-8 text-center text-content-muted text-sm`},Eu=M(f({__name:`MemoryDebug`,setup(e){let n=E(!1),r=E(!1),a=E(``),o=E(null),c=E(!1),l=E(null),f=E(!1),p=v(()=>o.value&&(o.value.current_top_20||o.value.growth_since_baseline));async function m(){n.value=!0,a.value=``;try{let e=await A.get(`memory_debug`);e.success&&e.data?(c.value=!!e.data.tracing,l.value=e.data.rss_mb??null,(e.data.current_top_20||e.data.growth_since_baseline)&&(o.value=e.data)):a.value=e.error||`Failed to fetch memory status`}catch(e){a.value=e instanceof Error?e.message:`Failed to fetch memory status`}finally{n.value=!1}}async function h(){r.value=!0,a.value=``;let e=c.value?`stop`:`start`;try{let t=await A.post(`memory_debug`,{action:e});t.success&&t.data?(c.value=!!t.data.tracing,e===`stop`&&(o.value=null,l.value=null)):a.value=t.error||`Failed to ${e} tracing`}catch(t){a.value=t instanceof Error?t.message:`Failed to ${e} tracing`}finally{r.value=!1,c.value&&await m()}}function T(e){return e.size_diff_kb>=100?`critical`:e.size_diff_kb>=10?`warning`:e.size_diff_kb>=1?`low`:`ok`}function D(e){let t=T(e);return t===`critical`?`Investigate`:t===`warning`?`Watch`:t===`low`?`Minor`:`Normal`}function O(e){let t=T(e);return t===`critical`?`bg-red-500/20 text-red-700 dark:text-red-400 border border-red-500/30`:t===`warning`?`bg-amber-500/20 text-amber-700 dark:text-amber-400 border border-amber-500/30`:t===`low`?`bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-blue-500/20`:`bg-green-500/15 text-green-700 dark:text-green-400 border border-green-500/20`}function k(e){let t=T(e);return t===`critical`?`border-red-500/40 dark:border-red-500/30 bg-red-500/5 dark:bg-red-500/5`:t===`warning`?`border-amber-500/40 dark:border-amber-500/30 bg-amber-500/5 dark:bg-amber-500/5`:t===`low`?`border-stroke-subtle dark:border-stroke/10 bg-background-mute/50 dark:bg-background/20`:`border-stroke-subtle/50 dark:border-stroke/5 bg-background-mute/30 dark:bg-background/10 opacity-60`}function j(e){let t=T(e);return t===`critical`?`text-red-600 dark:text-red-400`:t===`warning`?`text-amber-600 dark:text-amber-400`:t===`low`?`text-blue-600 dark:text-blue-400`:`text-green-600 dark:text-green-500`}function M(e){return e>=1024?`${(e/1024).toFixed(1)} MB`:e>=10?`${Math.round(e)} KB`:`${e.toFixed(1)} KB`}function P(e){return e.replace(/.*\/site-packages\//,``).replace(/.*\/lib\/python[^/]*\//,``).replace(/.*\/repeater\//,`repeater/`)}let F=v(()=>o.value?.growth_since_baseline?o.value.growth_since_baseline.reduce((e,t)=>e+t.size_diff_kb,0):0),I=v(()=>F.value>=500?`critical`:F.value>=50?`warning`:F.value>=5?`low`:`ok`),L=v(()=>{let e=I.value;return e===`critical`?`border-red-500/40 dark:border-red-500/30 bg-red-500/10 dark:bg-red-500/10 text-red-800 dark:text-red-300`:e===`warning`?`border-amber-500/40 dark:border-amber-500/30 bg-amber-500/10 dark:bg-amber-500/10 text-amber-800 dark:text-amber-300`:e===`low`?`border-blue-500/30 bg-blue-500/10 dark:bg-blue-500/10 text-blue-800 dark:text-blue-300`:`border-green-500/30 bg-green-500/10 dark:bg-green-500/10 text-green-800 dark:text-green-300`}),R=v(()=>{let e=I.value;return e===`critical`?``:e===`warning`?``:e===`low`?``:``}),z=v(()=>{let e=I.value;return e===`critical`?`Significant memory growth detected`:e===`warning`?`Some memory growth detected`:e===`low`?`Minor memory growth — likely normal`:`Memory looks healthy`}),B=v(()=>{let e=M(F.value),t=I.value;return t===`critical`?`Total growth: ${e}. Red items below need attention.`:t===`warning`?`Total growth: ${e}. Orange items below may need attention over time.`:t===`low`?`Total growth: ${e}. Nothing to worry about right now.`:`No significant growth since tracing started.`});return s(m),(e,s)=>(w(),C(`div`,Fl,[S(`div`,Il,[S(`div`,Ll,[s[3]||=S(`div`,null,[S(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-1`},` Memory Diagnostics `),S(`p`,{class:`text-sm text-content-secondary dark:text-content-muted`},` Trace memory allocations to find leaks. Tracing adds overhead — only enable when needed. `)],-1),S(`div`,Rl,[c.value&&p.value?(w(),C(`button`,{key:0,onClick:m,disabled:n.value,class:`px-3 py-1.5 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 text-sm disabled:opacity-50`},[n.value?(w(),C(`span`,Bl,[...s[1]||=[S(`span`,{class:`animate-spin w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full inline-block`},null,-1),y(` Checking… `,-1)]])):(w(),C(`span`,Vl,`Check Again`))],8,zl)):g(``,!0),S(`button`,{onClick:h,disabled:r.value,class:u([`px-3 py-1.5 rounded-lg border text-sm transition-colors disabled:opacity-50`,c.value?`bg-red-500/20 hover:bg-red-500/30 text-red-700 dark:text-red-400 border-red-500/50`:`bg-green-500/20 hover:bg-green-500/30 text-green-700 dark:text-green-400 border-green-500/50`])},[r.value?(w(),C(`span`,Ul,[s[2]||=S(`span`,{class:`animate-spin w-3.5 h-3.5 border-2 border-current border-t-transparent rounded-full inline-block`},null,-1),y(` `+b(c.value?`Stopping…`:`Starting…`),1)])):(w(),C(`span`,Wl,b(c.value?`Stop Tracing`:`Start Tracing`),1))],10,Hl)])]),a.value?(w(),C(`div`,Gl,b(a.value),1)):g(``,!0),!c.value&&l.value!==null&&!n.value?(w(),C(`div`,Kl,[S(`div`,ql,[s[4]||=S(`p`,{class:`text-xs text-content-muted mb-1`},`Current Memory (RSS)`,-1),S(`p`,Jl,b(l.value)+` MB`,1)])])):g(``,!0),c.value&&!p.value&&!n.value?(w(),C(`div`,Yl,[...s[5]||=[d(`
Tracing active

Memory tracing is running. Let the repeater run for a few minutes, then click Check Again to see which parts of the code are using more memory.

`,2)]])):g(``,!0),o.value&&p.value?(w(),C(`div`,Xl,[S(`div`,{class:u([`mb-5 p-4 rounded-lg border flex items-start gap-3`,L.value])},[S(`div`,{class:`mt-0.5`,innerHTML:R.value},null,8,Zl),S(`div`,null,[S(`p`,Ql,b(z.value),1),S(`p`,$l,b(B.value),1)])],2),S(`div`,eu,[o.value.rss_mb===void 0?g(``,!0):(w(),C(`div`,tu,[s[6]||=S(`p`,{class:`text-xs text-content-muted mb-1`},`Total Memory Used`,-1),S(`p`,nu,b(o.value.rss_mb)+` MB`,1)])),o.value.traced_current_mb===void 0?g(``,!0):(w(),C(`div`,ru,[s[7]||=S(`p`,{class:`text-xs text-content-muted mb-1`},`Tracked Now`,-1),S(`p`,iu,b(o.value.traced_current_mb)+` MB`,1)])),o.value.traced_peak_mb===void 0?g(``,!0):(w(),C(`div`,au,[s[8]||=S(`p`,{class:`text-xs text-content-muted mb-1`},`Peak Tracked`,-1),S(`p`,ou,b(o.value.traced_peak_mb)+` MB`,1)]))]),o.value.growth_since_baseline&&o.value.growth_since_baseline.length>0?(w(),C(`div`,su,[s[9]||=S(`h4`,{class:`text-sm font-semibold text-content-primary dark:text-content-primary mb-1`},`Memory Growth Breakdown`,-1),s[10]||=S(`p`,{class:`text-xs text-content-muted mb-3`},` Items at the top with red/orange tags are the most likely cause of memory issues. Green items are normal and can be ignored. `,-1),S(`div`,cu,[(w(!0),C(x,null,i(o.value.growth_since_baseline,(e,t)=>(w(),C(`div`,{key:t,class:u([`rounded-lg border p-3 transition-colors`,k(e)])},[S(`div`,lu,[S(`div`,uu,[S(`span`,du,b(t+1),1),S(`span`,{class:u([`shrink-0 inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold`,O(e)])},b(D(e)),3)]),S(`div`,fu,[S(`span`,{class:u([`font-mono text-sm font-semibold`,j(e)])},`+`+b(M(e.size_diff_kb)),3),S(`p`,pu,b(M(e.current_size_kb))+` total`,1)])]),S(`div`,mu,[S(`p`,hu,b(P(e.file)),1),e.count_diff===0?g(``,!0):(w(),C(`p`,gu,b(e.count_diff>0?`+`:``)+b(e.count_diff)+` new allocation`+b(Math.abs(e.count_diff)===1?``:`s`),1))])],2))),128))])])):o.value.growth_since_baseline&&o.value.growth_since_baseline.length===0?(w(),C(`div`,_u,[...s[11]||=[S(`svg`,{class:`w-5 h-5 text-green-600 dark:text-green-400 shrink-0`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`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`})],-1),S(`span`,{class:`text-green-700 dark:text-green-400`},`No memory growth detected. Everything looks healthy.`,-1)]])):g(``,!0),o.value.current_top_20&&o.value.current_top_20.length>0?(w(),C(`div`,vu,[S(`button`,{onClick:s[0]||=e=>f.value=!f.value,class:`flex items-center gap-2 text-sm font-semibold text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors mb-3`},[(w(),C(`svg`,{class:u([`w-4 h-4 transition-transform`,{"rotate-90":f.value}]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...s[12]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M9 5l7 7-7 7`},null,-1)]],2)),s[13]||=y(` Advanced: Current Top Allocations `,-1)]),_(N,{name:`expand`},{default:t(()=>[f.value?(w(),C(`div`,yu,[S(`table`,bu,[s[14]||=S(`thead`,null,[S(`tr`,{class:`bg-background-mute dark:bg-background/30 text-left`},[S(`th`,{class:`px-3 py-2 text-xs font-medium text-content-muted`},`#`),S(`th`,{class:`px-3 py-2 text-xs font-medium text-content-muted`},`Location`),S(`th`,{class:`px-3 py-2 text-xs font-medium text-content-muted text-right`},`Size`),S(`th`,{class:`px-3 py-2 text-xs font-medium text-content-muted text-right`},`Count`)])],-1),S(`tbody`,null,[(w(!0),C(x,null,i(o.value.current_top_20,(e,t)=>(w(),C(`tr`,{key:t,class:`border-t border-stroke-subtle/50 dark:border-stroke/5 hover:bg-background-mute/50 dark:hover:bg-background/20 transition-colors`},[S(`td`,xu,b(t+1),1),S(`td`,Su,b(P(e.file)),1),S(`td`,Cu,b(M(e.size_kb)),1),S(`td`,wu,b(e.count),1)]))),128))])])])):g(``,!0)]),_:1})])):g(``,!0)])):g(``,!0),!n.value&&!r.value&&!c.value&&!p.value&&!a.value&&l.value===null?(w(),C(`div`,Tu,[...s[15]||=[y(` Click `,-1),S(`strong`,null,`Start Tracing`,-1),y(` to enable memory diagnostics. `,-1),S(`br`,null,null,-1),S(`span`,{class:`text-xs`},`Tracing uses extra memory — remember to stop it when done.`,-1)]])):g(``,!0)])]))}}),[[`__scopeId`,`data-v-50d93367`]]),Du={class:`p-3 sm:p-6 space-y-4 sm:space-y-6`},Ou={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`},ku={class:`text-cyan-700 dark:text-primary text-sm sm:text-base`},Au={class:`mt-1 sm:mt-2 text-cyan-600 dark:text-primary/80`},ju={class:`glass-card rounded-[15px] p-3 sm:p-6`},Mu={class:`relative -mx-3 sm:mx-0 mb-4 sm:mb-6`},Nu={key:0,class:`absolute left-0 top-0 bottom-[1px] w-12 z-10 flex items-center`},Pu={key:0,class:`absolute right-0 top-0 bottom-[1px] w-12 z-10 flex items-center justify-end`},Fu=[`onClick`],Iu={class:`flex items-center gap-1 sm:gap-2`},Lu={key:0,class:`w-3.5 h-3.5 sm:w-4 sm:h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Ru={key:1,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},zu={key:2,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Bu={key:3,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Vu={key:4,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Hu={key:5,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Uu={key:6,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Wu={key:7,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Gu={key:8,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Ku={key:9,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},qu={key:10,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Ju={key:11,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Yu={class:`min-h-[400px]`},Xu={key:0,class:`flex items-center justify-center py-12`},Zu={key:1,class:`flex items-center justify-center py-12`},Qu={class:`text-center`},$u={class:`text-content-secondary dark:text-content-muted text-sm mb-4`},ed={key:2},td=M(f({name:`ConfigurationView`,__name:`Configuration`,setup(e){let n=j(),o=E(H(`configuration_activeTab`,`radio`)),l=E(!1),d=E(null),f=E(!1),p=E(!1);function v(){if(!d.value)return;let e=d.value;p.value=e.scrollLeft>4,f.value=e.scrollLeftee(`configuration_activeTab`,e));let D=[{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`},{id:`observer`,label:`Observer`,icon:`observer`},{id:`backup`,label:`Backup`,icon:`backup`},{id:`database`,label:`Database`,icon:`database`},{id:`memory`,label:`Memory`,icon:`memory`}];s(async()=>{try{await n.fetchStats(),l.value=!0}catch(e){console.error(`Failed to load configuration data:`,e),l.value=!0}c(()=>v())});function O(e){o.value=e}return(e,s)=>{let c=r(`router-link`);return w(),C(`div`,Du,[s[24]||=S(`div`,null,[S(`h1`,{class:`text-xl sm:text-2xl font-bold text-content-primary dark:text-content-primary`},` Configuration `),S(`p`,{class:`text-content-secondary dark:text-content-muted mt-1 sm:mt-2 text-sm sm:text-base`},` System configuration and settings `)],-1),S(`div`,Ou,[S(`div`,ku,[s[5]||=S(`strong`,null,`CAD Calibration Tool Available`,-1),S(`p`,Au,[s[4]||=y(` Optimize your Channel Activity Detection settings. `,-1),_(c,{to:`/cad-calibration`,class:`underline hover:text-cyan-800 dark:hover:text-primary transition-colors`},{default:t(()=>[...s[3]||=[y(` Launch CAD Calibration Tool → `,-1)]]),_:1})])])]),S(`div`,ju,[S(`div`,Mu,[_(N,{name:`tab-fade`},{default:t(()=>[p.value?(w(),C(`div`,Nu,[s[7]||=S(`div`,{class:`tab-fade-left absolute inset-0 pointer-events-none`},null,-1),S(`button`,{onClick:s[0]||=e=>T(`left`),class:`relative z-10 ml-1.5 w-6 h-6 flex items-center justify-center rounded-full bg-white dark:bg-zinc-900 shadow-md border border-gray-200 dark:border-white/10 text-gray-500 dark:text-gray-300`},[...s[6]||=[S(`svg`,{class:`w-3 h-3`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2.5`,d:`M15 19l-7-7 7-7`})],-1)]])])):g(``,!0)]),_:1}),_(N,{name:`tab-fade`},{default:t(()=>[f.value?(w(),C(`div`,Pu,[s[9]||=S(`div`,{class:`tab-fade-right absolute inset-0 pointer-events-none`},null,-1),S(`button`,{onClick:s[1]||=e=>T(`right`),class:`relative z-10 mr-1.5 w-6 h-6 flex items-center justify-center rounded-full bg-white dark:bg-zinc-900 shadow-md border border-gray-200 dark:border-white/10 text-gray-500 dark:text-gray-300`},[...s[8]||=[S(`svg`,{class:`w-3 h-3`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2.5`,d:`M9 5l7 7-7 7`})],-1)]])])):g(``,!0)]),_:1}),S(`div`,{ref_key:`tabsContainer`,ref:d,onScroll:v,class:`flex overflow-x-auto border-b border-stroke-subtle dark:border-stroke/10 px-3 sm:px-0 scrollbar-hide`},[(w(),C(x,null,i(D,e=>S(`button`,{key:e.id,onClick:t=>O(e.id),class:u([`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`,o.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`])},[S(`div`,Iu,[e.icon===`radio`?(w(),C(`svg`,Lu,[...s[10]||=[S(`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)]])):e.icon===`repeater`?(w(),C(`svg`,Ru,[...s[11]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 12h14M5 12l4-4m-4 4l4 4`},null,-1)]])):e.icon===`advert`?(w(),C(`svg`,zu,[...s[12]||=[S(`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)]])):e.icon===`duty`?(w(),C(`svg`,Bu,[...s[13]||=[S(`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)]])):e.icon===`delays`?(w(),C(`svg`,Vu,[...s[14]||=[S(`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)]])):e.icon===`keys`?(w(),C(`svg`,Hu,[...s[15]||=[S(`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.icon===`tokens`?(w(),C(`svg`,Uu,[...s[16]||=[S(`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)]])):e.icon===`web`?(w(),C(`svg`,Wu,[...s[17]||=[S(`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)]])):e.icon===`observer`?(w(),C(`svg`,Gu,[...s[18]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z`},null,-1)]])):e.icon===`backup`?(w(),C(`svg`,Ku,[...s[19]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4`},null,-1)]])):e.icon===`database`?(w(),C(`svg`,qu,[...s[20]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4`},null,-1)]])):e.icon===`memory`?(w(),C(`svg`,Ju,[...s[21]||=[S(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z`},null,-1)]])):g(``,!0),y(` `+b(e.label),1)])],10,Fu)),64))],544)]),S(`div`,Yu,[!l.value&&a(n).isLoading?(w(),C(`div`,Xu,[...s[22]||=[S(`div`,{class:`text-center`},[S(`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`}),S(`div`,{class:`text-content-secondary dark:text-content-muted`},` Loading configuration... `)],-1)]])):a(n).error&&!l.value?(w(),C(`div`,Zu,[S(`div`,Qu,[s[23]||=S(`div`,{class:`text-red-500 dark:text-red-400 mb-2`},`Failed to load configuration`,-1),S(`div`,$u,b(a(n).error),1),S(`button`,{onClick:s[2]||=e=>a(n).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 `)])])):(w(),C(`div`,ed,[m(S(`div`,null,[_(ye,{key:`radio-settings`})],512),[[z,o.value===`radio`]]),m(S(`div`,null,[_(St,{key:`repeater-settings`})],512),[[z,o.value===`repeater`]]),m(S(`div`,null,[_(so,{key:`advert-settings`})],512),[[z,o.value===`advert`]]),m(S(`div`,null,[_(Pt,{key:`duty-cycle`})],512),[[z,o.value===`duty`]]),m(S(`div`,null,[_(Jt,{key:`transmission-delays`})],512),[[z,o.value===`delays`]]),m(S(`div`,null,[_(Jr,{key:`transport-keys`})],512),[[z,o.value===`transport`]]),m(S(`div`,null,[_(Si,{key:`api-tokens`})],512),[[z,o.value===`api-tokens`]]),m(S(`div`,null,[_(qi,{key:`web-settings`})],512),[[z,o.value===`web`]]),m(S(`div`,null,[_(js,{key:`letsmesh-settings`})],512),[[z,o.value===`observer`]]),m(S(`div`,null,[_(Lc,{key:`backup-restore`})],512),[[z,o.value===`backup`]]),m(S(`div`,null,[_(Pl,{key:`database-management`})],512),[[z,o.value===`database`]]),m(S(`div`,null,[_(Eu,{key:`memory-debug`})],512),[[z,o.value===`memory`]])]))])])])}}}),[[`__scopeId`,`data-v-e8f5e632`]]);export{td as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Configuration-zQuuYGWe.css b/repeater/web/html/assets/Configuration-zQuuYGWe.css new file mode 100644 index 0000000..c29dff2 --- /dev/null +++ b/repeater/web/html/assets/Configuration-zQuuYGWe.css @@ -0,0 +1 @@ +.leaflet-pane[data-v-fd94857e],.leaflet-tile[data-v-fd94857e],.leaflet-marker-icon[data-v-fd94857e],.leaflet-marker-shadow[data-v-fd94857e],.leaflet-tile-container[data-v-fd94857e],.leaflet-pane>svg[data-v-fd94857e],.leaflet-pane>canvas[data-v-fd94857e],.leaflet-zoom-box[data-v-fd94857e],.leaflet-image-layer[data-v-fd94857e],.leaflet-layer[data-v-fd94857e]{position:absolute;top:0;left:0}.leaflet-container[data-v-fd94857e]{overflow:hidden}.leaflet-tile[data-v-fd94857e],.leaflet-marker-icon[data-v-fd94857e],.leaflet-marker-shadow[data-v-fd94857e]{-webkit-user-select:none;user-select:none;-webkit-user-drag:none}.leaflet-tile[data-v-fd94857e]::selection{background:0 0}.leaflet-safari .leaflet-tile[data-v-fd94857e]{image-rendering:-webkit-optimize-contrast}.leaflet-safari .leaflet-tile-container[data-v-fd94857e]{-webkit-transform-origin:0 0;width:1600px;height:1600px}.leaflet-marker-icon[data-v-fd94857e],.leaflet-marker-shadow[data-v-fd94857e]{display:block}.leaflet-container .leaflet-overlay-pane svg[data-v-fd94857e]{max-width:none!important;max-height:none!important}.leaflet-container .leaflet-marker-pane img[data-v-fd94857e],.leaflet-container .leaflet-shadow-pane img[data-v-fd94857e],.leaflet-container .leaflet-tile-pane img[data-v-fd94857e],.leaflet-container img.leaflet-image-layer[data-v-fd94857e],.leaflet-container .leaflet-tile[data-v-fd94857e]{width:auto;padding:0;max-width:none!important;max-height:none!important}.leaflet-container img.leaflet-tile[data-v-fd94857e]{mix-blend-mode:plus-lighter}.leaflet-container.leaflet-touch-zoom[data-v-fd94857e]{-ms-touch-action:pan-x pan-y;touch-action:pan-x pan-y}.leaflet-container.leaflet-touch-drag[data-v-fd94857e]{-ms-touch-action:pinch-zoom;touch-action:none;touch-action:pinch-zoom}.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom[data-v-fd94857e]{-ms-touch-action:none;touch-action:none}.leaflet-container[data-v-fd94857e]{-webkit-tap-highlight-color:transparent}.leaflet-container a[data-v-fd94857e]{-webkit-tap-highlight-color:#33b5e566}.leaflet-tile[data-v-fd94857e]{filter:inherit;visibility:hidden}.leaflet-tile-loaded[data-v-fd94857e]{visibility:inherit}.leaflet-zoom-box[data-v-fd94857e]{box-sizing:border-box;z-index:800;width:0;height:0}.leaflet-overlay-pane svg[data-v-fd94857e]{-moz-user-select:none}.leaflet-pane[data-v-fd94857e]{z-index:400}.leaflet-tile-pane[data-v-fd94857e]{z-index:200}.leaflet-overlay-pane[data-v-fd94857e]{z-index:400}.leaflet-shadow-pane[data-v-fd94857e]{z-index:500}.leaflet-marker-pane[data-v-fd94857e]{z-index:600}.leaflet-tooltip-pane[data-v-fd94857e]{z-index:650}.leaflet-popup-pane[data-v-fd94857e]{z-index:700}.leaflet-map-pane canvas[data-v-fd94857e]{z-index:100}.leaflet-map-pane svg[data-v-fd94857e]{z-index:200}.leaflet-vml-shape[data-v-fd94857e]{width:1px;height:1px}.lvml[data-v-fd94857e]{behavior:url(#default#VML);display:inline-block;position:absolute}.leaflet-control[data-v-fd94857e]{z-index:800;pointer-events:visiblePainted;pointer-events:auto;position:relative}.leaflet-top[data-v-fd94857e],.leaflet-bottom[data-v-fd94857e]{z-index:1000;pointer-events:none;position:absolute}.leaflet-top[data-v-fd94857e]{top:0}.leaflet-right[data-v-fd94857e]{right:0}.leaflet-bottom[data-v-fd94857e]{bottom:0}.leaflet-left[data-v-fd94857e]{left:0}.leaflet-control[data-v-fd94857e]{float:left;clear:both}.leaflet-right .leaflet-control[data-v-fd94857e]{float:right}.leaflet-top .leaflet-control[data-v-fd94857e]{margin-top:10px}.leaflet-bottom .leaflet-control[data-v-fd94857e]{margin-bottom:10px}.leaflet-left .leaflet-control[data-v-fd94857e]{margin-left:10px}.leaflet-right .leaflet-control[data-v-fd94857e]{margin-right:10px}.leaflet-fade-anim .leaflet-popup[data-v-fd94857e]{opacity:0;transition:opacity .2s linear}.leaflet-fade-anim .leaflet-map-pane .leaflet-popup[data-v-fd94857e]{opacity:1}.leaflet-zoom-animated[data-v-fd94857e]{transform-origin:0 0}svg.leaflet-zoom-animated[data-v-fd94857e]{will-change:transform}.leaflet-zoom-anim .leaflet-zoom-animated[data-v-fd94857e]{-webkit-transition:-webkit-transform .25s cubic-bezier(0,0,.25,1);-moz-transition:-moz-transform .25s cubic-bezier(0,0,.25,1);transition:transform .25s cubic-bezier(0,0,.25,1)}.leaflet-zoom-anim .leaflet-tile[data-v-fd94857e],.leaflet-pan-anim .leaflet-tile[data-v-fd94857e]{transition:none}.leaflet-zoom-anim .leaflet-zoom-hide[data-v-fd94857e]{visibility:hidden}.leaflet-interactive[data-v-fd94857e]{cursor:pointer}.leaflet-grab[data-v-fd94857e]{cursor:-webkit-grab;cursor:-moz-grab;cursor:grab}.leaflet-crosshair[data-v-fd94857e],.leaflet-crosshair .leaflet-interactive[data-v-fd94857e]{cursor:crosshair}.leaflet-popup-pane[data-v-fd94857e],.leaflet-control[data-v-fd94857e]{cursor:auto}.leaflet-dragging .leaflet-grab[data-v-fd94857e],.leaflet-dragging .leaflet-grab .leaflet-interactive[data-v-fd94857e],.leaflet-dragging .leaflet-marker-draggable[data-v-fd94857e]{cursor:move;cursor:-webkit-grabbing;cursor:-moz-grabbing;cursor:grabbing}.leaflet-marker-icon[data-v-fd94857e],.leaflet-marker-shadow[data-v-fd94857e],.leaflet-image-layer[data-v-fd94857e],.leaflet-pane>svg path[data-v-fd94857e],.leaflet-tile-container[data-v-fd94857e]{pointer-events:none}.leaflet-marker-icon.leaflet-interactive[data-v-fd94857e],.leaflet-image-layer.leaflet-interactive[data-v-fd94857e],.leaflet-pane>svg path.leaflet-interactive[data-v-fd94857e],svg.leaflet-image-layer.leaflet-interactive path[data-v-fd94857e]{pointer-events:visiblePainted;pointer-events:auto}.leaflet-container[data-v-fd94857e]{outline-offset:1px;background:#ddd}.leaflet-container a[data-v-fd94857e]{color:#0078a8}.leaflet-zoom-box[data-v-fd94857e]{background:#ffffff80;border:2px dotted #38f}.leaflet-container[data-v-fd94857e]{font-family:Helvetica Neue,Arial,Helvetica,sans-serif;font-size:.75rem;line-height:1.5}.leaflet-bar[data-v-fd94857e]{border-radius:4px;box-shadow:0 1px 5px #000000a6}.leaflet-bar a[data-v-fd94857e]{text-align:center;color:#000;background-color:#fff;border-bottom:1px solid #ccc;width:26px;height:26px;line-height:26px;text-decoration:none;display:block}.leaflet-bar a[data-v-fd94857e],.leaflet-control-layers-toggle[data-v-fd94857e]{background-position:50%;background-repeat:no-repeat;display:block}.leaflet-bar a[data-v-fd94857e]:hover,.leaflet-bar a[data-v-fd94857e]:focus{background-color:#f4f4f4}.leaflet-bar a[data-v-fd94857e]:first-child{border-top-left-radius:4px;border-top-right-radius:4px}.leaflet-bar a[data-v-fd94857e]:last-child{border-bottom:none;border-bottom-right-radius:4px;border-bottom-left-radius:4px}.leaflet-bar a.leaflet-disabled[data-v-fd94857e]{cursor:default;color:#bbb;background-color:#f4f4f4}.leaflet-touch .leaflet-bar a[data-v-fd94857e]{width:30px;height:30px;line-height:30px}.leaflet-touch .leaflet-bar a[data-v-fd94857e]:first-child{border-top-left-radius:2px;border-top-right-radius:2px}.leaflet-touch .leaflet-bar a[data-v-fd94857e]:last-child{border-bottom-right-radius:2px;border-bottom-left-radius:2px}.leaflet-control-zoom-in[data-v-fd94857e],.leaflet-control-zoom-out[data-v-fd94857e]{text-indent:1px;font:700 18px Lucida Console,Monaco,monospace}.leaflet-touch .leaflet-control-zoom-in[data-v-fd94857e],.leaflet-touch .leaflet-control-zoom-out[data-v-fd94857e]{font-size:22px}.leaflet-control-layers[data-v-fd94857e]{background:#fff;border-radius:5px;box-shadow:0 1px 5px #0006}.leaflet-control-layers-toggle[data-v-fd94857e]{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAaCAQAAAADQ4RFAAACf0lEQVR4AY1UM3gkARTePdvdoTxXKc+qTl3aU5U6b2Kbkz3Gtq3Zw6ziLGNPzrYx7946Tr6/ee/XeCQ4D3ykPtL5tHno4n0d/h3+xfuWHGLX81cn7r0iTNzjr7LrlxCqPtkbTQEHeqOrTy4Yyt3VCi/IOB0v7rVC7q45Q3Gr5K6jt+3Gl5nCoDD4MtO+j96Wu8atmhGqcNGHObuf8OM/x3AMx38+4Z2sPqzCxRFK2aF2e5Jol56XTLyggAMTL56XOMoS1W4pOyjUcGGQdZxU6qRh7B9Zp+PfpOFlqt0zyDZckPi1ttmIp03jX8gyJ8a/PG2yutpS/Vol7peZIbZcKBAEEheEIAgFbDkz5H6Zrkm2hVWGiXKiF4Ycw0RWKdtC16Q7qe3X4iOMxruonzegJzWaXFrU9utOSsLUmrc0YjeWYjCW4PDMADElpJSSQ0vQvA1Tm6/JlKnqFs1EGyZiFCqnRZTEJJJiKRYzVYzJck2Rm6P4iH+cmSY0YzimYa8l0EtTODFWhcMIMVqdsI2uiTvKmTisIDHJ3od5GILVhBCarCfVRmo4uTjkhrhzkiBV7SsaqS+TzrzM1qpGGUFt28pIySQHR6h7F6KSwGWm97ay+Z+ZqMcEjEWebE7wxCSQwpkhJqoZA5ivCdZDjJepuJ9IQjGGUmuXJdBFUygxVqVsxFsLMbDe8ZbDYVCGKxs+W080max1hFCarCfV+C1KATwcnvE9gRRuMP2prdbWGowm1KB1y+zwMMENkM755cJ2yPDtqhTI6ED1M/82yIDtC/4j4BijjeObflpO9I9MwXTCsSX8jWAFeHr05WoLTJ5G8IQVS/7vwR6ohirYM7f6HzYpogfS3R2OAAAAAElFTkSuQmCC);width:36px;height:36px}.leaflet-retina .leaflet-control-layers-toggle[data-v-fd94857e]{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADQAAAA0CAQAAABvcdNgAAAEsklEQVR4AWL4TydIhpZK1kpWOlg0w3ZXP6D2soBtG42jeI6ZmQTHzAxiTbSJsYLjO9HhP+WOmcuhciVnmHVQcJnp7DFvScowZorad/+V/fVzMdMT2g9Cv9guXGv/7pYOrXh2U+RRR3dSd9JRx6bIFc/ekqHI29JC6pJ5ZEh1yWkhkbcFeSjxgx3L2m1cb1C7bceyxA+CNjT/Ifff+/kDk2u/w/33/IeCMOSaWZ4glosqT3DNnNZQ7Cs58/3Ce5HL78iZH/vKVIaYlqzfdLu8Vi7dnvUbEza5Idt36tquZFldl6N5Z/POLof0XLK61mZCmJSWjVF9tEjUluu74IUXvgttuVIHE7YxSkaYhJZam7yiM9Pv82JYfl9nptxZaxMJE4YSPty+vF0+Y2up9d3wwijfjZbabqm/3bZ9ecKHsiGmRflnn1MW4pjHf9oLufyn2z3y1D6n8g8TZhxyzipLNPnAUpsOiuWimg52psrTZYnOWYNDTMuWBWa0tJb4rgq1UvmutpaYEbZlwU3CLJm/ayYjHW5/h7xWLn9Hh1vepDkyf7dE7MtT5LR4e7yYpHrkhOUpEfssBLq2pPhAqoSWKUkk7EDqkmK6RrCEzqDjhNDWNE+XSMvkJRDWlZTmCW0l0PHQGRZY5t1L83kT0Y3l2SItk5JAWHl2dCOBm+fPu3fo5/3v61RMCO9Jx2EEYYhb0rmNQMX/vm7gqOEJLcXTGw3CAuRNeyaPWwjR8PRqKQ1PDA/dpv+on9Shox52WFnx0KY8onHayrJzm87i5h9xGw/tfkev0jGsQizqezUKjk12hBMKJ4kbCqGPVNXudyyrShovGw5CgxsRICxF6aRmSjlBnHRzg7Gx8fKqEubI2rahQYdR1YgDIRQO7JvQyD52hoIQx0mxa0ODtW2Iozn1le2iIRdzwWewedyZzewidueOGqlsn1MvcnQpuVwLGG3/IR1hIKxCjelIDZ8ldqWz25jWAsnldEnK0Zxro19TGVb2ffIZEsIO89EIEDvKMPrzmBOQcKQ+rroye6NgRRxqR4U8EAkz0CL6uSGOm6KQCdWjvjRiSP1BPalCRS5iQYiEIvxuBMJEWgzSoHADcVMuN7IuqqTeyUPq22qFimFtxDyBBJEwNyt6TM88blFHao/6tWWhuuOM4SAK4EI4QmFHA+SEyWlp4EQoJ13cYGzMu7yszEIBOm2rVmHUNqwAIQabISNMRstmdhNWcFLsSm+0tjJH1MdRxO5Nx0WDMhCtgD6OKgZeljJqJKc9po8juskR9XN0Y1lZ3mWjLR9JCO1jRDMd0fpYC2VnvjBSEFg7wBENc0R9HFlb0xvF1+TBEpF68d+DHR6IOWVv2BECtxo46hOFUBd/APU57WIoEwJhIi2CdpyZX0m93BZicktMj1AS9dClteUFAUNUIEygRZCtik5zSxI9MubTBH1GOiHsiLJ3OCoSZkILa9PxiN0EbvhsAo8tdAf9Seepd36lGWHmtNANTv5Jd0z4QYyeo/UEJqxKRpg5LZx6btLPsOaEmdMyxYdlc8LMaJnikDlhclqmPiQnTEpLUIZEwkRagjYkEibQErwhkTAKCLQEbUgkzJQWc/0PstHHcfEdQ+UAAAAASUVORK5CYII=);background-size:26px 26px}.leaflet-touch .leaflet-control-layers-toggle[data-v-fd94857e]{width:44px;height:44px}.leaflet-control-layers .leaflet-control-layers-list[data-v-fd94857e],.leaflet-control-layers-expanded .leaflet-control-layers-toggle[data-v-fd94857e]{display:none}.leaflet-control-layers-expanded .leaflet-control-layers-list[data-v-fd94857e]{display:block;position:relative}.leaflet-control-layers-expanded[data-v-fd94857e]{color:#333;background:#fff;padding:6px 10px 6px 6px}.leaflet-control-layers-scrollbar[data-v-fd94857e]{padding-right:5px;overflow:hidden scroll}.leaflet-control-layers-selector[data-v-fd94857e]{margin-top:2px;position:relative;top:1px}.leaflet-control-layers label[data-v-fd94857e]{font-size:1.08333em;display:block}.leaflet-control-layers-separator[data-v-fd94857e]{border-top:1px solid #ddd;height:0;margin:5px -10px 5px -6px}.leaflet-default-icon-path[data-v-fd94857e]{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAApCAYAAADAk4LOAAAFgUlEQVR4Aa1XA5BjWRTN2oW17d3YaZtr2962HUzbDNpjszW24mRt28p47v7zq/bXZtrp/lWnXr337j3nPCe85NcypgSFdugCpW5YoDAMRaIMqRi6aKq5E3YqDQO3qAwjVWrD8Ncq/RBpykd8oZUb/kaJutow8r1aP9II0WmLKLIsJyv1w/kqw9Ch2MYdB++12Onxee/QMwvf4/Dk/Lfp/i4nxTXtOoQ4pW5Aj7wpici1A9erdAN2OH64x8OSP9j3Ft3b7aWkTg/Fm91siTra0f9on5sQr9INejH6CUUUpavjFNq1B+Oadhxmnfa8RfEmN8VNAsQhPqF55xHkMzz3jSmChWU6f7/XZKNH+9+hBLOHYozuKQPxyMPUKkrX/K0uWnfFaJGS1QPRtZsOPtr3NsW0uyh6NNCOkU3Yz+bXbT3I8G3xE5EXLXtCXbbqwCO9zPQYPRTZ5vIDXD7U+w7rFDEoUUf7ibHIR4y6bLVPXrz8JVZEql13trxwue/uDivd3fkWRbS6/IA2bID4uk0UpF1N8qLlbBlXs4Ee7HLTfV1j54APvODnSfOWBqtKVvjgLKzF5YdEk5ewRkGlK0i33Eofffc7HT56jD7/6U+qH3Cx7SBLNntH5YIPvODnyfIXZYRVDPqgHtLs5ABHD3YzLuespb7t79FY34DjMwrVrcTuwlT55YMPvOBnRrJ4VXTdNnYug5ucHLBjEpt30701A3Ts+HEa73u6dT3FNWwflY86eMHPk+Yu+i6pzUpRrW7SNDg5JHR4KapmM5Wv2E8Tfcb1HoqqHMHU+uWDD7zg54mz5/2BSnizi9T1Dg4QQXLToGNCkb6tb1NU+QAlGr1++eADrzhn/u8Q2YZhQVlZ5+CAOtqfbhmaUCS1ezNFVm2imDbPmPng5wmz+gwh+oHDce0eUtQ6OGDIyR0uUhUsoO3vfDmmgOezH0mZN59x7MBi++WDL1g/eEiU3avlidO671bkLfwbw5XV2P8Pzo0ydy4t2/0eu33xYSOMOD8hTf4CrBtGMSoXfPLchX+J0ruSePw3LZeK0juPJbYzrhkH0io7B3k164hiGvawhOKMLkrQLyVpZg8rHFW7E2uHOL888IBPlNZ1FPzstSJM694fWr6RwpvcJK60+0HCILTBzZLFNdtAzJaohze60T8qBzyh5ZuOg5e7uwQppofEmf2++DYvmySqGBuKaicF1blQjhuHdvCIMvp8whTTfZzI7RldpwtSzL+F1+wkdZ2TBOW2gIF88PBTzD/gpeREAMEbxnJcaJHNHrpzji0gQCS6hdkEeYt9DF/2qPcEC8RM28Hwmr3sdNyht00byAut2k3gufWNtgtOEOFGUwcXWNDbdNbpgBGxEvKkOQsxivJx33iow0Vw5S6SVTrpVq11ysA2Rp7gTfPfktc6zhtXBBC+adRLshf6sG2RfHPZ5EAc4sVZ83yCN00Fk/4kggu40ZTvIEm5g24qtU4KjBrx/BTTH8ifVASAG7gKrnWxJDcU7x8X6Ecczhm3o6YicvsLXWfh3Ch1W0k8x0nXF+0fFxgt4phz8QvypiwCCFKMqXCnqXExjq10beH+UUA7+nG6mdG/Pu0f3LgFcGrl2s0kNNjpmoJ9o4B29CMO8dMT4Q5ox8uitF6fqsrJOr8qnwNbRzv6hSnG5wP+64C7h9lp30hKNtKdWjtdkbuPA19nJ7Tz3zR/ibgARbhb4AlhavcBebmTHcFl2fvYEnW0ox9xMxKBS8btJ+KiEbq9zA4RthQXDhPa0T9TEe69gWupwc6uBUphquXgf+/FrIjweHQS4/pduMe5ERUMHUd9xv8ZR98CxkS4F2n3EUrUZ10EYNw7BWm9x1GiPssi3GgiGRDKWRYZfXlON+dfNbM+GgIwYdwAAAAASUVORK5CYII=)}.leaflet-container .leaflet-control-attribution[data-v-fd94857e]{background:#fffc;margin:0}.leaflet-control-attribution[data-v-fd94857e],.leaflet-control-scale-line[data-v-fd94857e]{color:#333;padding:0 5px;line-height:1.4}.leaflet-control-attribution a[data-v-fd94857e]{text-decoration:none}.leaflet-control-attribution a[data-v-fd94857e]:hover,.leaflet-control-attribution a[data-v-fd94857e]:focus{text-decoration:underline}.leaflet-attribution-flag[data-v-fd94857e]{width:1em;height:.6669em;vertical-align:baseline!important;display:inline!important}.leaflet-left .leaflet-control-scale[data-v-fd94857e]{margin-left:5px}.leaflet-bottom .leaflet-control-scale[data-v-fd94857e]{margin-bottom:5px}.leaflet-control-scale-line[data-v-fd94857e]{white-space:nowrap;box-sizing:border-box;text-shadow:1px 1px #fff;background:#fffc;border:2px solid #777;border-top:none;padding:2px 5px 1px;line-height:1.1}.leaflet-control-scale-line[data-v-fd94857e]:not(:first-child){border-top:2px solid #777;border-bottom:none;margin-top:-2px}.leaflet-control-scale-line[data-v-fd94857e]:not(:first-child):not(:last-child){border-bottom:2px solid #777}.leaflet-touch .leaflet-control-attribution[data-v-fd94857e],.leaflet-touch .leaflet-control-layers[data-v-fd94857e],.leaflet-touch .leaflet-bar[data-v-fd94857e]{box-shadow:none}.leaflet-touch .leaflet-control-layers[data-v-fd94857e],.leaflet-touch .leaflet-bar[data-v-fd94857e]{background-clip:padding-box;border:2px solid #0003}.leaflet-popup[data-v-fd94857e]{text-align:center;margin-bottom:20px;position:absolute}.leaflet-popup-content-wrapper[data-v-fd94857e]{text-align:left;border-radius:12px;padding:1px}.leaflet-popup-content[data-v-fd94857e]{min-height:1px;margin:13px 24px 13px 20px;font-size:1.08333em;line-height:1.3}.leaflet-popup-content p[data-v-fd94857e]{margin:1.3em 0}.leaflet-popup-tip-container[data-v-fd94857e]{pointer-events:none;width:40px;height:20px;margin-top:-1px;margin-left:-20px;position:absolute;left:50%;overflow:hidden}.leaflet-popup-tip[data-v-fd94857e]{pointer-events:auto;width:17px;height:17px;margin:-10px auto 0;padding:1px;transform:rotate(45deg)}.leaflet-popup-content-wrapper[data-v-fd94857e],.leaflet-popup-tip[data-v-fd94857e]{color:#333;background:#fff;box-shadow:0 3px 14px #0006}.leaflet-container a.leaflet-popup-close-button[data-v-fd94857e]{text-align:center;color:#757575;background:0 0;border:none;width:24px;height:24px;font:16px/24px Tahoma,Verdana,sans-serif;text-decoration:none;position:absolute;top:0;right:0}.leaflet-container a.leaflet-popup-close-button[data-v-fd94857e]:hover,.leaflet-container a.leaflet-popup-close-button[data-v-fd94857e]:focus{color:#585858}.leaflet-popup-scrolled[data-v-fd94857e]{overflow:auto}.leaflet-oldie .leaflet-popup-content-wrapper[data-v-fd94857e]{-ms-zoom:1}.leaflet-oldie .leaflet-popup-tip[data-v-fd94857e]{-ms-filter:"progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";width:24px;filter:progid:DXImageTransform.Microsoft.Matrix(M11=.707107, M12=.707107, M21=-.707107, M22=.707107);margin:0 auto}.leaflet-oldie .leaflet-control-zoom[data-v-fd94857e],.leaflet-oldie .leaflet-control-layers[data-v-fd94857e],.leaflet-oldie .leaflet-popup-content-wrapper[data-v-fd94857e],.leaflet-oldie .leaflet-popup-tip[data-v-fd94857e]{border:1px solid #999}.leaflet-div-icon[data-v-fd94857e]{background:#fff;border:1px solid #666}.leaflet-tooltip[data-v-fd94857e]{color:#222;white-space:nowrap;-webkit-user-select:none;user-select:none;pointer-events:none;background-color:#fff;border:1px solid #fff;border-radius:3px;padding:6px;position:absolute;box-shadow:0 1px 3px #0006}.leaflet-tooltip.leaflet-interactive[data-v-fd94857e]{cursor:pointer;pointer-events:auto}.leaflet-tooltip-top[data-v-fd94857e]:before,.leaflet-tooltip-bottom[data-v-fd94857e]:before,.leaflet-tooltip-left[data-v-fd94857e]:before,.leaflet-tooltip-right[data-v-fd94857e]:before{pointer-events:none;content:"";background:0 0;border:6px solid #0000;position:absolute}.leaflet-tooltip-bottom[data-v-fd94857e]{margin-top:6px}.leaflet-tooltip-top[data-v-fd94857e]{margin-top:-6px}.leaflet-tooltip-bottom[data-v-fd94857e]:before,.leaflet-tooltip-top[data-v-fd94857e]:before{margin-left:-6px;left:50%}.leaflet-tooltip-top[data-v-fd94857e]:before{border-top-color:#fff;margin-bottom:-12px;bottom:0}.leaflet-tooltip-bottom[data-v-fd94857e]:before{border-bottom-color:#fff;margin-top:-12px;margin-left:-6px;top:0}.leaflet-tooltip-left[data-v-fd94857e]{margin-left:-6px}.leaflet-tooltip-right[data-v-fd94857e]{margin-left:6px}.leaflet-tooltip-left[data-v-fd94857e]:before,.leaflet-tooltip-right[data-v-fd94857e]:before{margin-top:-6px;top:50%}.leaflet-tooltip-left[data-v-fd94857e]:before{border-left-color:#fff;margin-right:-12px;right:0}.leaflet-tooltip-right[data-v-fd94857e]:before{border-right-color:#fff;margin-left:-12px;left:0}@media print{.leaflet-control[data-v-fd94857e]{-webkit-print-color-adjust:exact;print-color-adjust:exact}}.ml-0[data-v-ed9c8a11]{margin-left:0}.ml-4[data-v-ed9c8a11]{margin-left:1rem}.ml-8[data-v-ed9c8a11]{margin-left:2rem}.ml-12[data-v-ed9c8a11]{margin-left:3rem}.ml-16[data-v-ed9c8a11]{margin-left:4rem}.ml-20[data-v-ed9c8a11]{margin-left:5rem}.ml-24[data-v-ed9c8a11]{margin-left:6rem}.ml-28[data-v-ed9c8a11]{margin-left:7rem}.ml-32[data-v-ed9c8a11]{margin-left:8rem}.dropdown-enter-active[data-v-45835eb1],.dropdown-leave-active[data-v-45835eb1]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-45835eb1],.dropdown-leave-to[data-v-45835eb1]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-50d93367],.expand-leave-active[data-v-50d93367]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-50d93367],.expand-leave-to[data-v-50d93367]{opacity:0;max-height:0}.expand-enter-to[data-v-50d93367],.expand-leave-from[data-v-50d93367]{opacity:1;max-height:2000px}.tab-fade-left[data-v-e8f5e632]{background:linear-gradient(to right, var(--color-surface) 30%, transparent)}.tab-fade-right[data-v-e8f5e632]{background:linear-gradient(to left, var(--color-surface) 30%, transparent)}.tab-fade-enter-active[data-v-e8f5e632],.tab-fade-leave-active[data-v-e8f5e632]{transition:opacity .2s}.tab-fade-enter-from[data-v-e8f5e632],.tab-fade-leave-to[data-v-e8f5e632]{opacity:0} diff --git a/repeater/web/html/assets/ConfirmDialog-PLW-eI8u.js b/repeater/web/html/assets/ConfirmDialog-PLW-eI8u.js new file mode 100644 index 0000000..196a1f4 --- /dev/null +++ b/repeater/web/html/assets/ConfirmDialog-PLW-eI8u.js @@ -0,0 +1 @@ +import{dt as e,g as t,l as n,pt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-HnidnMFy.js";import{h as s}from"./index-BFltqMtv.js";var c={class:`flex items-center justify-between mb-4`},l={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},u={class:`mb-6`},d={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},h={class:`flex gap-3`},g=t({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(t,{emit:g}){let _=t,v=g,y=e=>{e.target===e.currentTarget&&v(`close`)},b={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(t,g)=>_.show?(o(),a(`div`,{key:0,onClick:y,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`}},[i(`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:g[3]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`h3`,l,r(_.title),1),i(`button`,{onClick:g[0]||=e=>v(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...g[4]||=[i(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),i(`div`,u,[i(`div`,{class:e([`inline-flex p-3 rounded-xl mb-4`,b[_.variant]])},[_.variant===`danger`?(o(),a(`svg`,d,[...g[5]||=[i(`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)]])):_.variant===`warning`?(o(),a(`svg`,f,[...g[6]||=[i(`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(),a(`svg`,p,[...g[7]||=[i(`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),i(`p`,m,r(_.message),1)]),i(`div`,h,[i(`button`,{onClick:g[1]||=e=>v(`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`},r(_.cancelText),1),i(`button`,{onClick:g[2]||=e=>v(`confirm`),class:e([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[_.variant]])},r(_.confirmText),3)])])])):n(``,!0)}});export{g as t}; \ No newline at end of file diff --git a/repeater/web/html/assets/Dashboard-BLK8l9Tc.css b/repeater/web/html/assets/Dashboard-BLK8l9Tc.css new file mode 100644 index 0000000..e03e974 --- /dev/null +++ b/repeater/web/html/assets/Dashboard-BLK8l9Tc.css @@ -0,0 +1 @@ +.sparkline-card[data-v-d5c09182]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px);background:#ffffffbf;border:1px solid #0000000f;border-radius:12px;padding:12px 14px;transition:background .3s,border-color .3s,box-shadow .3s;overflow:hidden;box-shadow:0 4px 16px #0000000a,0 1px 3px #00000005}.dark .sparkline-card[data-v-d5c09182]{background:#0006;border:1px solid #ffffff0d;box-shadow:0 4px 16px #0003}.card-header[data-v-d5c09182]{justify-content:space-between;align-items:baseline;margin-bottom:8px;display:flex}.card-title[data-v-d5c09182]{color:#4b5563b3;text-transform:uppercase;letter-spacing:.05em;font-size:11px;font-weight:500;transition:color .3s}.dark .card-title[data-v-d5c09182]{color:#fff9}.card-value[data-v-d5c09182]{font-variant-numeric:tabular-nums;font-size:22px;font-weight:700;line-height:1}.card-values[data-v-d5c09182]{align-items:baseline;gap:6px;display:flex}.card-secondary-value[data-v-d5c09182]{font-variant-numeric:tabular-nums;opacity:.85;font-size:13px;font-weight:600;line-height:1}.card-chart[data-v-d5c09182]{width:100%;height:28px;overflow:hidden}.card-chart canvas[data-v-d5c09182]{width:100%!important;height:100%!important}@media (width>=1024px){.sparkline-card[data-v-d5c09182]{padding:14px 16px}.card-header[data-v-d5c09182]{margin-bottom:10px}.card-title[data-v-d5c09182]{font-size:12px}.card-value[data-v-d5c09182]{font-size:26px}.card-chart[data-v-d5c09182]{height:32px}}.stats-cards-container[data-v-9aa769d6]{will-change:auto;contain:layout}.stat-card[data-v-9aa769d6]{transition:opacity .3s ease-out}.stat-card[data-v-9aa769d6] .text-lg,.stat-card[data-v-9aa769d6] .text-\[30px\]{transition:color .2s ease-out}canvas[data-v-51cd61e9]{width:100%;height:100%}.modal-enter-active[data-v-c8711b75]{transition:all .3s cubic-bezier(.4,0,.2,1)}.modal-leave-active[data-v-c8711b75]{transition:all .2s ease-in}.modal-enter-from[data-v-c8711b75]{opacity:0;transform:scale(.95)translateY(-10px)}.modal-leave-to[data-v-c8711b75]{opacity:0;transform:scale(1.05)}.custom-scrollbar[data-v-c8711b75]{scrollbar-width:thin;scrollbar-color:#ffffff4d transparent}.custom-scrollbar[data-v-c8711b75]::-webkit-scrollbar{width:6px}.custom-scrollbar[data-v-c8711b75]::-webkit-scrollbar-track{background:#ffffff1a;border-radius:3px}.custom-scrollbar[data-v-c8711b75]::-webkit-scrollbar-thumb{background:#ffffff4d;border-radius:3px}.custom-scrollbar[data-v-c8711b75]::-webkit-scrollbar-thumb:hover{background:#fff6}.glass-card[data-v-c8711b75]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px)}.fade-enter-active[data-v-8961931d],.fade-leave-active[data-v-8961931d]{transition:opacity .3s ease-out,transform .3s ease-out}.fade-enter-from[data-v-8961931d],.fade-leave-to[data-v-8961931d]{opacity:0;transform:translateY(-10px)}@keyframes spin-8961931d{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.animate-spin[data-v-8961931d]{animation:.8s linear infinite spin-8961931d}.packet-list-enter-active[data-v-8961931d],.packet-list-leave-active[data-v-8961931d],.packet-list-move[data-v-8961931d]{transition:all .4s ease-out}.packet-list-enter-from[data-v-8961931d]{opacity:0;transform:translateY(-30px)scale(.98)}.packet-list-enter-to[data-v-8961931d],.packet-list-leave-from[data-v-8961931d]{opacity:1;transform:translateY(0)scale(1)}.packet-list-leave-to[data-v-8961931d]{opacity:0;transform:translateY(-20px)scale(.95)}.packet-row[data-v-8961931d]{transition:all .3s;position:relative}.packet-list-enter-active .packet-row[data-v-8961931d]{background:linear-gradient(90deg,#4ec9b01a 0%,#4ec9b00d 50%,#0000 100%);border-left:3px solid #4ec9b099;border-radius:8px;padding-left:12px;box-shadow:0 0 20px #4ec9b033}.packet-row[data-v-8961931d]:hover{background:#ffffff05;border-radius:8px;transition:background .2s}@media (width<=1023px){.filter-container[data-v-8961931d]{flex-direction:column;align-items:stretch;gap:1rem}.header-info[data-v-8961931d]{flex-direction:column;align-items:flex-start;gap:.5rem}.packet-count[data-v-8961931d]{order:1}.live-mode-badge[data-v-8961931d]{order:2;align-self:flex-start}.loading-indicator[data-v-8961931d],.error-indicator[data-v-8961931d]{order:3;align-self:flex-start}.filter-controls[data-v-8961931d]{flex-direction:column;grid-template-columns:1fr 1fr;gap:.75rem;display:grid!important}.filter-controls .flex.flex-col[data-v-8961931d]{flex-direction:column;align-items:stretch;gap:.25rem}.filter-controls .flex.flex-col label[data-v-8961931d]{margin-bottom:0;font-size:.75rem}.reset-container[data-v-8961931d]{justify-content:center;margin-top:.5rem;display:flex;grid-column:span 2!important}.pagination-container[data-v-8961931d]{flex-direction:column;align-items:stretch;gap:1rem}.pagination-info[data-v-8961931d]{text-align:center;flex-direction:column;justify-content:center;gap:.5rem}.load-more-section[data-v-8961931d]{justify-content:center}.load-more-count[data-v-8961931d]{display:none}.pagination-controls[data-v-8961931d]{justify-content:center}.page-numbers[data-v-8961931d]{scrollbar-width:none;-ms-overflow-style:none;max-width:200px;overflow-x:auto}.page-numbers[data-v-8961931d]::-webkit-scrollbar{display:none}.ellipsis[data-v-8961931d]{display:none}.page-number[data-v-8961931d]{flex-shrink:0;min-width:40px}}@media (width<=640px){.filter-controls[data-v-8961931d]{gap:.75rem;grid-template-columns:1fr!important}.reset-container[data-v-8961931d]{grid-column:span 1!important}.header-info h3[data-v-8961931d]{font-size:1.125rem}.packet-count[data-v-8961931d]{font-size:.75rem}.live-mode-badge[data-v-8961931d]{padding:.25rem .5rem;font-size:.75rem}.pagination-info span[data-v-8961931d]{font-size:.75rem}.prev-next-btn[data-v-8961931d]{min-width:40px;padding:.5rem}.page-numbers[data-v-8961931d]{gap:.25rem;max-width:150px}.page-number[data-v-8961931d]{min-width:36px;padding:.5rem .25rem;font-size:.75rem}.load-more-section button[data-v-8961931d]{padding:.375rem .75rem;font-size:.6rem}} diff --git a/repeater/web/html/assets/Dashboard-ClL05x7j.js b/repeater/web/html/assets/Dashboard-ClL05x7j.js new file mode 100644 index 0000000..bb23025 --- /dev/null +++ b/repeater/web/html/assets/Dashboard-ClL05x7j.js @@ -0,0 +1,2 @@ +import{A as e,E as t,I as n,K as r,S as i,b as a,c as o,dt as s,f as c,ft as l,g as u,i as d,j as f,k as p,l as m,m as h,o as g,p as _,pt as v,r as y,s as b,u as x,w as S,x as C,z as w}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t as T}from"./api-CbM6k1ZB.js";import{t as E}from"./system-BH4r-ii6.js";import{t as D}from"./packets-C-dzvp0W.js";import{t as O}from"./websocket-nXR7EYbj.js";import{t as k}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{a as A,c as ee,d as te,h as j}from"./index-BFltqMtv.js";import{n as M,t as N}from"./preferences-Bv8i60GL.js";import{a as P,c as F,i as I,l as L,m as ne,s as R,u as re}from"./chart-B1uYMRrx.js";import{t as z}from"./useSignalQuality-BfZWbBxN.js";var ie={class:`sparkline-card`},B={class:`card-header`},ae={class:`card-title`},V={class:`card-values`},H={class:`card-chart`},U=k(u({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(e){P.register(I,L,re,F,R,ne);let t=e,r=w(null),o=w(null),s=e=>{if(e.length<3)return e;let t=Math.min(15,Math.max(3,Math.floor(e.length*.2))),n=[];for(let r=0;re+t,0)/s.length)}let r=Math.min(12,n.length),i=n.length/r,a=[];for(let e=0;e!t.data||t.data.length===0?[]:s(t.data)),u=g(()=>!t.secondaryData||t.secondaryData.length===0?[]:s(t.secondaryData)),d=()=>{if(!r.value)return;let e=r.value.getContext(`2d`);if(!e)return;o.value&&=(o.value.destroy(),null);let i=c.value;if(i.length<2)return;let a=[{data:i,borderColor:t.color,borderWidth:2.5,fill:!1,tension:.4,pointRadius:0,pointHoverRadius:0}],s=u.value;s.length>=2&&t.secondaryColor&&a.push({data:s,borderColor:t.secondaryColor,borderWidth:2,borderDash:[4,3],fill:!1,tension:.4,pointRadius:0,pointHoverRadius:0}),o.value=n(new P(e,{type:`line`,data:{labels:i.map((e,t)=>t.toString()),datasets:a},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}}}}))},f=()=>{if(!o.value){d();return}let e=c.value;if(e.length<2)return;o.value.data.labels=e.map((e,t)=>t.toString()),o.value.data.datasets[0].data=e;let n=u.value;n.length>=2&&t.secondaryColor&&(o.value.data.datasets.length<2?o.value.data.datasets.push({data:n,borderColor:t.secondaryColor,borderWidth:2,borderDash:[4,3],fill:!1,tension:.4,pointRadius:0,pointHoverRadius:0}):o.value.data.datasets[1].data=n),o.value.update(`default`)};return p(()=>t.data,()=>{a(()=>f())},{deep:!0}),p(()=>t.color,()=>{o.value&&(o.value.data.datasets[0].borderColor=t.color,o.value.update(`none`))}),i(()=>{a(()=>d())}),C(()=>{o.value&&=(o.value.destroy(),null)}),(t,n)=>(S(),x(`div`,ie,[b(`div`,B,[b(`p`,ae,v(e.title),1),b(`div`,V,[b(`span`,{class:`card-value`,style:l({color:e.color})},v(typeof e.value==`number`?e.value.toLocaleString():e.value),5),e.secondaryValue===void 0?m(``,!0):(S(),x(`span`,{key:0,class:`card-secondary-value`,style:l({color:e.secondaryColor})},v(e.secondaryLabel)+v(typeof e.secondaryValue==`number`?e.secondaryValue.toLocaleString():e.secondaryValue),5))])]),b(`div`,H,[e.showChart?(S(),x(`canvas`,{key:0,ref_key:`canvasRef`,ref:r},null,512)):m(``,!0)])]))}}),[[`__scopeId`,`data-v-d5c09182`]]),W={class:`grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3 lg:gap-4 mb-5 stats-cards-container`},G=k(u({name:`StatsCards`,__name:`StatsCards`,setup(e){let t=D(),n=O(),r=w(null),o=w(!1),s=g(()=>{let e=t.packetStats,n=t.systemStats,r=e=>{let t=Math.floor(e/86400),n=Math.floor(e%86400/3600),r=Math.floor(e%3600/60);return t>0?`${t}d ${n}h`:n>0?`${n}h ${r}m`:`${r}m`},i=e?.total_packets||0,a=e?.dropped_packets||0,o=i>0?Math.round(a/i*100):0;return{packetsReceived:i,packetsForwarded:e?.transmitted_packets||0,uptimeFormatted:n?r(n.uptime_seconds||0):`0m`,uptimeHours:n?Math.floor((n.uptime_seconds||0)/3600):0,droppedPackets:a,dropPercent:`${o}%`,signalQuality:Math.round((e?.avg_rssi||0)+120),crcErrorCount:t.crcErrorCount}}),c=g(()=>t.sparklineData),l=async()=>{if(!o.value)try{o.value=!0,await Promise.all([t.fetchSystemStats(),t.fetchPacketStats({hours:24})]),await a()}catch(e){console.error(`Error fetching stats:`,e)}finally{o.value=!1}};return i(async()=>{await t.initializeSparklineHistory(),l(),r.value=window.setInterval(()=>{t.interpolateRates()},6e4)}),A(l,{intervalMs:3e4,enabled:()=>!n.isConnected,immediate:!1}),C(()=>{r.value&&clearInterval(r.value)}),(e,t)=>(S(),x(`div`,W,[h(U,{title:`Up Time`,value:s.value.uptimeFormatted,color:`#EBA0FC`,data:[],showChart:!1,class:`stat-card`},null,8,[`value`]),h(U,{title:`RX Packets`,value:s.value.packetsReceived,color:`#AAE8E8`,data:c.value.totalPackets,class:`stat-card`},null,8,[`value`,`data`]),h(U,{title:`Forward`,value:s.value.packetsForwarded,color:`#FFC246`,data:c.value.transmittedPackets,class:`stat-card`},null,8,[`value`,`data`]),h(U,{title:`Dropped`,value:s.value.droppedPackets,color:`#FB787B`,data:c.value.droppedPackets,class:`stat-card`},null,8,[`value`,`data`]),h(U,{title:`CRC Errors`,value:s.value.crcErrorCount,color:`#F59E0B`,data:c.value.crcErrors,class:`stat-card`},null,8,[`value`,`data`])]))}}),[[`__scopeId`,`data-v-9aa769d6`]]),K={class:`glass-card rounded-[10px] p-4 lg:p-6`},q={class:`h-48 lg:h-56 relative`},oe={key:0,class:`absolute inset-0 flex items-center justify-center`},J={key:1,class:`absolute inset-0 flex items-center justify-center`},se={class:`text-red-600 dark:text-red-400 text-sm lg:text-base`},ce={key:2,class:`absolute inset-0 flex items-center justify-center`},Y={key:3,class:`h-full flex flex-col`},X={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`},le={class:`text-content-primary dark:text-content-primary text-sm font-medium mb-1`},ue={class:`text-content-primary dark:text-content-primary`},de={class:`flex-1 flex items-end justify-evenly gap-4 px-4`},Z=[`onMouseenter`],fe={class:`text-content-primary dark:text-content-primary text-xs sm:text-sm font-semibold text-center w-full`,style:{"padding-bottom":`5px`}},pe={class:`text-content-secondary dark:text-content-muted text-xs mt-2 text-center`},me={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`},he={class:`truncate text-left`},ge={key:1,class:`mt-3 text-xs text-content-secondary dark:text-content-muted text-center`},_e=k(u({name:`PacketTypesChart`,__name:`PacketTypesChart`,setup(e){let n=w([]),r=D(),a=O(),o=w(!0),s=w(null),c=w(null),u=[{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=g(()=>u.map(e=>{let t=n.value.filter(t=>e.types.some(e=>t.name.includes(e)||t.name===e)).sort((e,t)=>t.count-e.count).map((t,n)=>({...t,color:e.subColors[n%e.subColors.length]}));return{name:e.name,color:e.subColors[0],items:t,total:t.reduce((e,t)=>e+t.count,0)}}).filter(e=>e.total>0)),f=g(()=>Math.max(...d.value.map(e=>e.total),1)),h=g(()=>d.value.reduce((e,t)=>e+t.total,0)),_=async()=>{try{s.value=null;let e=await T.get(`/packet_type_graph_data`);if(e?.success&&e?.data){let t=e.data;if(t?.series){let e=[];t.series.forEach((t,n)=>{let r=0;t.data&&Array.isArray(t.data)&&(r=t.data.reduce((e,t)=>e+(t[1]||0),0)),r>0&&e.push({name:t.name||`Type ${t.type}`,type:t.type,count:r,color:``})}),n.value=e,o.value=!1}else s.value=`No series data in server response`,o.value=!1}else s.value=`Invalid response from server`,o.value=!1}catch(e){s.value=e instanceof Error?e.message:`Failed to load data`,o.value=!1}},C={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=()=>{let e=r.packetTypeBreakdown;!e||e.length===0||(n.value=e.map(e=>({name:C[Number(e.type)]||`Type ${e.type}`,type:e.type,count:e.count,color:``})),o.value=!1,s.value=null)},k=e=>Math.max(e/f.value*90,2),ee=(e,t)=>t===0?0:e/t*100;return i(()=>{_()}),p(()=>r.packetTypeBreakdown,()=>E(),{deep:!0,immediate:!0}),A(_,{intervalMs:3e4,enabled:()=>!a.isConnected,immediate:!0}),(e,n)=>(S(),x(`div`,K,[n[3]||=b(`div`,{class:`flex items-baseline justify-between mb-3 lg:mb-4`},[b(`h3`,{class:`text-content-primary dark:text-content-primary text-lg lg:text-xl font-semibold`},` Packet Types `),b(`p`,{class:`text-content-secondary dark:text-content-muted text-xs lg:text-sm uppercase`},` Distribution by Type `)],-1),b(`div`,q,[o.value?(S(),x(`div`,oe,[...n[1]||=[b(`div`,{class:`text-content-secondary dark:text-content-primary text-sm lg:text-base`},` Loading packet types... `,-1)]])):s.value?(S(),x(`div`,J,[b(`div`,se,v(s.value),1)])):d.value.length===0?(S(),x(`div`,ce,[...n[2]||=[b(`div`,{class:`text-content-secondary dark:text-content-primary text-sm lg:text-base`},` No packet data available `,-1)]])):(S(),x(`div`,Y,[c.value?(S(),x(`div`,X,[b(`div`,le,v(c.value.name)+` · `+v(c.value.total.toLocaleString()),1),(S(!0),x(y,null,t(c.value.items,e=>(S(),x(`div`,{key:e.type,class:`flex justify-between gap-4 text-xs text-content-secondary dark:text-content-muted`},[b(`span`,null,v(e.name),1),b(`span`,ue,v(e.count.toLocaleString()),1)]))),128))])):m(``,!0),b(`div`,de,[(S(!0),x(y,null,t(d.value,e=>(S(),x(`div`,{key:e.name,class:`flex flex-col items-center flex-1 max-w-32 h-full justify-end cursor-pointer`,onMouseenter:t=>c.value=e,onMouseleave:n[0]||=e=>c.value=null},[b(`span`,fe,v(e.total.toLocaleString()),1),b(`div`,{class:`w-full rounded-[5px] transition-all duration-300 ease-out hover:opacity-90 overflow-hidden flex flex-col-reverse`,style:l({height:k(e.total)+`%`,minHeight:`8px`})},[(S(!0),x(y,null,t(e.items,t=>(S(),x(`div`,{key:t.type,style:l({height:ee(t.count,e.total)+`%`,backgroundColor:t.color})},null,4))),128))],4),b(`span`,pe,v(e.name),1)],40,Z))),128))])]))]),d.value.length>0?(S(),x(`div`,me,[(S(!0),x(y,null,t(d.value,e=>(S(),x(`div`,{key:`legend-`+e.name,class:`flex flex-col gap-0.5 min-w-[100px] max-w-[140px] flex-shrink-0`},[(S(!0),x(y,null,t(e.items,e=>(S(),x(`div`,{key:e.type,class:`flex items-center gap-1.5`},[b(`span`,{class:`w-2 h-2 rounded-sm shrink-0`,style:l({backgroundColor:e.color})},null,4),b(`span`,he,v(e.name),1)]))),128))]))),128))])):m(``,!0),d.value.length>0?(S(),x(`div`,ge,` Total: `+v(h.value.toLocaleString())+` packets `,1)):m(``,!0)]))}}),[[`__scopeId`,`data-v-fdd759f8`]]),ve={class:`glass-card rounded-[10px] p-4 lg:p-6`},ye={class:`relative h-40 lg:h-48`},be={class:`mt-3 lg:mt-4 grid grid-cols-2 gap-3 lg:gap-4`},xe={class:`text-center`},Se={class:`text-lg lg:text-2xl font-bold text-content-primary dark:text-content-primary`},Ce={class:`text-center`},we={class:`text-lg lg:text-2xl font-bold text-content-primary dark:text-content-primary`},Te={class:`mt-2 lg:mt-3 grid grid-cols-3 gap-2 lg:gap-3 text-center`},Ee={class:`text-xs lg:text-sm font-semibold text-accent-purple 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`},Oe={class:`text-xs text-content-secondary dark:text-content-muted`},ke={class:`text-xs lg:text-sm font-semibold text-accent-red 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`},je={class:`text-xs text-content-secondary dark:text-content-muted`},Me={class:`text-xs lg:text-sm font-semibold text-white`},Ne=k(u({name:`AirtimeUtilizationChart`,__name:`AirtimeUtilizationChart`,setup(e){let t=D(),n=E(),o=w(null),s=w([]),l=w(!0),u=w(null),d=w(30),f=w({totalReceived:0,totalTransmitted:0,dropped:0,firstPacketTime:0}),p=w({sf:9,bwHz:62500,cr:5,preamble:17}),h=(e,t=60)=>{if(e.length===0)return[];let n=1-.5**(1/t),r=Math.min(e.length,Math.max(10,Math.floor(t/3))),i=0,a=0;for(let t=0;t(i=n*e.rxUtil+(1-n)*i,a=n*e.txUtil+(1-n)*a,{...e,rxUtil:i,txUtil:a}))},y=g(()=>{let e=t.packetStats?.total_packets||0,r=t.packetStats?.transmitted_packets||0,i=n.stats?.uptime_seconds||0,a=e||f.value.totalReceived,o=r||f.value.totalTransmitted,s=f.value.firstPacketTime>0?Math.floor(Date.now()/1e3)-f.value.firstPacketTime:0,c=i||s,l=Math.max(c/3600,.1);if(l<1){let e=Math.max(c/60,1);return{rxRate:{value:Math.round(a/e*100)/100,label:l<.5?`RX/min (early)`:`RX/min`},txRate:{value:Math.round(o/e*100)/100,label:l<.5?`TX/min (early)`:`TX/min`},confidence:`low`}}let u=Math.round(a/l*100)/100,d=Math.round(o/l*100)/100,p,m;return l<6?(p=`RX/hr (${Math.round(l)}h)`,m=`medium`):l<24?(p=`RX/hr (${Math.round(l)}h)`,m=`high`):(p=`RX/hr`,m=`high`),{rxRate:{value:u,label:p},txRate:{value:d,label:p.replace(`RX`,`TX`)},confidence:m}}),O=async()=>{l.value=!0;try{let e=60*1e3,t=Math.floor(Date.now()/1e3),n=t-24*3600,r=0;try{let e=await T.get(`/stats`);if(e.success&&e.data){let t=e.data,n=t.config;if(n?.radio){let e=n.radio;p.value={sf:e.spreading_factor??9,bwHz:e.bandwidth??62500,cr:e.coding_rate??5,preamble:e.preamble_length??17}}r=t.dropped_count??0}}catch{}let i=await T.get(`/airtime_chart_data`,{start_timestamp:n,end_timestamp:t,bucket_seconds:60,sf:p.value.sf,bw_hz:p.value.bwHz,cr:p.value.cr,preamble:p.value.preamble});if(!i.success){s.value=[],l.value=!1,a(()=>k());return}let o=i.data,c=o.buckets||[];f.value={totalReceived:o.rx_total||0,totalTransmitted:o.tx_total||0,dropped:r,firstPacketTime:c.length>0?c[0].timestamp:t};let u=24*3600/60,m=new Float64Array(u),g=new Float64Array(u);for(let t of c){let r=Math.floor((t.timestamp-n)/60);r>=0&&r[e.rxUtil,e.txUtil]))*1.05;d.value=Math.max(5,Math.ceil(x/5)*5),l.value=!1,a(()=>k())}catch(e){console.error(`Failed to fetch airtime data:`,e),s.value=[],l.value=!1,a(()=>k())}},k=()=>{if(!o.value)return;let e=o.value,t=e.getContext(`2d`);if(!t)return;let n=e.parentElement;if(!n)return;let r=n.getBoundingClientRect(),i=r.width,a=r.height;if(e.width=i*window.devicePixelRatio,e.height=a*window.devicePixelRatio,e.style.width=i+`px`,e.style.height=a+`px`,t.scale(window.devicePixelRatio,window.devicePixelRatio),t.clearRect(0,0,i,a),l.value){t.fillStyle=`#666`,t.font=`16px system-ui`,t.textAlign=`center`,t.fillText(`Loading chart data...`,i/2,a/2);return}if(s.value.length===0){t.fillStyle=`#666`,t.font=`16px system-ui`,t.textAlign=`center`,t.fillText(`No data available`,i/2,a/2);return}let c=i-45-20,u=a-40,f=d.value,p=d.value;t.strokeStyle=`rgba(255, 255, 255, 0.1)`,t.lineWidth=1,t.font=`10px system-ui`,t.textAlign=`right`;for(let e=0;e<=5;e++){let n=20+u*e/5;t.beginPath(),t.moveTo(45,n),t.lineTo(i-20,n),t.stroke();let r=f-e/5*p;t.fillStyle=`rgba(255, 255, 255, 0.5)`,t.fillText(`${r.toFixed(0)}%`,40,n+3)}for(let e=0;e<=6;e++){let n=45+c*e/6;t.beginPath(),t.moveTo(n,20),t.lineTo(n,a-20),t.stroke()}s.value.length>1&&(t.strokeStyle=`#EBA0FC`,t.lineWidth=2,t.beginPath(),s.value.forEach((e,n)=>{let r=45+c*n/(s.value.length-1),i=a-20-Math.min(e.rxUtil,d.value)/p*u;n===0?t.moveTo(r,i):t.lineTo(r,i)}),t.stroke()),s.value.length>1&&(t.strokeStyle=`#FB787B`,t.lineWidth=2,t.beginPath(),s.value.forEach((e,n)=>{let r=45+c*n/(s.value.length-1),i=a-20-Math.min(e.txUtil,d.value)/p*u;n===0?t.moveTo(r,i):t.lineTo(r,i)}),t.stroke())};return i(()=>{O(),u.value=window.setInterval(O,3e4),a(()=>{k(),setTimeout(()=>k(),100)}),window.addEventListener(`resize`,k)}),C(()=>{u.value&&clearInterval(u.value),window.removeEventListener(`resize`,k)}),(e,n)=>(S(),x(`div`,ve,[n[3]||=c(`

Airtime Utilization

Activity (Last 24 Hours)

Rx Util
Tx Util
`,3),b(`div`,ye,[b(`canvas`,{ref_key:`chartRef`,ref:o,class:`absolute inset-0 w-full h-full`},null,512)]),b(`div`,be,[b(`div`,xe,[b(`div`,Se,v(r(t).packetStats?.total_packets||f.value.totalReceived),1),n[0]||=b(`div`,{class:`text-xs text-content-secondary dark:text-content-muted uppercase tracking-wide`},` Total Received `,-1)]),b(`div`,Ce,[b(`div`,we,v(r(t).packetStats?.transmitted_packets||f.value.totalTransmitted),1),n[1]||=b(`div`,{class:`text-xs text-content-secondary dark:text-content-muted uppercase tracking-wide`},` Total Transmitted `,-1)])]),b(`div`,Te,[b(`div`,null,[b(`div`,Ee,[_(v(y.value.rxRate.value)+` `,1),y.value.confidence===`low`?(S(),x(`span`,De)):m(``,!0)]),b(`div`,Oe,v(y.value.rxRate.label),1)]),b(`div`,null,[b(`div`,ke,[_(v(y.value.txRate.value)+` `,1),y.value.confidence===`low`?(S(),x(`span`,Ae)):m(``,!0)]),b(`div`,je,v(y.value.txRate.label),1)]),b(`div`,null,[b(`div`,Me,v(r(t).packetStats?.dropped_packets||f.value.dropped),1),n[2]||=b(`div`,{class:`text-xs text-white/60`},`Dropped`,-1)])])]))}}),[[`__scopeId`,`data-v-51cd61e9`]]),Pe={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`},Fe={class:`flex items-center justify-between p-8 pb-4 flex-shrink-0`},Ie={class:`text-content-secondary dark:text-content-muted text-sm`},Le={class:`flex items-center gap-2`},Re=[`title`],ze={class:`flex-1 overflow-y-auto custom-scrollbar px-8`},Be={class:`mb-6`},Ve={class:`glass-card bg-white/5 rounded-[15px] p-4`},He={class:`grid grid-cols-1 md:grid-cols-2 gap-4`},Ue={class:`space-y-3`},We={class:`flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10`},Ge={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`},qe={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`},Xe={class:`space-y-3`},Ze={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-semibold`},$e={class:`flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10`},et={class:`text-content-primary dark:text-content-primary font-semibold`},tt={class:`flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10`},nt={class:`mb-6`},rt={class:`bg-gray-50 dark:bg-white/5 rounded-[15px] p-4 border border-stroke-subtle dark:border-stroke/10`},it={class:`space-y-3`},at={class:`flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10`},ot={class:`text-content-primary dark:text-content-primary`},st={key:0,class:`pt-2`},ct={class:`glass-card bg-background-mute dark:bg-black/30 rounded-[10px] p-4 mb-4`},lt={class:`w-full overflow-x-auto`},ut={class:`text-content-primary dark:text-content-primary/90 text-xs font-mono whitespace-pre leading-relaxed min-w-full`},dt={class:`flex items-center justify-between mb-3`},ft={class:`text-content-secondary dark:text-content-primary/80 text-sm font-semibold`},pt={class:`text-content-muted dark:text-content-muted text-xs`},mt={class:`bg-background-mute dark:bg-black/40 rounded-[8px] p-3 mb-3`},ht={class:`font-mono text-xs text-content-primary dark:text-content-primary break-all whitespace-pre-wrap leading-relaxed`},gt={class:`bg-gray-50 dark:bg-white/5 rounded-[10px] overflow-hidden`},_t={key:0,class:`min-w-0`},vt={class:`text-cyan-500 text-sm font-mono break-words min-w-0`},yt={class:`text-content-primary dark:text-content-primary text-sm break-words min-w-0`},bt={class:`text-content-primary dark:text-content-primary text-sm font-semibold break-all min-w-0 overflow-hidden`},xt=[`title`],St={key:0,class:`text-orange-500 text-xs font-mono break-all min-w-0 overflow-hidden`},Ct=[`title`],wt={class:`grid grid-cols-2 gap-2`},Tt={class:`text-cyan-500 text-sm font-mono break-words`},Et={class:`text-content-primary dark:text-content-primary text-sm break-words`},Dt=[`title`],Ot={key:0},kt=[`title`],At={key:0,class:`text-content-muted dark:text-content-muted text-xs italic mt-2 px-1`},jt={key:1,class:`py-2`},Mt={class:`mb-6`},Nt={class:`bg-gray-50 dark:bg-white/5 rounded-[15px] p-4 border border-stroke-subtle dark:border-stroke/10`},Pt={class:`space-y-4`},Ft={class:`grid grid-cols-1 md:grid-cols-2 gap-4`},It={class:`flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10`},Lt={class:`flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10`},Rt={key:0,class:`py-2`},zt={class:`bg-background-mute dark:bg-black/20 rounded-[10px] p-4`},Bt={class:`flex items-center flex-wrap gap-2`},Vt={class:`relative group`},Ht={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`},Ut={class:`font-mono text-[10px] font-semibold tracking-tight text-content-primary dark:text-content-primary/90 sm:text-xs`},Wt={class:`pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 transform whitespace-nowrap rounded-md bg-neutral-900 px-2 py-1 font-mono text-xs text-white opacity-0 shadow-lg ring-1 ring-white/10 transition-opacity group-hover:opacity-100`},Gt={key:0,class:`mx-2 text-cyan-600 dark:text-cyan-400/60`},Kt={key:1,class:`py-2`},qt={class:`text-content-secondary dark:text-content-muted text-sm mb-2 flex items-center`},Jt={key:0,class:`w-4 h-4 ml-2 text-yellow-500`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Yt={key:1,class:`text-yellow-500 text-xs ml-1`},Xt={class:`bg-background-mute dark:bg-black/20 rounded-[10px] p-4`},Zt={class:`flex items-center flex-wrap gap-2`},Qt={class:`relative group`},$t={key:0,class:`absolute -top-1 -right-1 w-2 h-2 bg-yellow-400 rounded-full animate-pulse`},en={class:`pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 -translate-x-1/2 transform whitespace-nowrap rounded-md bg-neutral-900 px-2 py-1 font-mono text-xs text-white opacity-0 shadow-lg ring-1 ring-white/10 transition-opacity group-hover:opacity-100`},tn={key:0,class:`mx-1 text-orange-600 dark:text-orange-400/60`},nn={class:`mb-6`},rn={class:`glass-card bg-gray-50 dark:bg-white/5 rounded-[15px] p-4`},an={class:`grid grid-cols-1 md:grid-cols-3 gap-4 mb-4`},on={class:`text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]`},sn={class:`text-lg font-bold text-content-primary dark:text-content-primary`},cn={class:`text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]`},ln={class:`text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]`},un={class:`text-lg font-bold text-content-primary dark:text-content-primary`},dn={key:0,class:`mb-4`},fn={class:`flex items-center gap-3`},pn={class:`flex gap-1`},mn={class:`text-content-secondary dark:text-content-primary/80 text-sm capitalize`},hn={key:1,class:`mb-4`},gn={key:2,class:`mb-4`},_n={class:`text-content-secondary dark:text-content-muted text-sm mb-3`},vn={class:`space-y-2`},yn={class:`flex items-center gap-3`},bn={class:`text-content-muted dark:text-content-muted text-sm`},xn={key:3,class:`mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke/10`},Sn={class:`grid grid-cols-1 md:grid-cols-3 gap-3 mb-4`},Cn={class:`text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]`},wn={class:`text-2xl font-bold text-content-primary dark:text-content-primary`},Tn={class:`text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]`},En={class:`text-2xl font-bold text-content-primary dark:text-content-primary`},Dn={class:`text-content-muted dark:text-content-muted text-xs mt-1`},On={class:`text-center p-3 glass-card bg-background-mute dark:bg-black/20 rounded-[10px]`},kn={class:`text-content-muted dark:text-content-muted text-xs mt-1`},An={key:0,class:`glass-card bg-background-mute dark:bg-black/20 rounded-[10px] p-4`},jn={class:`space-y-3`},Mn={class:`flex-shrink-0 w-16 text-right`},Nn={class:`text-content-secondary dark:text-content-muted text-xs`},Pn={class:`flex-1 relative`},Fn={class:`h-8 rounded-lg overflow-hidden bg-background-mute dark:bg-stroke/5 relative`},In={class:`absolute inset-0 flex items-center px-3`},Ln={class:`text-content-primary dark:text-content-primary text-xs font-mono font-semibold`},Rn={class:`flex-shrink-0 w-12 text-left`},zn={class:`text-content-muted dark:text-content-muted text-xs`},Bn={class:`grid grid-cols-1 md:grid-cols-2 gap-4`},Vn={class:`space-y-2`},Hn={class:`flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10`},Un={class:`text-content-primary dark:text-content-primary`},Wn={class:`flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10`},Gn={class:`space-y-2`},Kn={class:`flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10`},qn={key:0,class:`flex justify-between py-2 border-b border-stroke-subtle dark:border-stroke/10`},Jn={class:`text-red-600 dark:text-red-400 text-sm`},Yn={class:`p-8 pt-4 border-t border-stroke-subtle dark:border-stroke/10 flex justify-end flex-shrink-0`},Xn=k(u({name:`PacketDetailsModal`,__name:`PacketDetailsModal`,props:{packet:{},isOpen:{type:Boolean},localHash:{}},emits:[`close`],setup(n,{emit:r}){let{getSignalQuality:i}=z(),a=n,c=r,u=w(!1),f=e=>new Date(e*1e3).toLocaleString(),g=e=>e.transmitted?e.is_duplicate?`text-amber-600 dark:text-amber-400`:e.drop_reason?`text-red-600 dark:text-red-400`:`text-green-600 dark:text-green-400`:`text-red-600 dark:text-red-400`,C=e=>e.transmitted?e.is_duplicate?`Duplicate`:e.drop_reason?`Dropped`:`Forwarded`:`Dropped`,T=e=>({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]||`Unknown Type (${e})`,E=e=>({0:`Transport Flood`,1:`Flood`,2:`Direct`,3:`Transport Direct`})[e]||`Unknown Route (${e})`,D=e=>{if(!e)return`None`;let t=e.replace(/\s+/g,``).toUpperCase().match(/.{2}/g)||[],n=[];for(let e=0;e{try{let r=0,i=t.length/2;if(i>=100){if(t.length>=r+64){let i=t.slice(r,r+64);e.push({name:`Public Key`,byteRange:`${(n+r)/2}-${(n+r+63)/2}`,hexData:i.match(/.{8}/g)?.join(` `)||i,description:`Ed25519 public key of the node (32 bytes)`,fields:[{bits:`0-255`,name:`Ed25519 Public Key`,value:`${i.slice(0,16)}...${i.slice(-16)}`,binary:`32 bytes (256 bits)`}]}),r+=64}if(t.length>=r+8){let i=t.slice(r,r+8),a=parseInt(i,16),o=new Date(a*1e3);e.push({name:`Timestamp`,byteRange:`${(n+r)/2}-${(n+r+7)/2}`,hexData:i.match(/.{2}/g)?.join(` `)||i,description:`Unix timestamp of advertisement`,fields:[{bits:`0-31`,name:`Unix Timestamp`,value:`${a} (${o.toLocaleString()})`,binary:a.toString(2).padStart(32,`0`)}]}),r+=8}if(t.length>=r+128){let i=t.slice(r,r+128);e.push({name:`Signature`,byteRange:`${(n+r)/2}-${(n+r+127)/2}`,hexData:i.match(/.{8}/g)?.join(` `)||i,description:`Ed25519 signature of public key, timestamp, and appdata`,fields:[{bits:`0-511`,name:`Ed25519 Signature`,value:`${i.slice(0,16)}...${i.slice(-16)}`,binary:`64 bytes (512 bits)`}]}),r+=128}t.length>r&&k(e,t.slice(r),n+r)}else e.push({name:`ADVERT AppData (Partial)`,byteRange:`${n/2}-${n/2+i-1}`,hexData:t.match(/.{2}/g)?.join(` `)||t,description:`Partial ADVERT data - appears to be just AppData portion (${i} bytes)`,fields:[{bits:`0-${i*8-1}`,name:`Partial Data`,value:`${i} bytes - attempting to decode as AppData`,binary:`${i} bytes (${i*8} bits)`}]}),k(e,t,n)}catch(n){e.push({name:`ADVERT Parse Error`,byteRange:`N/A`,hexData:t.slice(0,32)+`...`,description:`Failed to parse ADVERT payload structure`,fields:[{bits:`N/A`,name:`Error`,value:`Parse error: ${n instanceof Error?n.message:`Unknown error`}`,binary:`Invalid`}]})}},k=(e,t,n)=>{try{let r=t.length/2;e.push({name:`AppData`,byteRange:`${n/2}-${n/2+r-1}`,hexData:t.match(/.{2}/g)?.join(` `)||t,description:`Node advertisement application data (${r} bytes)`,fields:[{bits:`0-${r*8-1}`,name:`Application Data`,value:`${r} bytes (contains flags, location, name, etc.)`,binary:`${r} bytes (${r*8} bits)`}]});let i=0;if(t.length>=2){let r=parseInt(t.slice(i,i+2),16),a=[],o=!!(r&16),s=!!(r&32),c=!!(r&64),l=!!(r&128);if(r&1&&a.push(`is chat node`),r&2&&a.push(`is repeater`),r&4&&a.push(`is room server`),r&8&&a.push(`is sensor`),o&&a.push(`has location`),s&&a.push(`has feature 1`),c&&a.push(`has feature 2`),l&&a.push(`has name`),e.push({name:`AppData Flags`,byteRange:`${(n+i)/2}`,hexData:`0x${t.slice(i,i+2)}`,description:`Flags indicating which optional fields are present`,fields:[{bits:`0-7`,name:`Flags`,value:a.join(`, `)||`none`,binary:r.toString(2).padStart(8,`0`)}]}),i+=2,o&&t.length>=i+16){let r=t.slice(i,i+8),a=[];for(let e=6;e>=0;e-=2)a.push(r.slice(e,e+2));let o=parseInt(a.join(``),16),s=o>2147483647?o-4294967296:o,c=s/1e6,l=t.slice(i+8,i+16),u=[];for(let e=6;e>=0;e-=2)u.push(l.slice(e,e+2));let d=parseInt(u.join(``),16),f=d>2147483647?d-4294967296:d,p=f/1e6;e.push({name:`Location Data`,byteRange:`${(n+i)/2}-${(n+i+15)/2}`,hexData:`${r.match(/.{2}/g)?.join(` `)||r} ${l.match(/.{2}/g)?.join(` `)||l}`,description:`GPS coordinates (latitude and longitude)`,fields:[{bits:`0-31`,name:`Latitude`,value:`${c.toFixed(6)}° (raw: ${s})`,binary:s.toString(2).padStart(32,`0`)},{bits:`32-63`,name:`Longitude`,value:`${p.toFixed(6)}° (raw: ${f})`,binary:f.toString(2).padStart(32,`0`)}]}),i+=16}if(s&&t.length>=i+4){let r=t.slice(i,i+4),a=parseInt(r,16);e.push({name:`Feature 1`,byteRange:`${(n+i)/2}-${(n+i+3)/2}`,hexData:r.match(/.{2}/g)?.join(` `)||r,description:`Reserved feature 1 (2 bytes)`,fields:[{bits:`0-15`,name:`Feature 1 Value`,value:`${a}`,binary:a.toString(2).padStart(16,`0`)}]}),i+=4}if(c&&t.length>=i+4){let r=t.slice(i,i+4),a=parseInt(r,16);e.push({name:`Feature 2`,byteRange:`${(n+i)/2}-${(n+i+3)/2}`,hexData:r.match(/.{2}/g)?.join(` `)||r,description:`Reserved feature 2 (2 bytes)`,fields:[{bits:`0-15`,name:`Feature 2 Value`,value:`${a}`,binary:a.toString(2).padStart(16,`0`)}]}),i+=4}if(l&&t.length>i){let r=t.slice(i),a=r.match(/.{2}/g)||[],o=a.map(e=>{let t=parseInt(e,16);return t>=32&&t<=126?String.fromCharCode(t):`.`}).join(``).replace(/\.+$/,``);e.push({name:`Node Name`,byteRange:`${(n+i)/2}-${(n+t.length-1)/2}`,hexData:r.match(/.{2}/g)?.join(` `)||r,description:`Node name string (${a.length} bytes)`,fields:[{bits:`0-${a.length*8-1}`,name:`Node Name`,value:`"${o}"`,binary:`ASCII text (${a.length} bytes)`}]})}}}catch(n){e.push({name:`AppData Parse Error`,byteRange:`N/A`,hexData:t.slice(0,Math.min(32,t.length)),description:`Failed to parse AppData structure`,fields:[{bits:`N/A`,name:`Error`,value:`Parse error: ${n instanceof Error?n.message:`Unknown error`}`,binary:`Invalid`}]})}},A=e=>{if(!e)return[];if(Array.isArray(e))return e;if(typeof e==`string`)try{return JSON.parse(e)}catch{return[]}return[]},te=e=>{let t=[];if(!e)return t;try{let n=e.raw_packet;if(n){let e=n.replace(/\s+/g,``).toUpperCase(),r=0;if(e.length>=2){let n=e.slice(r,r+2),i=parseInt(n,16),a=i&3,o=(i&60)>>2,s=(i&192)>>6;if(t.push({name:`Header`,byteRange:`0`,hexData:`0x${n}`,description:`Contains routing type, payload type, and payload version`,fields:[{bits:`0-1`,name:`Route Type`,value:{0:`Transport Flood`,1:`Flood`,2:`Direct`,3:`Transport Direct`}[a]||`Unknown`,binary:a.toString(2).padStart(2,`0`)},{bits:`2-5`,name:`Payload Type`,value:{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`}[o]||`Unknown`,binary:o.toString(2).padStart(4,`0`)},{bits:`6-7`,name:`Version`,value:s.toString(),binary:s.toString(2).padStart(2,`0`)}]}),r+=2,(a===0||a===3)&&e.length>=r+8){let n=e.slice(r,r+8),i=parseInt(n.slice(0,4),16),a=parseInt(n.slice(4,8),16);t.push({name:`Transport Codes`,byteRange:`1-4`,hexData:`${n.slice(0,4)} ${n.slice(4,8)}`,description:`2x 16-bit transport codes for routing optimization`,fields:[{bits:`0-15`,name:`Code 1`,value:i.toString(),binary:i.toString(2).padStart(16,`0`)},{bits:`16-31`,name:`Code 2`,value:a.toString(),binary:a.toString(2).padStart(16,`0`)}]}),r+=8}if(e.length>=r+2){let n=e.slice(r,r+2),i=parseInt(n,16),a=(i>>6)+1,o=i&63,s=o*a;if(t.push({name:`Path Length`,byteRange:`${r/2}`,hexData:`0x${n}`,description:`${o} hop${o===1?``:`s`}, ${a}-byte hash${a>1?`es`:``} (${s} bytes)`,fields:[{bits:`6-7`,name:`Hash Size`,value:`${a}-byte`,binary:(i>>6&3).toString(2).padStart(2,`0`)},{bits:`0-5`,name:`Hop Count`,value:`${o}`,binary:(i&63).toString(2).padStart(6,`0`)}]}),r+=2,s>0&&e.length>=r+s*2){let n=e.slice(r,r+s*2),i=RegExp(`.{${a*2}}`,`g`),c=n.match(i)||[];t.push({name:`Path Data`,byteRange:`${r/2}-${(r+s*2-2)/2}`,hexData:c.join(` `)||n,description:`${o} × ${a}-byte routing hash${o===1?``:`es`}`,fields:c.map((e,t)=>({bits:`${t*a*8}-${(t+1)*a*8-1}`,name:`Hop ${t+1}`,value:e.toUpperCase(),binary:`${a} byte${a>1?`s`:``}`}))}),r+=s*2}}if(e.length>r){let n=e.slice(r),i=n.length/2;o===4?O(t,n,r):t.push({name:`Payload Data`,byteRange:`${r/2}-${r/2+i-1}`,hexData:n.match(/.{2}/g)?.join(` `)||n,description:`Application data content`,fields:[{bits:`0-${i*8-1}`,name:`Application Data`,value:`${i} bytes`,binary:`${i} bytes (${i*8} bits)`}]})}}}else{if(e.header){let n=e.header.replace(/0x/gi,``).replace(/\s+/g,``).toUpperCase(),r=parseInt(n,16),i=r&3,a=(r&60)>>2,o=(r&192)>>6;t.push({name:`Header`,byteRange:`0`,hexData:`0x${n}`,description:`Contains routing type, payload type, and payload version`,fields:[{bits:`0-1`,name:`Route Type`,value:{0:`Transport Flood`,1:`Flood`,2:`Direct`,3:`Transport Direct`}[i]||`Unknown`,binary:i.toString(2).padStart(2,`0`)},{bits:`2-5`,name:`Payload Type`,value:{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`}[a]||`Unknown`,binary:a.toString(2).padStart(4,`0`)},{bits:`6-7`,name:`Version`,value:o.toString(),binary:o.toString(2).padStart(2,`0`)}]}),e.transport_codes&&t.push({name:`Transport Codes`,byteRange:`1-4`,hexData:e.transport_codes,description:`2x 16-bit transport codes for routing optimization`,fields:[{bits:`0-31`,name:`Transport Codes`,value:e.transport_codes,binary:`Available in separate field`}]}),e.original_path&&e.original_path.length>0&&t.push({name:`Original Path`,byteRange:`?`,hexData:e.original_path.join(` `),description:`Original routing path (${e.original_path.length} nodes)`,fields:[{bits:`0-?`,name:`Path Nodes`,value:`${e.original_path.length} nodes`,binary:`Available as node list`}]}),e.forwarded_path&&e.forwarded_path.length>0&&t.push({name:`Forwarded Path`,byteRange:`?`,hexData:e.forwarded_path.join(` `),description:`Forwarded routing path (${e.forwarded_path.length} nodes)`,fields:[{bits:`0-?`,name:`Path Nodes`,value:`${e.forwarded_path.length} nodes`,binary:`Available as node list`}]})}if(e.payload){let n=e.payload.replace(/\s+/g,``).toUpperCase(),r=n.length/2;e.type===4?O(t,n,0):t.push({name:`Payload Data`,byteRange:`0-${r-1}`,hexData:n.match(/.{2}/g)?.join(` `)||n,description:`Application data content (${r} bytes)`,fields:[{bits:`0-${r*8-1}`,name:`Application Data`,value:`${r} bytes`,binary:`${r} bytes (${r*8} bits)`}]})}}}catch{t.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 t},M=(e,t)=>e==null||t==null?`text-content-muted dark:text-content-muted`:i(t).color,N=e=>{if(e==null)return{level:0,className:`signal-none`};let t=i(e),n,r;return t.bars>=5?(n=4,r=`signal-excellent`):t.bars>=4?(n=3,r=`signal-good`):t.bars>=2?(n=2,r=`signal-fair`):t.bars>=1?(n=1,r=`signal-poor`):(n=0,r=`signal-none`),{level:n,className:r}},P=e=>{if(!e)return[];try{let t=JSON.parse(e);return Array.isArray(t)?t:[]}catch{return[]}},F=e=>e>=1e3?`${(e/1e3).toFixed(2)}s`:`${Math.round(e)}ms`,I=e=>{e.key===`Escape`&&c(`close`)},L=e=>{e.target===e.currentTarget&&c(`close`)};return p(()=>a.isOpen,e=>{e?document.body.style.overflow=`hidden`:document.body.style.overflow=``},{immediate:!0}),(r,i)=>(S(),o(d,{to:`body`},[h(ee,{name:`modal`,appear:``},{default:e(()=>[n.isOpen&&n.packet?(S(),x(`div`,{key:0,class:`fixed inset-0 z-50 flex items-center justify-center p-4 overflow-hidden`,onClick:L,onKeydown:I,tabindex:`0`},[i[51]||=b(`div`,{class:`absolute inset-0 bg-black/60 backdrop-blur-md pointer-events-none`},null,-1),b(`div`,{class:`relative w-full max-w-4xl max-h-[90vh] flex flex-col`,onClick:i[3]||=j(()=>{},[`stop`])},[b(`div`,Pe,[b(`div`,Fe,[b(`div`,null,[i[4]||=b(`h2`,{class:`text-2xl font-bold text-content-primary dark:text-content-primary mb-1`},` Packet Details `,-1),b(`p`,Ie,v(T(n.packet.type))+` - `+v(E(n.packet.route)),1)]),b(`div`,Le,[b(`button`,{onClick:i[0]||=e=>u.value=!u.value,class:s([`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all duration-200`,u.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:u.value?`Hide binary values`:`Show binary values`},[...i[5]||=[b(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4`})],-1),b(`span`,{class:`text-xs font-medium`},`Binary`,-1)]],10,Re),b(`button`,{onClick:i[1]||=e=>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`},[...i[6]||=[b(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])])]),b(`div`,ze,[b(`div`,Be,[i[13]||=b(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-4 flex items-center`},[b(`div`,{class:`w-2 h-2 rounded-full bg-cyan-400 mr-3`}),_(` Basic Information `)],-1),b(`div`,Ve,[b(`div`,He,[b(`div`,Ue,[b(`div`,We,[i[7]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Timestamp`,-1),b(`span`,Ge,v(f(n.packet.timestamp)),1)]),b(`div`,Ke,[i[8]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Packet Hash`,-1),b(`span`,qe,v(n.packet.packet_hash),1)]),n.packet.header?(S(),x(`div`,Je,[i[9]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Header`,-1),b(`span`,Ye,v(n.packet.header),1)])):m(``,!0)]),b(`div`,Xe,[b(`div`,Ze,[i[10]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Type`,-1),b(`span`,Qe,v(n.packet.type)+` (`+v(T(n.packet.type))+`)`,1)]),b(`div`,$e,[i[11]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Route`,-1),b(`span`,et,v(n.packet.route)+` (`+v(E(n.packet.route))+`)`,1)]),b(`div`,tt,[i[12]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Status`,-1),b(`span`,{class:s([`font-semibold`,g(n.packet)])},v(C(n.packet)),3)])])])])]),b(`div`,nt,[i[25]||=b(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-4 flex items-center`},[b(`div`,{class:`w-2 h-2 rounded-full bg-orange-400 mr-3`}),_(` Payload Data `)],-1),b(`div`,rt,[b(`div`,it,[b(`div`,at,[i[14]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Payload Length`,-1),b(`span`,ot,v(n.packet.payload_length||n.packet.length)+` bytes`,1)]),n.packet.payload?(S(),x(`div`,st,[i[23]||=b(`div`,{class:`text-content-secondary dark:text-content-muted text-sm mb-3`},` Payload Analysis `,-1),b(`div`,ct,[i[15]||=b(`div`,{class:`text-content-secondary dark:text-content-muted text-xs mb-2 font-semibold`},` Raw Hex Data `,-1),b(`div`,lt,[b(`pre`,ut,v(D(n.packet.payload)),1)])]),(S(!0),x(y,null,t(te(n.packet).filter(e=>!e.name.includes(`Parse Error`)),(e,n)=>(S(),x(`div`,{key:n,class:`mb-4`},[b(`div`,dt,[b(`h4`,ft,v(e.name),1),b(`span`,pt,`Bytes `+v(e.byteRange),1)]),b(`div`,mt,[b(`div`,ht,v(e.hexData),1)]),b(`div`,gt,[b(`div`,{class:s([`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`,u.value?`grid-cols-4`:`grid-cols-3`])},[i[16]||=b(`div`,{class:`min-w-0`},`Bits`,-1),i[17]||=b(`div`,{class:`min-w-0`},`Field`,-1),i[18]||=b(`div`,{class:`min-w-0`},`Value`,-1),u.value?(S(),x(`div`,_t,`Binary`)):m(``,!0)],2),(S(!0),x(y,null,t(e.fields,(e,t)=>(S(),x(`div`,{key:t,class:s([`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`,u.value?`grid-cols-4`:`grid-cols-3`])},[b(`div`,vt,v(e.bits),1),b(`div`,yt,v(e.name),1),b(`div`,bt,[b(`span`,{class:`block`,title:e.value},v(e.value),9,xt)]),u.value?(S(),x(`div`,St,[b(`span`,{class:`block`,title:e.binary},v(e.binary),9,Ct)])):m(``,!0)],2))),128)),(S(!0),x(y,null,t(e.fields,(e,t)=>(S(),x(`div`,{key:`mobile-${t}`,class:`md:hidden p-3 border-b border-stroke-subtle dark:border-stroke/5 last:border-b-0 space-y-2`},[b(`div`,wt,[b(`div`,null,[i[19]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide`},`Bits:`,-1),b(`div`,Tt,v(e.bits),1)]),b(`div`,null,[i[20]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide`},`Field:`,-1),b(`div`,Et,v(e.name),1)])]),b(`div`,null,[i[21]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide`},`Value:`,-1),b(`div`,{class:`text-content-primary dark:text-content-primary text-sm font-semibold break-all`,title:e.value},v(e.value),9,Dt)]),u.value?(S(),x(`div`,Ot,[i[22]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-xs uppercase tracking-wide`},`Binary:`,-1),b(`div`,{class:`text-orange-500 text-xs font-mono break-all`,title:e.binary},v(e.binary),9,kt)])):m(``,!0)]))),128))]),e.description?(S(),x(`div`,At,v(e.description),1)):m(``,!0)]))),128))])):(S(),x(`div`,jt,[...i[24]||=[b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Payload:`,-1),b(`span`,{class:`text-content-muted dark:text-content-muted ml-2`},`None`,-1)]]))])])]),b(`div`,Mt,[i[33]||=b(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-4 flex items-center`},[b(`div`,{class:`w-2 h-2 rounded-full bg-purple-400 mr-3`}),_(` Path Information `)],-1),b(`div`,Nt,[b(`div`,Pt,[b(`div`,Ft,[b(`div`,It,[i[26]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Source Hash`,-1),b(`span`,{class:s([`text-content-primary dark:text-content-primary font-mono text-xs`,a.localHash&&n.packet.src_hash===a.localHash?`bg-cyan-400/20 text-cyan-600 dark:text-cyan-300 px-1 rounded`:``])},v(n.packet.src_hash||`Unknown`),3)]),b(`div`,Lt,[i[27]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Destination Hash`,-1),b(`span`,{class:s([`text-content-primary dark:text-content-primary font-mono text-xs`,a.localHash&&n.packet.dst_hash===a.localHash?`bg-cyan-400/20 text-cyan-600 dark:text-cyan-300 px-1 rounded`:``])},v(n.packet.dst_hash||`Broadcast`),3)])]),A(n.packet.original_path).length>0?(S(),x(`div`,Rt,[i[29]||=b(`div`,{class:`text-content-secondary dark:text-content-muted text-sm mb-2`},` Original Path `,-1),b(`div`,zt,[b(`div`,Bt,[(S(!0),x(y,null,t(A(n.packet.original_path),(e,t)=>(S(),x(`div`,{key:t,class:`flex items-center`},[b(`div`,Vt,[b(`div`,Ht,[b(`div`,Ut,v(e.toUpperCase()),1)]),b(`div`,Wt,` Node: `+v(e.toUpperCase()),1)]),t0?(S(),x(`div`,Kt,[b(`div`,qt,[i[31]||=_(` Forwarded Path `,-1),JSON.stringify(A(n.packet.original_path))===JSON.stringify(A(n.packet.forwarded_path))?m(``,!0):(S(),x(`svg`,Jt,[...i[30]||=[b(`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)]])),JSON.stringify(A(n.packet.original_path))===JSON.stringify(A(n.packet.forwarded_path))?m(``,!0):(S(),x(`span`,Yt,`(Modified)`))]),b(`div`,Xt,[b(`div`,Zt,[(S(!0),x(y,null,t(A(n.packet.forwarded_path),(e,t)=>(S(),x(`div`,{key:t,class:`flex items-center`},[b(`div`,Qt,[b(`div`,{class:s([`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`,a.localHash&&e===a.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`])},[b(`div`,{class:s([`font-mono text-[10px] font-semibold tracking-tight sm:text-xs`,a.localHash&&e===a.localHash?`text-yellow-200`:`text-white/90`])},v(e.toUpperCase()),3),a.localHash&&e===a.localHash?(S(),x(`div`,$t)):m(``,!0)],2),b(`div`,en,v(e.toUpperCase()),1)]),tb(`div`,{key:e,class:s([`w-2 h-6 rounded-sm transition-all duration-300`,e<=N(n.packet.rssi).level?{"signal-excellent":`bg-green-400`,"signal-good":`bg-cyan-400`,"signal-fair":`bg-yellow-400`,"signal-poor":`bg-red-400`}[N(n.packet.rssi).className]:`bg-stroke-subtle dark:bg-stroke/10`])},null,2)),64))]),b(`span`,mn,v(N(n.packet.rssi).className.replace(`signal-`,``)),1)])])),n.packet.is_trace&&n.packet.path_snr_details&&n.packet.path_snr_details.length>0?(S(),x(`div`,gn,[b(`div`,_n,` Path SNR Details (`+v(n.packet.path_snr_details.length)+` hops) `,1),b(`div`,vn,[(S(!0),x(y,null,t(n.packet.path_snr_details,(e,t)=>(S(),x(`div`,{key:t,class:`flex items-center justify-between p-2 glass-card bg-background-mute dark:bg-black/20 rounded-[8px]`},[b(`div`,yn,[b(`span`,bn,v(t+1)+`.`,1),b(`span`,{class:s([`font-mono text-xs text-content-primary dark:text-content-primary`,a.localHash&&e.hash===a.localHash?`bg-cyan-400/20 text-cyan-600 dark:text-cyan-300 px-1 rounded`:``])},v(e.hash.toUpperCase()),3)]),b(`span`,{class:s([`text-sm font-bold`,M(e.snr_db,null)])},v(e.snr_db.toFixed(1))+`dB `,3)]))),128))])])):m(``,!0),n.packet.transmitted&&n.packet.lbt_attempts!==void 0?(S(),x(`div`,xn,[i[45]||=b(`div`,{class:`text-content-secondary dark:text-content-muted text-sm mb-3 flex items-center`},[b(`svg`,{class:`w-4 h-4 mr-2`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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`})]),_(` Listen Before Talk (LBT) Metrics `)],-1),b(`div`,Sn,[b(`div`,Cn,[i[41]||=b(`div`,{class:`text-content-secondary dark:text-content-muted text-xs mb-1`},` CAD Attempts `,-1),b(`div`,wn,v(n.packet.lbt_attempts),1)]),b(`div`,Tn,[i[42]||=b(`div`,{class:`text-content-secondary dark:text-content-muted text-xs mb-1`},` Total LBT Delay `,-1),b(`div`,En,v(F(P(n.packet.lbt_backoff_delays_ms).reduce((e,t)=>e+t,0))),1),b(`div`,Dn,v(P(n.packet.lbt_backoff_delays_ms).length)+` backoffs `,1)]),b(`div`,On,[i[43]||=b(`div`,{class:`text-content-secondary dark:text-content-muted text-xs mb-1`},` Channel Status `,-1),b(`div`,{class:s([`text-lg font-bold`,n.packet.lbt_channel_busy?`text-yellow-600 dark:text-yellow-400`:`text-green-600 dark:text-green-400`])},v(n.packet.lbt_channel_busy?`BUSY`:`CLEAR`),3),b(`div`,kn,v(n.packet.lbt_channel_busy?`Waited for clear`:`Immediate TX`),1)])]),P(n.packet.lbt_backoff_delays_ms).length>0?(S(),x(`div`,An,[i[44]||=b(`div`,{class:`text-content-secondary dark:text-content-muted text-xs mb-3 font-semibold`},` Backoff Pattern (Exponential with Jitter) `,-1),b(`div`,jn,[(S(!0),x(y,null,t(P(n.packet.lbt_backoff_delays_ms),(e,t)=>(S(),x(`div`,{key:t,class:`flex items-center gap-3`},[b(`div`,Mn,[b(`span`,Nn,`Attempt `+v(t+1),1)]),b(`div`,Pn,[b(`div`,Fn,[b(`div`,{class:s([`h-full rounded-lg transition-all duration-300`,[t===0?`bg-gradient-to-r from-cyan-500/50 to-cyan-600/50`:t===1?`bg-gradient-to-r from-yellow-500/50 to-yellow-600/50`:t===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:l({width:`${Math.min(100,e/Math.max(...P(n.packet.lbt_backoff_delays_ms))*100)}%`})},[b(`div`,In,[b(`span`,Ln,v(F(e)),1)])],6)])]),b(`div`,Rn,[b(`span`,zn,v(Math.round(e/P(n.packet.lbt_backoff_delays_ms).reduce((e,t)=>e+t,0)*100))+`% `,1)])]))),128))])])):m(``,!0)])):m(``,!0),b(`div`,Bn,[b(`div`,Vn,[b(`div`,Hn,[i[46]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`TX Delay`,-1),b(`span`,Un,v(Number(n.packet.tx_delay_ms)>0?Number(n.packet.tx_delay_ms).toFixed(1)+`ms`:`-`),1)]),b(`div`,Wn,[i[47]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Transmitted`,-1),b(`span`,{class:s(n.packet.transmitted?`text-green-600 dark:text-green-400`:`text-red-600 dark:text-red-400`)},v(n.packet.transmitted?`Yes`:`No`),3)])]),b(`div`,Gn,[b(`div`,Kn,[i[48]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Is Duplicate`,-1),b(`span`,{class:s(n.packet.is_duplicate?`text-amber-600 dark:text-amber-400`:`text-content-muted dark:text-content-muted`)},v(n.packet.is_duplicate?`Yes`:`No`),3)]),n.packet.drop_reason?(S(),x(`div`,qn,[i[49]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Drop Reason`,-1),b(`span`,Jn,v(n.packet.drop_reason),1)])):m(``,!0)])])])])]),b(`div`,Yn,[b(`button`,{onClick:i[2]||=e=>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)):m(``,!0)]),_:1})]))}}),[[`__scopeId`,`data-v-c8711b75`]]),Zn={class:`glass-card rounded-[20px] p-6`},Qn={class:`flex flex-col lg:flex-row lg:justify-between lg:items-center mb-6 gap-4 filter-container`},$n={class:`flex items-center gap-2 header-info relative`},er={class:`text-content-secondary dark:text-content-muted text-sm packet-count`},tr=[`title`],nr={class:`hidden sm:inline`},rr={key:1,class:`text-accent-red text-sm error-indicator`},ir={class:`flex items-center gap-3 lg:flex filter-controls`},ar={class:`flex flex-col`},or=[`value`],sr={class:`flex flex-col`},cr=[`value`],lr={class:`flex flex-col`},ur={class:`flex flex-col reset-container`},dr=[`disabled`],fr={class:`space-y-4 overflow-hidden`},pr={class:`space-y-4`},mr=[`onClick`],hr={class:`hidden lg:grid grid-cols-12 gap-2 items-center`},gr={class:`col-span-1 text-content-primary dark:text-content-primary text-sm`},_r={class:`col-span-1 flex items-center gap-2`},vr={class:`flex flex-col`},yr={class:`text-content-primary dark:text-content-primary text-xs`},br=[`title`],xr={class:`col-span-2`},Sr={class:`col-span-1 text-content-primary dark:text-content-primary text-xs`},Cr={class:`col-span-2`},wr={class:`space-y-1`},Tr={key:0,class:`flex items-center gap-0.5 flex-wrap`},Er=[`title`],Dr={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`},Or={key:0,class:`text-[9px] text-content-muted dark:text-content-muted ml-1`},kr={key:1,class:`flex items-center gap-1`},Ar={class:`inline-block px-2 py-0.5 rounded bg-badge-cyan-bg text-badge-cyan-text text-xs font-mono`},jr={class:`col-span-1 text-content-primary dark:text-content-primary text-xs`},Mr={class:`col-span-1 text-content-primary dark:text-content-primary text-xs`},Nr={class:`col-span-1 text-content-primary dark:text-content-primary text-xs`},Pr={class:`col-span-1 text-content-primary dark:text-content-primary text-xs`},Fr={key:0,class:`flex items-center gap-1`},Ir={class:`col-span-1`},Lr={key:0,class:`text-accent-red text-[8px] italic truncate`},Rr={class:`lg:hidden space-y-2`},zr={class:`flex items-center justify-between`},Br={class:`flex items-center gap-2`},Vr={class:`flex flex-col`},Hr={class:`text-content-primary dark:text-content-primary text-sm font-medium`},Ur=[`title`],Wr={class:`flex items-center gap-2 text-right`},Gr={class:`text-content-secondary dark:text-content-muted text-xs`},Kr={class:`flex items-center justify-between`},qr={class:`flex items-center gap-1.5`},Jr={key:0,class:`flex flex-wrap items-center gap-0.5`},Yr=[`title`],Xr={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`},Zr={key:0,class:`text-[9px] text-content-muted dark:text-content-muted ml-1`},Qr={class:`flex items-center gap-1`},$r={class:`inline-block px-2 py-0.5 rounded bg-badge-cyan-bg text-badge-cyan-text text-xs font-mono font-semibold`},ei={class:`flex items-center gap-0.5 text-content-muted dark:text-content-muted/60`},ti={key:0,class:`text-[9px] font-medium`,title:`Multi-hop path`},ni={class:`flex items-center gap-1`},ri={class:`flex items-center gap-2`},ii={class:`flex items-center gap-1`},ai={key:0,class:`flex gap-0.5`},oi={class:`text-content-primary dark:text-content-primary text-xs`},si={class:`flex items-center justify-between text-content-secondary dark:text-content-muted text-xs`},ci={class:`flex items-center gap-3`},li={class:`flex items-center gap-2`},ui={key:0,class:`flex items-center gap-1`},di={key:0,class:`text-accent-red text-xs italic`},fi={key:0,class:`flex justify-between items-center mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke pagination-container`},pi={class:`flex items-center gap-4 pagination-info`},mi={class:`text-content-secondary dark:text-content-muted text-sm`},hi={key:0,class:`flex items-center gap-2 load-more-section`},gi=[`disabled`],_i={class:`text-content-secondary dark:text-content-muted text-xs load-more-count`},vi={class:`flex items-center gap-2 pagination-controls`},yi=[`disabled`],bi={class:`flex items-center gap-1 page-numbers`},xi={key:1,class:`text-content-secondary dark:text-content-muted text-sm px-2 ellipsis`},Si=[`onClick`],Ci={key:2,class:`text-content-secondary dark:text-content-muted text-sm px-2 ellipsis`},wi=[`disabled`],Ti={key:1,class:`flex justify-center mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke`},Ei={class:`flex items-center gap-4`},Di={class:`text-content-secondary dark:text-content-muted text-sm`},Oi={class:`text-content-secondary dark:text-content-muted text-xs`},ki={key:2,class:`flex justify-center mt-6 pt-4 border-t border-stroke-subtle dark:border-stroke`},Q=10,$=1e3,Ai=k(u({name:`PacketTable`,__name:`PacketTable`,setup(e){let n=D(),a=O(),o=w(1),l=w(100),u=w(!1),d=w(!1),_=null;p(()=>n.isLoading,e=>{e?(_&&=(clearTimeout(_),null),d.value=!0):_=window.setTimeout(()=>{d.value=!1,_=null},600)});let T=w(null),E=w(!1),k=async e=>{if(T.value=e,E.value=!0,e.packet_hash&&(!e.header||!e.raw_packet))try{let t=await n.getPacketByHash(e.packet_hash);t&&T.value?.packet_hash===e.packet_hash&&(T.value={...T.value,...t})}catch{}},ee=()=>{E.value=!1,T.value=null},j=w(N(`packetTable_selectedType`,`all`)),P=w(N(`packetTable_selectedRoute`,`all`)),F=w(!1),I=w(null),L=[`all`,`0`,`1`,`2`,`3`,`4`,`5`,`6`,`7`,`8`,`9`,`10`,`11`],ne=[`all`,`1`,`2`];p(j,e=>{M(`packetTable_selectedType`,e),o.value=1}),p(P,e=>{M(`packetTable_selectedRoute`,e),o.value=1}),p(F,()=>{o.value=1});let R=g(()=>{let e=n.recentPackets;if(j.value!==`all`){let t=parseInt(j.value);e=e.filter(e=>e.type===t)}if(P.value!==`all`){let t=parseInt(P.value);e=e.filter(e=>e.route===t)}return F.value&&I.value!==null&&(e=e.filter(e=>e.timestamp>=I.value)),e}),re=g(()=>{let e=(o.value-1)*Q,t=e+Q;return R.value.slice(e,t)}),z=g(()=>Math.ceil(R.value.length/Q)),ie=g(()=>o.value===z.value),B=g(()=>n.recentPackets.length>=l.value&&l.value<$),ae=g(()=>ie.value&&B.value&&!u.value),V=e=>new Date(e*1e3).toLocaleTimeString(void 0,{hour12:!0}),H=e=>({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`})[e]||`TYPE_${e}`,U=e=>({0:`T-Flood`,1:`Flood`,2:`Direct`,3:`T-Direct`})[e]||`Route ${e}`,W=e=>e.transmitted?`text-accent-green`:`text-primary`,G=e=>e.drop_reason?`Dropped`:e.transmitted?`Forward`:`Received`,K=e=>e===1?`bg-badge-cyan-bg text-badge-cyan-text`:`bg-badge-neutral-bg text-badge-neutral-text`,q=e=>({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`})[e]||`bg-gray-500`,oe=e=>({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`})[e]||`border-l-gray-500`,J=e=>!e.transmitted||!e.lbt_attempts||e.lbt_attempts===0?`bg-green-400`:e.lbt_attempts===1?`bg-cyan-400`:e.lbt_attempts===2?`bg-yellow-400`:`bg-orange-400`,se=e=>e>=1e3?(e/1e3).toFixed(2)+`s`:e.toFixed(1)+`ms`,ce=e=>{if(!e)return[];if(Array.isArray(e))return e;if(typeof e==`string`)try{let t=JSON.parse(e);return typeof t==`string`?JSON.parse(t):Array.isArray(t)?t:[]}catch{return[]}return[]},Y=e=>{let t=ce(e.original_path),n=ce(e.forwarded_path),r=t.length>0?t:n;return r.length===0?null:{hops:r.length-1,nodes:r.map(e=>e.toUpperCase())}},X=e=>{if(e.type!==4||!e.payload)return null;try{let t=e.payload.replace(/\s+/g,``).toUpperCase(),n=t,r=0;if(t.length/2>=100)if(t.length>200)n=t.slice(200),r=0;else return null;if(n.length>=2){let e=parseInt(n.slice(0,2),16);r+=2;let t=!!(e&16),i=!!(e&32),a=!!(e&64);if(!(e&128))return null;if(t&&n.length>=r+16&&(r+=16),i&&n.length>=r+4&&(r+=4),a&&n.length>=r+4&&(r+=4),n.length>r){let e=(n.slice(r).match(/.{2}/g)||[]).map(e=>{let t=parseInt(e,16);return t>=32&&t<=126?String.fromCharCode(t):`.`}).join(``).replace(/\.*$/,``);return e.length>0?e:null}}}catch(e){console.error(`Error parsing ADVERT node name:`,e)}return null},le=()=>{j.value=`all`,P.value=`all`,F.value=!1,I.value=null,o.value=1},ue=()=>{F.value?(F.value=!1,I.value=null):(F.value=!0,I.value=Date.now()/1e3),o.value=1},de=g(()=>I.value?new Date(I.value*1e3).toLocaleTimeString(void 0,{hour12:!0}):``),Z=async e=>{try{let t=e||l.value;await n.fetchRecentPackets({limit:t})}catch(e){console.error(`Error fetching packet data:`,e)}},fe=async()=>{if(!(u.value||l.value>=$)){u.value=!0;try{let e=Math.min(l.value+200,$);l.value=e,await Z(e)}catch(e){console.error(`Error loading more records:`,e)}finally{u.value=!1}}};return i(async()=>{await Z()}),A(()=>Z(),{intervalMs:1e4,enabled:()=>!a.isConnected,immediate:!1}),C(()=>{_&&clearTimeout(_)}),(e,i)=>(S(),x(y,null,[b(`div`,Zn,[b(`div`,Qn,[b(`div`,$n,[i[7]||=b(`h3`,{class:`text-content-primary dark:text-content-primary text-xl font-semibold`},` Recent Packets `,-1),b(`span`,er,` (`+v(R.value.length)+` of `+v(r(n).recentPackets.length)+`) `,1),F.value?(S(),x(`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 ${de.value}`},[b(`span`,nr,`Live Mode (since `+v(de.value)+`)`,1),i[6]||=b(`span`,{class:`sm:hidden`},`Live`,-1)],8,tr)):m(``,!0),r(n).error?(S(),x(`span`,rr,v(r(n).error),1)):m(``,!0)]),b(`div`,ir,[b(`div`,ar,[i[8]||=b(`label`,{class:`text-content-secondary dark:text-content-muted text-xs mb-1`},`Type`,-1),f(b(`select`,{"onUpdate:modelValue":i[0]||=e=>j.value=e,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`},[(S(),x(y,null,t(L,e=>b(`option`,{key:e,value:e,class:`bg-surface dark:bg-surface-elevated text-content-primary dark:text-content-primary`},v(e===`all`?`All Types`:`Type ${e} (${H(parseInt(e))})`),9,or)),64))],512),[[te,j.value]])]),b(`div`,sr,[i[9]||=b(`label`,{class:`text-content-secondary dark:text-content-muted text-xs mb-1`},`Route`,-1),f(b(`select`,{"onUpdate:modelValue":i[1]||=e=>P.value=e,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`},[(S(),x(y,null,t(ne,e=>b(`option`,{key:e,value:e,class:`bg-surface dark:bg-surface-elevated text-content-primary dark:text-content-primary`},v(e===`all`?`All Routes`:`Route ${e} (${U(parseInt(e))})`),9,cr)),64))],512),[[te,P.value]])]),b(`div`,lr,[i[10]||=b(`label`,{class:`text-content-secondary dark:text-content-muted text-xs mb-1`},`Filter`,-1),b(`button`,{onClick:ue,class:s([`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":F.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":!F.value}])},v(F.value?`New Only`:`Show New`),3)]),b(`div`,ur,[i[11]||=b(`label`,{class:`text-transparent text-xs mb-1`},`.`,-1),b(`button`,{onClick:le,class:s([`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":j.value===`all`&&P.value===`all`&&!F.value,"hover:bg-primary/10":j.value!==`all`||P.value!==`all`||F.value}]),disabled:j.value===`all`&&P.value===`all`&&!F.value},` Reset `,10,dr)])])]),i[25]||=c(``,1),b(`div`,fr,[b(`div`,pr,[(S(!0),x(y,null,t(re.value,(e,n)=>(S(),x(`div`,{key:`${e.packet_hash}_${e.timestamp}_${n}`,class:s([`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`,oe(e.type)]),onClick:t=>k(e)},[b(`div`,hr,[b(`div`,gr,v(V(e.timestamp)),1),b(`div`,_r,[b(`div`,{class:s([`w-2 h-2 rounded-full`,q(e.type)])},null,2),b(`div`,vr,[b(`span`,yr,v(H(e.type)),1),e.type===4&&X(e)?(S(),x(`span`,{key:0,class:`text-accent-red/70 text-[10px] font-medium max-w-[80px] truncate`,title:X(e)||void 0},v(X(e)),9,br)):m(``,!0)])]),b(`div`,xr,[b(`span`,{class:s([`inline-block px-2 py-1 rounded text-xs font-medium`,K(e.route)])},v(U(e.route)),3)]),b(`div`,Sr,v(e.length)+`B `,1),b(`div`,Cr,[b(`div`,wr,[Y(e)?(S(),x(`div`,Tr,[(S(!0),x(y,null,t(Y(e).nodes,(t,n)=>(S(),x(y,{key:n},[b(`span`,{class:s([`inline-block max-w-full truncate px-1.5 py-0.5 rounded text-[9px] font-mono font-semibold leading-tight tracking-tight`,n===0?`bg-badge-cyan-bg text-badge-cyan-text`:`bg-gray-500/20 text-content-muted dark:text-content-muted`]),title:t},v(t),11,Er),n0?(S(),x(`span`,Or,` (`+v(Y(e).hops)+` hop`+v(Y(e).hops>1?`s`:``)+`) `,1)):m(``,!0)])):(S(),x(`div`,kr,[b(`span`,Ar,v(e.src_hash?.slice(-4).toUpperCase()||`????`),1),i[13]||=b(`svg`,{class:`w-3 h-3 text-content-muted dark:text-content-muted/60`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2.5`,d:`M9 5l7 7-7 7`})],-1),b(`span`,{class:s([`inline-block px-2 py-0.5 rounded text-xs font-mono`,e.dst_hash?`bg-badge-cyan-bg text-badge-cyan-text`:`bg-yellow-500/20 text-yellow-700 dark:text-yellow-300`])},v(e.dst_hash?e.dst_hash.slice(-4).toUpperCase():`BCAST`),3)]))])]),b(`div`,jr,v(e.rssi==null?`N/A`:e.rssi.toFixed(0)),1),b(`div`,Mr,v(e.snr==null?`N/A`:e.snr.toFixed(1)+`dB`),1),b(`div`,Nr,v(e.score==null?`N/A`:e.score.toFixed(2)),1),b(`div`,Pr,[Number(e.tx_delay_ms)>0?(S(),x(`div`,Fr,[e.transmitted?(S(),x(`div`,{key:0,class:s([`w-1.5 h-1.5 rounded-full flex-shrink-0`,J(e)])},null,2)):m(``,!0),b(`span`,null,v(se(Number(e.tx_delay_ms))),1)])):m(``,!0)]),b(`div`,Ir,[b(`div`,null,[b(`span`,{class:s([`text-xs font-medium`,W(e)])},v(G(e)),3),e.drop_reason?(S(),x(`p`,Lr,v(e.drop_reason),1)):m(``,!0)])])]),b(`div`,Rr,[b(`div`,zr,[b(`div`,Br,[b(`div`,{class:s([`w-2 h-2 rounded-full flex-shrink-0`,q(e.type)])},null,2),b(`div`,Vr,[b(`span`,Hr,v(H(e.type)),1),e.type===4&&X(e)?(S(),x(`span`,{key:0,class:`text-accent-red/70 text-[10px] font-medium leading-tight`,title:X(e)||void 0},v(X(e)),9,Ur)):m(``,!0)]),b(`span`,{class:s([`inline-block px-2 py-1 rounded text-xs font-medium ml-2`,K(e.route)])},v(U(e.route)),3)]),b(`div`,Wr,[b(`span`,Gr,v(V(e.timestamp)),1),b(`span`,{class:s([`text-xs font-medium`,W(e)])},v(G(e)),3)])]),b(`div`,Kr,[b(`div`,qr,[Y(e)?(S(),x(`div`,Jr,[i[15]||=b(`span`,{class:`text-content-muted dark:text-content-muted text-[10px] font-medium`},`PATH`,-1),(S(!0),x(y,null,t(Y(e).nodes,(t,n)=>(S(),x(y,{key:n},[b(`span`,{class:s([`inline-block max-w-full truncate px-1.5 py-0.5 rounded text-[9px] font-mono font-semibold leading-tight tracking-tight`,n===0?`bg-badge-cyan-bg text-badge-cyan-text`:`bg-gray-500/20 text-content-muted dark:text-content-muted`]),title:t},v(t),11,Yr),n0?(S(),x(`span`,Zr,` (`+v(Y(e).hops)+` hop`+v(Y(e).hops>1?`s`:``)+`) `,1)):m(``,!0)])):(S(),x(y,{key:1},[b(`div`,Qr,[i[16]||=b(`span`,{class:`text-content-muted dark:text-content-muted text-[10px] font-medium`},`SRC`,-1),b(`span`,$r,v(e.src_hash?.slice(-4)||`????`),1)]),b(`div`,ei,[i[18]||=b(`svg`,{class:`w-3 h-3`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2.5`,d:`M9 5l7 7-7 7`})],-1),e.route===1?(S(),x(`span`,ti,[...i[17]||=[b(`svg`,{class:`w-2.5 h-2.5 inline`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 5l7 7-7 7M5 5l7 7-7 7`})],-1)]])):m(``,!0)]),b(`div`,ni,[b(`span`,{class:s([`inline-block px-2 py-0.5 rounded text-xs font-mono font-semibold`,e.dst_hash?`bg-badge-cyan-bg text-badge-cyan-text`:`bg-yellow-500/20 text-yellow-700 dark:text-yellow-300`])},v(e.dst_hash?e.dst_hash.slice(-4).toUpperCase():`BCAST`),3),i[19]||=b(`span`,{class:`text-content-muted dark:text-content-muted text-[10px] font-medium`},`DST`,-1)])],64))]),b(`div`,ri,[b(`div`,ii,[e.snr==null?m(``,!0):(S(),x(`div`,ai,[b(`div`,{class:s([`w-1 h-3 rounded-sm`,e.snr>=-10?`bg-green-400`:`bg-white/20`])},null,2),b(`div`,{class:s([`w-1 h-4 rounded-sm`,e.snr>=-5?`bg-green-400`:`bg-white/20`])},null,2),b(`div`,{class:s([`w-1 h-5 rounded-sm`,e.snr>=0?`bg-green-400`:`bg-white/20`])},null,2),b(`div`,{class:s([`w-1 h-6 rounded-sm`,e.snr>=10?`bg-green-400`:`bg-white/20`])},null,2)])),b(`span`,oi,v(e.rssi==null?`TX`:e.rssi.toFixed(0)+`dBm`),1)])])]),b(`div`,si,[b(`div`,ci,[b(`span`,null,v(e.length)+`B`,1),b(`span`,null,`SNR: `+v(e.snr==null?`N/A`:e.snr.toFixed(1)+`dB`),1),b(`span`,null,`Score: `+v(e.score==null?`N/A`:e.score.toFixed(2)),1)]),b(`div`,li,[Number(e.tx_delay_ms)>0?(S(),x(`span`,ui,[e.transmitted?(S(),x(`div`,{key:0,class:s([`w-1.5 h-1.5 rounded-full flex-shrink-0`,J(e)])},null,2)):m(``,!0),b(`span`,null,v(se(Number(e.tx_delay_ms))),1)])):m(``,!0)])]),e.drop_reason?(S(),x(`div`,di,v(e.drop_reason),1)):m(``,!0)])],10,mr))),128))])]),z.value>1?(S(),x(`div`,fi,[b(`div`,pi,[b(`span`,mi,` Showing `+v((o.value-1)*Q+1)+` - `+v(Math.min(o.value*Q,R.value.length))+` of `+v(R.value.length)+` packets `,1),ae.value?(S(),x(`div`,hi,[i[20]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-xs`},`•`,-1),b(`button`,{onClick:fe,disabled:u.value,class:s([`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":!u.value,"text-content-secondary dark:text-content-muted border-stroke-subtle dark:border-stroke cursor-not-allowed opacity-50":u.value}])},v(u.value?`Loading...`:`Load ${Math.min(200,$-l.value)} more`),11,gi),b(`span`,_i,`(`+v(l.value)+`/`+v($)+` max)`,1)])):m(``,!0)]),b(`div`,vi,[b(`button`,{onClick:i[2]||=e=>--o.value,disabled:o.value<=1,class:s([`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":o.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":o.value>1}])},[...i[21]||=[b(`span`,{class:`hidden sm:inline`},`Previous`,-1),b(`span`,{class:`sm:hidden`},`‹`,-1)]],10,yi),b(`div`,bi,[o.value>3?(S(),x(`button`,{key:0,onClick:i[3]||=e=>o.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 `)):m(``,!0),o.value>4?(S(),x(`span`,xi,`...`)):m(``,!0),(S(!0),x(y,null,t(Array.from({length:Math.min(5,z.value)},(e,t)=>Math.max(1,Math.min(o.value-2,z.value-4))+t).filter(e=>e<=z.value),e=>(S(),x(`button`,{key:e,onClick:t=>o.value=e,class:s([`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":o.value===e,"border-stroke-subtle dark:border-stroke text-content-primary dark:text-content-primary hover:border-primary hover:text-primary hover:bg-primary/5":o.value!==e}])},v(e),11,Si))),128)),o.valueo.value=z.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`},v(z.value),1)):m(``,!0)]),b(`button`,{onClick:i[5]||=e=>o.value+=1,disabled:o.value>=z.value,class:s([`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":o.value>=z.value,"border-stroke-subtle dark:border-stroke text-content-primary dark:text-content-primary hover:border-primary hover:text-primary hover:bg-primary/5":o.value(S(),x(`div`,null,[h(G),b(`div`,ji,[h(Ne),h(_e)]),h(Ai)]))}});export{Mi as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Help-CaIFoQMt.js b/repeater/web/html/assets/Help-CaIFoQMt.js new file mode 100644 index 0000000..749b931 --- /dev/null +++ b/repeater/web/html/assets/Help-CaIFoQMt.js @@ -0,0 +1 @@ +import{f as e,g as t,u as n,w as r}from"./runtime-core.esm-bundler-HnidnMFy.js";var i=t({name:`HelpView`,__name:`Help`,setup(t){return(t,i)=>(r(),n(`div`,null,[...i[0]||=[e(`

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{i as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Login-CRioMgum.css b/repeater/web/html/assets/Login-CRioMgum.css new file mode 100644 index 0000000..3b1a484 --- /dev/null +++ b/repeater/web/html/assets/Login-CRioMgum.css @@ -0,0 +1 @@ +.bg-gradient-light[data-v-fec81ee3]{background:linear-gradient(#0ea5e966,#06b6d44d)}.bg-gradient-dark[data-v-fec81ee3]{background:linear-gradient(#67e8f94d,#a5f3fc26)}.login-card[data-v-fec81ee3]{-webkit-backdrop-filter:blur(40px)saturate(180%);background:#ffffffb3}.dark .login-card[data-v-fec81ee3]{background:#11191c66}.input-glass[data-v-fec81ee3]{-webkit-backdrop-filter:blur(20px);background:#ffffffe6;border:1px solid #d1d5db}.dark .input-glass[data-v-fec81ee3]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-fec81ee3]:focus{background:#fff}.dark .input-glass[data-v-fec81ee3]:focus{background:#ffffff1a}.input-glass[data-v-fec81ee3]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-fec81ee3]{opacity:0;transition:opacity .3s;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-fec81ee3]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-fec81ee3]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-fec81ee3]:before{content:"";-webkit-mask-composite:xor;background:linear-gradient(90deg,#0000 0%,#aae8e84d 50%,#0000 100%);border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-image:linear-gradient(#fff 0 0),linear-gradient(#fff 0 0);-webkit-mask-position:0 0,0 0;-webkit-mask-size:auto,auto;-webkit-mask-repeat:repeat,repeat;-webkit-mask-clip:content-box,border-box;-webkit-mask-origin:content-box,border-box;-webkit-mask-composite:xor;mask-composite:exclude;-webkit-mask-source-type:auto,auto;mask-mode:match-source,match-source}.button-glass[data-v-fec81ee3]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-fec81ee3]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-fec81ee3]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}.login-content:has(.button-glass:hover:not(:disabled)) .logo-image[data-v-fec81ee3]{filter:brightness(1.4)drop-shadow(0 0 12px #aae8e8b3);transform:scale(1.02)}.login-content:has(.button-glass:hover:not(:disabled)) .logo-glow[data-v-fec81ee3]{opacity:.6;transform:scale(1.15)}.logo-glow[data-v-fec81ee3]{opacity:0}.dark .logo-glow[data-v-fec81ee3]{opacity:1}@keyframes float-fec81ee3{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-fec81ee3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-fec81ee3{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-fec81ee3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-fec81ee3]{animation:8s ease-in-out infinite pulse-slow-fec81ee3}.animate-pulse-slower[data-v-fec81ee3]{animation:10s ease-in-out infinite pulse-slower-fec81ee3}.animate-pulse-slowest[data-v-fec81ee3]{animation:12s ease-in-out infinite pulse-slowest-fec81ee3}@keyframes shake-fec81ee3{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-fec81ee3]{animation:.5s ease-in-out shake-fec81ee3}@keyframes logo-aura-cycle-fec81ee3{0%,to{filter:brightness()saturate()drop-shadow(0 0 7px #38bdf873)}25%{filter:brightness(1.02)saturate(1.05)drop-shadow(0 0 10px #6366f16b)}50%{filter:brightness()saturate(1.03)drop-shadow(0 0 8px #22d3ee73)}75%{filter:brightness(1.02)saturate(1.05)drop-shadow(0 0 10px #34d3996b)}}.logo-image-animated[data-v-fec81ee3]{will-change:filter;animation:6s ease-in-out infinite logo-aura-cycle-fec81ee3}.form-group[data-v-fec81ee3]{position:relative}.form-group:hover label[data-v-fec81ee3]{color:#aae8e8e6;transition:color .3s} diff --git a/repeater/web/html/assets/Login-Yx7HUvzW.js b/repeater/web/html/assets/Login-Yx7HUvzW.js new file mode 100644 index 0000000..8045fde --- /dev/null +++ b/repeater/web/html/assets/Login-Yx7HUvzW.js @@ -0,0 +1 @@ +import{g as e,j as t,l as n,m as r,p as i,pt as a,s as o,u as s,w as c,z as l}from"./runtime-core.esm-bundler-HnidnMFy.js";import{i as u}from"./vue-router-Cr0wB7EX.js";import{f as d,n as f,r as p,s as m}from"./api-CbM6k1ZB.js";import{t as h}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{f as g,h as _,i as v,r as y,t as b}from"./index-BFltqMtv.js";var x={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`},S={key:0,class:`bg-red-500/10 border border-red-500/30 rounded-lg p-3`},C={class:`text-red-600 dark:text-red-400 text-sm`},w={key:1,class:`bg-green-500/10 border border-green-600/40 dark:border-green-500/30 rounded-lg p-3`},T={class:`text-green-600 dark:text-green-400 text-sm`},E={class:`flex justify-end gap-3 mt-6`},D=[`disabled`],O=[`disabled`],k={key:0,class:`w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin`},A=e({name:`ChangePasswordModal`,__name:`ChangePasswordModal`,props:{isOpen:{type:Boolean},canSkip:{type:Boolean,default:!0}},emits:[`close`,`success`],setup(e,{emit:r}){let u=r,d=l(``),p=l(``),m=l(``),h=l(!1),v=l(``),y=l(``),b=()=>{h.value||u(`close`)},A=()=>{u(`close`)},j=async()=>{if(v.value=``,y.value=``,p.value.length<8){v.value=`New password must be at least 8 characters long`;return}if(p.value!==m.value){v.value=`Passwords do not match`;return}if(p.value===d.value){v.value=`New password must be different from current password`;return}h.value=!0;try{let e=(await f.post(`/auth/change_password`,{current_password:d.value,new_password:p.value})).data;e&&e.success?(y.value=e.message||`Password changed successfully!`,setTimeout(()=>{u(`success`),u(`close`)},1500)):v.value=e?.error||`Failed to change password`}catch(e){console.error(`Password change error:`,e),v.value=e.response?.data?.error||`Failed to change password. Please try again.`}finally{h.value=!1}};return(r,l)=>e.isOpen?(c(),s(`div`,{key:0,class:`fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm`,onClick:_(b,[`self`])},[o(`div`,x,[l[6]||=o(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary mb-2`},` Change Default Password `,-1),l[7]||=o(`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),o(`form`,{onSubmit:_(j,[`prevent`]),class:`space-y-4`},[o(`div`,null,[l[3]||=o(`label`,{class:`block text-sm font-medium text-content-secondary dark:text-content-primary/70 mb-2`},`Current Password`,-1),t(o(`input`,{"onUpdate:modelValue":l[0]||=e=>d.value=e,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),[[g,d.value]])]),o(`div`,null,[l[4]||=o(`label`,{class:`block text-sm font-medium text-content-secondary dark:text-content-primary/70 mb-2`},`New Password`,-1),t(o(`input`,{"onUpdate:modelValue":l[1]||=e=>p.value=e,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),[[g,p.value]])]),o(`div`,null,[l[5]||=o(`label`,{class:`block text-sm font-medium text-content-secondary dark:text-content-primary/70 mb-2`},`Confirm New Password`,-1),t(o(`input`,{"onUpdate:modelValue":l[2]||=e=>m.value=e,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),[[g,m.value]])]),v.value?(c(),s(`div`,S,[o(`p`,C,a(v.value),1)])):n(``,!0),y.value?(c(),s(`div`,w,[o(`p`,T,a(y.value),1)])):n(``,!0),o(`div`,E,[e.canSkip?(c(),s(`button`,{key:0,type:`button`,onClick:A,disabled:h.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,D)):n(``,!0),o(`button`,{type:`submit`,disabled:h.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`},[h.value?(c(),s(`div`,k)):n(``,!0),i(` `+a(h.value?`Changing...`:`Change Password`),1)],8,O)])],32)])])):n(``,!0)}}),j={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`},M={class:`absolute top-4 right-4 z-20`},N={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`},P={class:`relative login-content`},F={class:`form-group`},I={class:`relative`},L=[`disabled`],R={class:`form-group`},z={class:`relative`},B=[`disabled`],V={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`},H={class:`text-red-600 dark:text-red-400 text-xs sm:text-sm font-medium`},U=[`disabled`],W={key:0,class:`w-4 h-4 sm:w-5 sm:h-5 border-2 border-white border-t-transparent rounded-full animate-spin`},G={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`},K={class:`relative`},q={class:`mt-6 sm:mt-8 pt-4 sm:pt-6 border-t border-stroke-subtle dark:border-stroke/10`},J={class:`flex items-center justify-center gap-3`},Y={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`},X={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`},Z=h(e({name:`LoginView`,__name:`Login`,setup(e){let i=u(),h=p(),x=l(`admin`),S=l(``),C=l(!1),w=l(``),T=l(!1),E=l(!1),D=async()=>{w.value=``,C.value=!0;try{let e=m(),t=(await f.post(`/auth/login`,{username:x.value,password:S.value,client_id:e})).data;t.success&&t.token?S.value===`admin123`?(d(t.token),h.markAuthenticated(),E.value=!0,T.value=!0):(d(t.token),h.markAuthenticated(),i.push(`/`)):w.value=t.error||`Login failed`}catch(e){console.error(`Login error:`,e),w.value=e.response?.data?.error||`Connection error. Please try again.`}finally{C.value=!1}},O=()=>{T.value=!1,i.push(`/`)},k=()=>{T.value=!1,E.value&&i.push(`/`)};return(e,i)=>(c(),s(`div`,j,[o(`div`,M,[r(b)]),i[10]||=o(`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),i[11]||=o(`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),i[12]||=o(`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),o(`div`,N,[i[9]||=o(`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),o(`div`,P,[i[8]||=o(`div`,{class:`text-center mb-6 sm:mb-10`},[o(`div`,{class:`mb-4 sm:mb-6 flex justify-center`},[o(`img`,{src:`/assets/pymclogo-ew909fnk.png`,alt:`pyMC`,class:`logo-image logo-image-animated h-36 sm:h-40 relative z-10`})]),o(`p`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},` Sign in to access your dashboard `)],-1),o(`form`,{onSubmit:_(D,[`prevent`]),class:`space-y-4 sm:space-y-5`},[o(`div`,F,[i[3]||=o(`label`,{for:`username`,class:`block text-content-secondary dark:text-content-primary/90 text-xs sm:text-sm font-medium mb-2`},` Username `,-1),o(`div`,I,[t(o(`input`,{id:`username`,"onUpdate:modelValue":i[0]||=e=>x.value=e,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:C.value},null,8,L),[[g,x.value]]),i[2]||=o(`div`,{class:`absolute inset-0 rounded-[12px] pointer-events-none input-glow`},null,-1)])]),o(`div`,R,[i[5]||=o(`label`,{for:`password`,class:`block text-content-secondary dark:text-content-primary/90 text-xs sm:text-sm font-medium mb-2`},` Password `,-1),o(`div`,z,[t(o(`input`,{id:`password`,"onUpdate:modelValue":i[1]||=e=>S.value=e,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:C.value},null,8,B),[[g,S.value]]),i[4]||=o(`div`,{class:`absolute inset-0 rounded-[12px] pointer-events-none input-glow`},null,-1)])]),w.value?(c(),s(`div`,V,[o(`p`,H,a(w.value),1)])):n(``,!0),o(`button`,{type:`submit`,disabled:C.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`},[C.value?(c(),s(`div`,W)):(c(),s(`svg`,G,[...i[6]||=[o(`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)]])),o(`span`,K,a(C.value?`Signing in...`:`Sign In`),1)],8,U)],32),o(`div`,q,[i[7]||=o(`div`,{class:`flex flex-col items-center justify-center mb-4`},[o(`p`,{class:`text-content-muted dark:text-content-muted text-[10px] sm:text-xs mb-1.5 tracking-wide uppercase opacity-60`},`Powered by`),o(`img`,{src:`/assets/meshcore-DQNtEl5I.svg`,alt:`MeshCore`,class:`h-4 sm:h-5 opacity-50 brightness-0 dark:brightness-100`})],-1),o(`div`,J,[o(`a`,Y,[r(v,{class:`w-5 h-5 sm:w-6 sm:h-6 text-white group-hover:text-primary transition-colors`})]),o(`a`,X,[r(y,{class:`w-5 h-5 sm:w-6 sm:h-6 text-white group-hover:text-yellow-500 transition-colors`})])])])])]),r(A,{"is-open":T.value,"can-skip":!0,onClose:k,onSuccess:O},null,8,[`is-open`])]))}}),[[`__scopeId`,`data-v-fec81ee3`]]);export{Z as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Logs-DiVYCMnG.js b/repeater/web/html/assets/Logs-DiVYCMnG.js new file mode 100644 index 0000000..e8c0a50 --- /dev/null +++ b/repeater/web/html/assets/Logs-DiVYCMnG.js @@ -0,0 +1 @@ +import{E as e,S as t,dt as n,f as r,g as i,l as a,o,p as s,pt as c,r as l,s as u,u as d,w as f,x as p,z as m}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t as h}from"./api-CbM6k1ZB.js";var g={class:`space-y-6`},_={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6`},v={class:`flex items-center justify-between mb-4`},y=[`disabled`],b={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4`},x={class:`flex flex-wrap gap-2`},S=[`onClick`],C={key:0,class:`w-px h-6 bg-stroke-subtle dark:bg-stroke/20 mx-2 self-center`},w=[`onClick`],T={class:`glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] overflow-hidden`},E={key:0,class:`p-8 text-center`},D={key:1,class:`p-8 text-center`},O={class:`text-content-secondary dark:text-content-muted mb-4`},k={key:2,class:`max-h-[600px] overflow-y-auto`},A={key:0,class:`p-8 text-center`},j={key:1,class:`divide-y divide-gray-200 dark:divide-white/5`},M={class:`flex-shrink-0 text-content-secondary dark:text-content-muted`},N={class:`flex-shrink-0 px-2 py-1 text-xs font-medium rounded bg-blue-500/20 text-blue-600 dark:text-blue-400`},P={class:`text-content-primary dark:text-content-primary flex-1 break-all`},F=i({name:`LogsView`,__name:`Logs`,setup(i){let F=m([]),I=m(new Set),L=m(new Set([`DEBUG`,`INFO`,`WARNING`,`ERROR`])),R=m(new Set),z=m(new Set),B=m(!0),V=m(null),H=null,U=e=>{let t=e.match(/- ([^-]+) - (?:DEBUG|INFO|WARNING|ERROR) -/);return t?t[1].trim():`Unknown`},ee=e=>{let t=e.match(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2},\d{3} - [^-]+ - (?:DEBUG|INFO|WARNING|ERROR) - (.+)$/);return t?t[1]:e},W=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0},G=async()=>{try{let e=await h.getLogs();if(e.logs&&e.logs.length>0){F.value=e.logs;let t=new Set;F.value.forEach(e=>{let n=U(e.message);t.add(n)});let n=new Set;F.value.forEach(e=>{n.add(e.level)}),I.value.size===0&&(I.value=new Set(t));let r=!W(R.value,t),i=!W(z.value,n);r&&(R.value=t),i&&(z.value=n),V.value=null}}catch(e){console.error(`Error loading logs:`,e),V.value=e instanceof Error?e.message:`Failed to load logs`}finally{B.value=!1}},K=o(()=>F.value.filter(e=>{let t=U(e.message),n=I.value.has(t),r=L.value.has(e.level);return n&&r})),q=o(()=>Array.from(R.value).sort()),J=o(()=>{let e=[`ERROR`,`WARNING`,`WARN`,`INFO`,`DEBUG`];return Array.from(z.value).sort((t,n)=>{let r=e.indexOf(t),i=e.indexOf(n);return r!==-1&&i!==-1?r-i:t.localeCompare(n)})}),Y=e=>{L.value.has(e)?L.value.delete(e):L.value.add(e),L.value=new Set(L.value)},X=e=>new Date(e).toLocaleTimeString(`en-US`,{hour12:!1,hour:`2-digit`,minute:`2-digit`,second:`2-digit`}),Z=e=>({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`})[e]||`text-gray-400 bg-gray-900/20`,Q=(e,t)=>t?{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`}[e]||`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`,$=e=>{I.value.has(e)?I.value.delete(e):I.value.add(e),I.value=new Set(I.value)},te=()=>{I.value=new Set(R.value)},ne=()=>{I.value=new Set},re=()=>{L.value=new Set(z.value)},ie=()=>{L.value=new Set},ae=()=>{H&&clearInterval(H),H=setInterval(G,5e3)},oe=()=>{H&&=(clearInterval(H),null)};return t(()=>{G(),ae()}),p(()=>{oe()}),(t,i)=>(f(),d(`div`,g,[u(`div`,_,[u(`div`,v,[i[1]||=u(`div`,null,[u(`h1`,{class:`text-content-primary dark:text-content-primary text-2xl font-semibold mb-2`},` System Logs `),u(`p`,{class:`text-content-secondary dark:text-content-muted`},` Real-time system events and diagnostics `)],-1),u(`button`,{onClick:G,disabled:B.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`},[(f(),d(`svg`,{class:n([`w-4 h-4`,{"animate-spin":B.value}]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...i[0]||=[u(`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)),s(` `+c(B.value?`Loading...`:`Refresh`),1)],8,y)]),u(`div`,b,[u(`div`,{class:`flex flex-wrap items-center gap-3 mb-4`},[i[2]||=u(`span`,{class:`text-content-primary dark:text-content-primary font-medium`},`Filters:`,-1),u(`button`,{onClick:te,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 `),u(`button`,{onClick:ne,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 `),i[3]||=u(`div`,{class:`w-px h-4 bg-white/20 mx-1`},null,-1),u(`button`,{onClick:re,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 `),u(`button`,{onClick:ie,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 `)]),u(`div`,x,[(f(!0),d(l,null,e(q.value,e=>(f(),d(`button`,{key:`logger-`+e,onClick:t=>$(e),class:n([`px-3 py-1 text-xs border rounded-full transition-colors`,I.value.has(e)?`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(e),11,S))),128)),q.value.length>0&&J.value.length>0?(f(),d(`div`,C)):a(``,!0),(f(!0),d(l,null,e(J.value,e=>(f(),d(`button`,{key:`level-`+e,onClick:t=>Y(e),class:n([`px-3 py-1 text-xs border rounded-full transition-colors font-medium`,L.value.has(e)?Q(e,!0):Q(e,!1)])},c(e),11,w))),128))])])]),u(`div`,T,[B.value&&F.value.length===0?(f(),d(`div`,E,[...i[4]||=[u(`div`,{class:`animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4`},null,-1),u(`p`,{class:`text-content-secondary dark:text-content-muted`},`Loading system logs...`,-1)]])):V.value?(f(),d(`div`,D,[i[5]||=u(`div`,{class:`text-red-600 dark:text-red-400 mb-4`},[u(`svg`,{class:`w-12 h-12 mx-auto mb-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[u(`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[6]||=u(`h3`,{class:`text-content-primary dark:text-content-primary text-lg font-medium mb-2`},` Error Loading Logs `,-1),u(`p`,O,c(V.value),1),u(`button`,{onClick:G,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 `)])):(f(),d(`div`,k,[K.value.length===0?(f(),d(`div`,A,[...i[7]||=[r(`

No Logs to Display

No logs match the current filter criteria.

`,3)]])):(f(),d(`div`,j,[(f(!0),d(l,null,e(K.value,(e,t)=>(f(),d(`div`,{key:t,class:`flex items-start gap-4 p-4 hover:bg-background-mute dark:hover:bg-stroke/5 transition-colors font-mono text-sm`},[u(`span`,M,` [`+c(X(e.timestamp))+`] `,1),u(`span`,N,c(U(e.message)),1),u(`span`,{class:n([`flex-shrink-0 px-2 py-1 text-xs font-medium rounded`,Z(e.level)])},c(e.level),3),u(`span`,P,c(ee(e.message)),1)]))),128))]))]))])]))}});export{F as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/MessageDialog-CEzYMZ-3.js b/repeater/web/html/assets/MessageDialog-CEzYMZ-3.js new file mode 100644 index 0000000..1323a40 --- /dev/null +++ b/repeater/web/html/assets/MessageDialog-CEzYMZ-3.js @@ -0,0 +1 @@ +import{dt as e,g as t,l as n,pt as r,s as i,u as a,w as o}from"./runtime-core.esm-bundler-HnidnMFy.js";import{h as s}from"./index-BFltqMtv.js";var c={class:`mb-6`},l={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},u={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},d={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},f={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},p={class:`flex`},m=t({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(t,{emit:m}){let h=t,g=m,_=e=>{e.target===e.currentTarget&&g(`close`)},v={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`},y={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(t,m)=>h.show?(o(),a(`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`}},[i(`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:m[1]||=s(()=>{},[`stop`])},[i(`div`,c,[i(`div`,{class:e([`inline-flex p-3 rounded-xl mb-4`,v[h.variant]])},[h.variant===`success`?(o(),a(`svg`,l,[...m[2]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):h.variant===`error`?(o(),a(`svg`,u,[...m[3]||=[i(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(o(),a(`svg`,d,[...m[4]||=[i(`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),i(`p`,f,r(h.message),1)]),i(`div`,p,[i(`button`,{onClick:m[0]||=e=>g(`close`),class:e([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,y[h.variant]])},` OK `,2)])])])):n(``,!0)}});export{m as t}; \ No newline at end of file diff --git a/repeater/web/html/assets/Neighbors-CQcUQfDG.js b/repeater/web/html/assets/Neighbors-CQcUQfDG.js new file mode 100644 index 0000000..28d4e1b --- /dev/null +++ b/repeater/web/html/assets/Neighbors-CQcUQfDG.js @@ -0,0 +1,65 @@ +import{r as e}from"./chunk-DECur_0Z.js";import{A as t,C as n,E as r,S as i,b as a,c as o,dt as s,f as c,ft as l,g as u,i as d,j as f,k as p,l as m,m as h,o as g,p as _,pt as v,r as y,s as b,u as x,w as S,z as C}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t as w}from"./api-CbM6k1ZB.js";import{t as T}from"./system-BH4r-ii6.js";import{t as E}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{c as D,d as O,f as k,h as A,p as j}from"./index-BFltqMtv.js";import{t as M}from"./leaflet-src-PYB8oVmQ.js";/* empty css */import{n as N,t as P}from"./preferences-Bv8i60GL.js";import{t as F}from"./useSignalQuality-BfZWbBxN.js";var I={class:`bg-gray-50 dark:bg-white/5 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4 mb-6`},L={class:`flex items-center gap-3`},R={class:`flex-1 min-w-0`},z={class:`text-content-primary dark:text-content-primary font-medium truncate`},B={class:`text-content-secondary dark:text-content-muted text-sm font-mono`},V={key:0,class:`text-white/50 text-xs`},H={key:1,class:`text-white/50 text-xs`},U=u({__name:`DeleteNeighborModal`,props:{show:{type:Boolean},neighbor:{}},emits:[`close`,`delete`],setup(e,{emit:t}){let n=e,r=t,i=()=>{n.neighbor&&(r(`delete`,n.neighbor.id),a())},a=()=>{r(`close`)},o=e=>{e.target===e.currentTarget&&a()};return(t,n)=>e.show&&e.neighbor?(S(),x(`div`,{key:0,onClick:o,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`}},[b(`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[0]||=A(()=>{},[`stop`])},[b(`div`,{class:`flex items-center gap-3 mb-6`},[n[2]||=b(`svg`,{class:`w-6 h-6 text-accent-red`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),n[3]||=b(`div`,null,[b(`h3`,{class:`text-xl font-semibold text-content-primary dark:text-content-primary`},` Delete Neighbor `),b(`p`,{class:`text-content-secondary dark:text-content-muted text-sm mt-1`},` Are you sure you want to delete this neighbor? `)],-1),b(`button`,{onClick:a,class:`ml-auto text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...n[1]||=[b(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),b(`div`,I,[b(`div`,L,[b(`div`,R,[b(`div`,z,v(e.neighbor?.node_name||e.neighbor?.long_name||e.neighbor?.short_name||`Unknown`),1),b(`div`,B,` ID: `+v(e.neighbor?.node_num_hex||e.neighbor?.node_num||e.neighbor?.id||`N/A`),1),e.neighbor?.contact_type?(S(),x(`div`,V,v(e.neighbor.contact_type),1)):m(``,!0),e.neighbor?.hw_model?(S(),x(`div`,H,v(e.neighbor.hw_model),1)):m(``,!0)])])]),n[4]||=b(`div`,{class:`bg-accent-red/10 border border-accent-red/30 rounded-lg p-4 mb-6`},[b(`div`,{class:`flex items-center gap-2 text-accent-red text-sm`},[b(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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`})]),b(`span`,null,`This action cannot be undone`)])],-1),b(`div`,{class:`flex gap-3`},[b(`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 `),b(`button`,{onClick:i,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 `)])])])):m(``,!0)}}),W={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`},G={class:`flex items-center justify-between`},K={class:`flex items-center gap-3`},ee={key:0,class:`text-sm text-content-secondary dark:text-content-muted`},te={class:`p-6`},q={key:0,class:`text-center py-8`},ne={key:1,class:`text-center py-8`},re={class:`text-content-secondary dark:text-content-muted text-sm`},ie={key:2,class:`space-y-4`},ae={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},oe={class:`flex items-center justify-between mb-2`},se={class:`flex items-baseline gap-2`},ce={class:`text-3xl font-bold text-content-primary dark:text-content-primary`},le={class:`grid grid-cols-2 gap-3`},ue={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},de={class:`flex items-center gap-2 mb-2`},fe={class:`flex gap-0.5`},pe={class:`flex items-baseline gap-1`},me={class:`text-xl font-bold text-content-primary dark:text-content-primary`},he={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},ge={class:`flex items-baseline gap-1`},_e={class:`text-xl font-bold text-content-primary dark:text-content-primary`},ve={key:0,class:`flex items-start gap-3 bg-amber-500/10 border border-amber-500/30 rounded-[12px] p-3`},ye={class:`text-xs leading-relaxed`},be={class:`font-semibold text-amber-600 dark:text-amber-400 mb-0.5`},xe={class:`bg-background-mute dark:bg-background/50 border border-stroke-subtle dark:border-stroke/10 rounded-[15px] p-4`},Se={class:`relative`},Ce={class:`flex items-center gap-2 overflow-x-auto pb-2`},we={key:0,class:`relative flex items-center`},Te={key:0,class:`absolute left-1/2 -translate-x-1/2 animate-pulse`},Ee={class:`text-content-muted dark:text-content-muted text-xs mt-2 flex items-center justify-between`},De={key:0,class:`text-cyan-500 dark:text-primary animate-pulse`},Oe={class:`flex items-center justify-between text-xs text-content-muted dark:text-content-muted pt-2`},ke=E(u({__name:`PingResultModal`,props:{show:{type:Boolean},nodeName:{default:null},result:{default:null},error:{default:null},loading:{type:Boolean,default:!1}},emits:[`close`],setup(e,{emit:n}){let i=e,a=n,c=T(),{getSignalQuality:l}=F(),u=C(0),f=C(!1),_=g(()=>{let e=c.stats?.config?.radio?.spreading_factor??7,t=c.stats?.config?.radio?.bandwidth??125,n=c.stats?.config?.radio?.coding_rate??5;return 2**e/t*(8+4.25*(n-4)+20)}),w=g(()=>{if(!i.result)return{color:`text-gray-400`,label:`Unknown`};let e=i.result.rtt_ms,t=_.value,n=i.result.path.length,r=2*t*n+500*n;return e{if(!i.result)return{bars:0,color:`text-gray-400`};let e=l(i.result.rssi);return{bars:e.bars,color:e.color}}),O=g(()=>{if(!i.result)return 0;if(i.result.path_hash_mode!==void 0)return i.result.path_hash_mode;let e=i.result.path.reduce((e,t)=>{let n=t.replace(/^0x/i,``);return Math.max(e,n.length)},0);return e>4?2:e>2?1:0}),k=g(()=>O.value>0),j=g(()=>({0:`1-byte`,1:`2-byte`,2:`3-byte`})[O.value]??`1-byte`);p(()=>i.result,e=>{if(e&&!f.value){f.value=!0,u.value=0;let t=e.path.length,n=1500/(t*2),r=0,i=t*2-2,a=()=>{r<=i?(u.value=r/i,r++,setTimeout(a,n)):(f.value=!1,u.value=1)};setTimeout(a,100)}},{immediate:!0});let M=g(()=>{if(!i.result||!f.value)return-1;let e=i.result.path.length;if(e<=1)return-1;let t=u.value,n=.5;if(t<=n)return t/n*(e-1);{let r=(t-n)/n;return(e-1)*(1-r)}}),N=()=>{a(`close`)};return(n,i)=>(S(),o(d,{to:`body`},[h(D,{name:`modal`},{default:t(()=>[e.show?(S(),x(`div`,{key:0,class:`fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[99999] p-4`,onClick:A(N,[`self`])},[b(`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:i[0]||=A(()=>{},[`stop`])},[b(`div`,W,[b(`div`,G,[b(`div`,K,[i[2]||=b(`div`,{class:`p-2 bg-cyan-400/20 dark:bg-primary/20 rounded-lg`},[b(`svg`,{class:`w-5 h-5 text-cyan-500 dark:text-primary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),b(`div`,null,[i[1]||=b(`h2`,{class:`text-xl font-bold text-content-primary dark:text-content-primary`},` Ping Result `,-1),e.nodeName?(S(),x(`p`,ee,v(e.nodeName),1)):m(``,!0)])]),b(`button`,{onClick:N,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`},[...i[3]||=[b(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])])]),b(`div`,te,[e.loading?(S(),x(`div`,q,[...i[4]||=[b(`div`,{class:`animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4`},null,-1),b(`p`,{class:`text-content-secondary dark:text-content-muted`},`Sending ping...`,-1),b(`p`,{class:`text-content-muted dark:text-content-muted text-sm mt-1`},` Waiting for response... `,-1)]])):e.error?(S(),x(`div`,ne,[i[5]||=b(`div`,{class:`p-3 bg-accent-red/10 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center`},[b(`svg`,{class:`w-8 h-8 text-accent-red`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),i[6]||=b(`h3`,{class:`text-accent-red font-semibold mb-2`},`Ping Failed`,-1),b(`p`,re,v(e.error),1)])):e.result?(S(),x(`div`,ie,[b(`div`,ae,[b(`div`,oe,[i[7]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`Round-Trip Time`,-1),b(`span`,{class:s([`text-xs font-medium px-2 py-1 rounded-full`,w.value.color,`bg-current/10`])},v(w.value.label),3)]),b(`div`,se,[b(`span`,ce,v(e.result.rtt_ms.toFixed(2)),1),i[8]||=b(`span`,{class:`text-content-secondary dark:text-content-muted`},`ms`,-1)])]),b(`div`,le,[b(`div`,ue,[b(`div`,de,[i[9]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-sm`},`RSSI`,-1),b(`div`,fe,[(S(),x(y,null,r(5,e=>b(`div`,{key:e,class:s([`w-1 h-3 rounded-sm`,e<=E.value.bars?E.value.color:`bg-stroke-subtle dark:bg-stroke/10`])},null,2)),64))])]),b(`div`,pe,[b(`span`,me,v(e.result.rssi),1),i[10]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-xs`},`dBm`,-1)])]),b(`div`,he,[i[12]||=b(`div`,{class:`text-content-secondary dark:text-content-muted text-sm mb-2`},`SNR`,-1),b(`div`,ge,[b(`span`,_e,v(e.result.snr_db),1),i[11]||=b(`span`,{class:`text-content-secondary dark:text-content-muted text-xs`},`dB`,-1)])])]),k.value?(S(),x(`div`,ve,[i[14]||=b(`svg`,{class:`w-5 h-5 text-amber-500 flex-shrink-0 mt-0.5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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(`div`,ye,[b(`p`,be,v(j.value)+` path hashes active `,1),i[13]||=b(`p`,{class:`text-content-secondary dark:text-content-muted`},` This result uses multi-byte path hashes. The repeater being traced must be running firmware that supports multi-byte path hashes. Repeaters on older firmware will not respond to or correctly route these trace packets. `,-1)])])):m(``,!0),b(`div`,xe,[i[17]||=b(`div`,{class:`text-content-secondary dark:text-content-muted text-sm mb-3`},` Network Path `,-1),b(`div`,Se,[b(`div`,Ce,[(S(!0),x(y,null,r(e.result.path,(n,r)=>(S(),x(`div`,{key:r,class:`flex items-center gap-2 flex-shrink-0 relative`},[b(`div`,{class:s([`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`,f.value&&Math.floor(M.value)===r?`ring-2 ring-cyan-400/50 dark:ring-primary/50 scale-105`:``])},v(n),3),r[f.value&&M.value>=r&&M.valuenew Date(e*1e3).toLocaleString(),T=e=>e?`${e} dBm`:`N/A`,E=e=>e?`${e.toFixed(1)} dB`:`N/A`,O=e=>({0:`Transport Flood`,1:`Flood`,2:`Direct`,3:`Transport Direct`})[e||0]||`Unknown`,k=e=>({Unknown:`Unknown`,"Chat Node":`Chat Node`,Repeater:`Repeater`,"Room Server":`Room Server`,"Hybrid Node":`Hybrid Node`})[e]||e,j=e=>({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`})[e]||`text-gray-600 dark:text-gray-400`,M=async()=>{if(!c.neighbor?.latitude||!c.neighbor?.longitude)return;let e=`${c.neighbor.latitude.toFixed(6)}, ${c.neighbor.longitude.toFixed(6)}`;try{await navigator.clipboard.writeText(e),a.value=`Copied!`,setTimeout(()=>{a.value=`Copy`},2e3)}catch(e){console.error(`Failed to copy coordinates:`,e),a.value=`Failed`,setTimeout(()=>{a.value=`Copy`},2e3)}},N=g(()=>{if(!c.neighbor?.latitude||!c.neighbor?.longitude||!c.baseLatitude||!c.baseLongitude)return null;let e=(c.neighbor.latitude-c.baseLatitude)*Math.PI/180,t=(c.neighbor.longitude-c.baseLongitude)*Math.PI/180,n=Math.sin(e/2)*Math.sin(e/2)+Math.cos(c.baseLatitude*Math.PI/180)*Math.cos(c.neighbor.latitude*Math.PI/180)*Math.sin(t/2)*Math.sin(t/2);return 6371*(2*Math.atan2(Math.sqrt(n),Math.sqrt(1-n)))}),P=g(()=>c.neighbor?.latitude!==null&&c.neighbor?.longitude!==null&&c.neighbor?.latitude!==0&&c.neighbor?.longitude!==0&&Math.abs(c.neighbor?.latitude??0)<=90&&Math.abs(c.neighbor?.longitude??0)<=180),I=()=>{if(!u.value||!c.neighbor||!P.value)return;f&&=(f.remove(),null);let e=document.documentElement.classList.contains(`dark`);f=J.default.map(u.value,{center:[c.neighbor.latitude,c.neighbor.longitude],zoom:13,zoomControl:!0,attributionControl:!1});let t=e?`https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png`:`https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png`;J.default.tileLayer(t,{maxZoom:19,attribution:`© OpenStreetMap © CARTO`}).addTo(f);let n=J.default.divIcon({className:`custom-marker`,html:`
${c.neighbor.node_name?.charAt(0)||`?`}
`,iconSize:[32,32],iconAnchor:[16,16]});if(J.default.marker([c.neighbor.latitude,c.neighbor.longitude],{icon:n}).addTo(f).bindPopup(`${c.neighbor.node_name||`Unknown`}
${c.neighbor.pubkey.slice(0,8)}...`),c.baseLatitude!==null&&c.baseLongitude!==null&&c.baseLatitude!==0&&c.baseLongitude!==0&&Math.abs(c.baseLatitude)<=90&&Math.abs(c.baseLongitude)<=180){let e=J.default.divIcon({className:`custom-marker`,html:`
B
`,iconSize:[32,32],iconAnchor:[16,16]});J.default.marker([c.baseLatitude,c.baseLongitude],{icon:e}).addTo(f).bindPopup(`Base Station`),J.default.polyline([[c.baseLatitude,c.baseLongitude],[c.neighbor.latitude,c.neighbor.longitude]],{color:`#3b82f6`,weight:2,opacity:.6,dashArray:`5, 10`}).addTo(f);let t=J.default.latLngBounds([c.baseLatitude,c.baseLongitude],[c.neighbor.latitude,c.neighbor.longitude]);f.fitBounds(t,{padding:[50,50]})}},L=e=>{e.key===`Escape`&&l(`close`)},R=e=>{e.target===e.currentTarget&&l(`close`)};p(()=>c.isOpen,e=>{e?(document.body.style.overflow=`hidden`,setTimeout(()=>{P.value&&I()},100)):(document.body.style.overflow=``,f&&=(f.remove(),null))},{immediate:!0});let z=g(()=>c.neighbor?.rssi?i(c.neighbor.rssi):null);return(n,i)=>(S(),o(d,{to:`body`},[h(D,{name:`modal`,appear:``},{default:t(()=>[e.isOpen&&e.neighbor?(S(),x(`div`,{key:0,class:`fixed inset-0 z-50 flex items-center justify-center p-4 overflow-hidden`,onClick:R,onKeydown:L,tabindex:`0`},[i[20]||=b(`div`,{class:`absolute inset-0 bg-black/60 backdrop-blur-md pointer-events-none`},null,-1),b(`div`,{class:`relative w-full max-w-4xl max-h-[90vh] flex flex-col`,onClick:i[2]||=A(()=>{},[`stop`])},[b(`div`,Ae,[b(`div`,je,[b(`div`,Me,[b(`h2`,Ne,v(e.neighbor.node_name||`Unknown Node`),1),b(`p`,Pe,v(e.neighbor.pubkey),1)]),b(`div`,Fe,[b(`button`,{onClick:i[0]||=e=>l(`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`},[...i[3]||=[b(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])])]),b(`div`,Ie,[b(`div`,Le,[i[8]||=b(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-4`},` Basic Information `,-1),b(`div`,Re,[b(`div`,ze,[i[4]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` Contact Type `,-1),b(`div`,{class:s([`font-medium`,j(e.neighbor.contact_type)])},v(k(e.neighbor.contact_type)),3)]),b(`div`,Be,[i[5]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` Route Type `,-1),b(`div`,Ve,v(O(e.neighbor.route_type)),1)]),b(`div`,He,[i[6]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` Zero Hop `,-1),b(`div`,{class:s([`font-medium`,e.neighbor.zero_hop?`text-green-600 dark:text-green-400`:`text-gray-600 dark:text-gray-400`])},v(e.neighbor.zero_hop?`Yes`:`No`),3)]),b(`div`,Ue,[i[7]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` Advert Count `,-1),b(`div`,We,v(e.neighbor.advert_count.toLocaleString()),1)])])]),b(`div`,Ge,[i[12]||=b(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-4`},` Signal Quality `,-1),b(`div`,Ke,[b(`div`,qe,[i[9]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` RSSI `,-1),b(`div`,Je,v(T(e.neighbor.rssi)),1)]),b(`div`,Ye,[i[10]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` SNR `,-1),b(`div`,Xe,v(E(e.neighbor.snr)),1)]),z.value?(S(),x(`div`,Ze,[i[11]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` Signal Strength `,-1),b(`div`,Qe,[b(`div`,$e,[(S(),x(y,null,r(4,e=>b(`div`,{key:e,class:s([`w-1 h-3 rounded-sm`,e<=z.value.bars?z.value.color:`bg-gray-300 dark:bg-gray-700`])},null,2)),64))]),b(`span`,{class:s([`text-sm font-medium`,z.value.color])},v(z.value.quality),3)])])):m(``,!0)])]),b(`div`,et,[i[15]||=b(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-4`},` Timeline `,-1),b(`div`,tt,[b(`div`,nt,[i[13]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` First Seen `,-1),b(`div`,rt,v(w(e.neighbor.first_seen)),1)]),b(`div`,it,[i[14]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` Last Seen `,-1),b(`div`,at,v(w(e.neighbor.last_seen)),1)])])]),P.value?(S(),x(`div`,ot,[i[19]||=b(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary mb-4`},` Location `,-1),b(`div`,st,[b(`div`,ct,[i[16]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` Latitude `,-1),b(`div`,lt,v(e.neighbor.latitude?.toFixed(6)),1)]),b(`div`,ut,[i[17]||=b(`div`,{class:`text-content-muted dark:text-content-muted text-xs uppercase tracking-wide mb-1`},` Longitude `,-1),b(`div`,dt,v(e.neighbor.longitude?.toFixed(6)),1)]),b(`div`,ft,[b(`div`,pt,v(N.value===null?`Coordinates`:`Distance`),1),N.value===null?(S(),x(`button`,{key:1,onClick:M,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`},[i[18]||=b(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),_(` `+v(a.value),1)])):(S(),x(`div`,mt,v(N.value.toFixed(2))+` km `,1))])]),b(`div`,{ref_key:`mapContainer`,ref:u,class:`w-full h-96 rounded-[12px] overflow-hidden border border-stroke-subtle dark:border-white/10`},null,512)])):m(``,!0)]),b(`div`,ht,[b(`button`,{onClick:i[1]||=e=>l(`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)):m(``,!0)]),_:1})]))}}),[[`__scopeId`,`data-v-2fb1fa15`]]),_t=[Int8Array,Uint8Array,Uint8ClampedArray,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array],vt=1,Y=8,yt=class e{static from(t){if(!(t instanceof ArrayBuffer))throw Error(`Data must be an instance of ArrayBuffer.`);let[n,r]=new Uint8Array(t,0,2);if(n!==219)throw Error(`Data does not appear to be in a KDBush format.`);let i=r>>4;if(i!==vt)throw Error(`Got v${i} data when expected v${vt}.`);let a=_t[r&15];if(!a)throw Error(`Unrecognized array type.`);let[o]=new Uint16Array(t,2,1),[s]=new Uint32Array(t,4,1);return new e(s,o,a,t)}constructor(e,t=64,n=Float64Array,r){if(isNaN(e)||e<0)throw Error(`Unpexpected numItems value: ${e}.`);this.numItems=+e,this.nodeSize=Math.min(Math.max(+t,2),65535),this.ArrayType=n,this.IndexArrayType=e<65536?Uint16Array:Uint32Array;let i=_t.indexOf(this.ArrayType),a=e*2*this.ArrayType.BYTES_PER_ELEMENT,o=e*this.IndexArrayType.BYTES_PER_ELEMENT,s=(8-o%8)%8;if(i<0)throw Error(`Unexpected typed array class: ${n}.`);r&&r instanceof ArrayBuffer?(this.data=r,this.ids=new this.IndexArrayType(this.data,Y,e),this.coords=new this.ArrayType(this.data,Y+o+s,e*2),this._pos=e*2,this._finished=!0):(this.data=new ArrayBuffer(Y+a+o+s),this.ids=new this.IndexArrayType(this.data,Y,e),this.coords=new this.ArrayType(this.data,Y+o+s,e*2),this._pos=0,this._finished=!1,new Uint8Array(this.data,0,2).set([219,(vt<<4)+i]),new Uint16Array(this.data,2,1)[0]=t,new Uint32Array(this.data,4,1)[0]=e)}add(e,t){let n=this._pos>>1;return this.ids[n]=n,this.coords[this._pos++]=e,this.coords[this._pos++]=t,n}finish(){let e=this._pos>>1;if(e!==this.numItems)throw Error(`Added ${e} items when expected ${this.numItems}.`);return bt(this.ids,this.coords,this.nodeSize,0,this.numItems-1,0),this._finished=!0,this}range(e,t,n,r){if(!this._finished)throw Error(`Data not yet indexed - call index.finish().`);let{ids:i,coords:a,nodeSize:o}=this,s=[0,i.length-1,0],c=[];for(;s.length;){let l=s.pop()||0,u=s.pop()||0,d=s.pop()||0;if(u-d<=o){for(let o=d;o<=u;o++){let s=a[2*o],l=a[2*o+1];s>=e&&s<=n&&l>=t&&l<=r&&c.push(i[o])}continue}let f=d+u>>1,p=a[2*f],m=a[2*f+1];p>=e&&p<=n&&m>=t&&m<=r&&c.push(i[f]),(l===0?e<=p:t<=m)&&(s.push(d),s.push(f-1),s.push(1-l)),(l===0?n>=p:r>=m)&&(s.push(f+1),s.push(u),s.push(1-l))}return c}within(e,t,n){if(!this._finished)throw Error(`Data not yet indexed - call index.finish().`);let{ids:r,coords:i,nodeSize:a}=this,o=[0,r.length-1,0],s=[],c=n*n;for(;o.length;){let l=o.pop()||0,u=o.pop()||0,d=o.pop()||0;if(u-d<=a){for(let n=d;n<=u;n++)Ct(i[2*n],i[2*n+1],e,t)<=c&&s.push(r[n]);continue}let f=d+u>>1,p=i[2*f],m=i[2*f+1];Ct(p,m,e,t)<=c&&s.push(r[f]),(l===0?e-n<=p:t-n<=m)&&(o.push(d),o.push(f-1),o.push(1-l)),(l===0?e+n>=p:t+n>=m)&&(o.push(f+1),o.push(u),o.push(1-l))}return s}};function bt(e,t,n,r,i,a){if(i-r<=n)return;let o=r+i>>1;xt(e,t,o,r,i,a),bt(e,t,n,r,o-1,1-a),bt(e,t,n,o+1,i,1-a)}function xt(e,t,n,r,i,a){for(;i>r;){if(i-r>600){let o=i-r+1,s=n-r+1,c=Math.log(o),l=.5*Math.exp(2*c/3),u=.5*Math.sqrt(c*l*(o-l)/o)*(s-o/2<0?-1:1);xt(e,t,n,Math.max(r,Math.floor(n-s*l/o+u)),Math.min(i,Math.floor(n+(o-s)*l/o+u)),a)}let o=t[2*n+a],s=r,c=i;for(X(e,t,r,n),t[2*i+a]>o&&X(e,t,r,i);so;)c--}t[2*r+a]===o?X(e,t,r,c):(c++,X(e,t,c,i)),c<=n&&(r=c+1),n<=c&&(i=c-1)}}function X(e,t,n,r){St(e,n,r),St(t,2*n,2*r),St(t,2*n+1,2*r+1)}function St(e,t,n){let r=e[t];e[t]=e[n],e[n]=r}function Ct(e,t,n,r){let i=e-n,a=t-r;return i*i+a*a}var wt={minZoom:0,maxZoom:16,minPoints:2,radius:40,extent:512,nodeSize:64,log:!1,generateId:!1,reduce:null,map:e=>e},Tt=Math.fround||(e=>(t=>(e[0]=+t,e[0])))(new Float32Array(1)),Z=2,Q=3,Et=4,$=5,Dt=6,Ot=class{constructor(e){this.options=Object.assign(Object.create(wt),e),this.trees=Array(this.options.maxZoom+1),this.stride=this.options.reduce?7:6,this.clusterProps=[]}load(e){let{log:t,minZoom:n,maxZoom:r}=this.options;t&&console.time(`total time`);let i=`prepare ${e.length} points`;t&&console.time(i),this.points=e;let a=[];for(let t=0;t=n;e--){let n=+Date.now();o=this.trees[e]=this._createTree(this._cluster(o,e)),t&&console.log(`z%d: %d clusters in %dms`,e,o.numItems,+Date.now()-n)}return t&&console.timeEnd(`total time`),this}getClusters(e,t){let n=((e[0]+180)%360+360)%360-180,r=Math.max(-90,Math.min(90,e[1])),i=e[2]===180?180:((e[2]+180)%360+360)%360-180,a=Math.max(-90,Math.min(90,e[3]));if(e[2]-e[0]>=360)n=-180,i=180;else if(n>i){let e=this.getClusters([n,r,180,a],t),o=this.getClusters([-180,r,i,a],t);return e.concat(o)}let o=this.trees[this._limitZoom(t)],s=o.range(jt(n),Mt(a),jt(i),Mt(r)),c=o.data,l=[];for(let e of s){let t=this.stride*e;l.push(c[t+$]>1?kt(c,t,this.clusterProps):this.points[c[t+Q]])}return l}getChildren(e){let t=this._getOriginId(e),n=this._getOriginZoom(e),r=`No cluster with the specified id.`,i=this.trees[n];if(!i)throw Error(r);let a=i.data;if(t*this.stride>=a.length)throw Error(r);let o=this.options.radius/(this.options.extent*2**(n-1)),s=a[t*this.stride],c=a[t*this.stride+1],l=i.within(s,c,o),u=[];for(let t of l){let n=t*this.stride;a[n+Et]===e&&u.push(a[n+$]>1?kt(a,n,this.clusterProps):this.points[a[n+Q]])}if(u.length===0)throw Error(r);return u}getLeaves(e,t,n){t||=10,n||=0;let r=[];return this._appendLeaves(r,e,t,n,0),r}getTile(e,t,n){let r=this.trees[this._limitZoom(e)],i=2**e,{extent:a,radius:o}=this.options,s=o/a,c=(n-s)/i,l=(n+1+s)/i,u={features:[]};return this._addTileFeatures(r.range((t-s)/i,c,(t+1+s)/i,l),r.data,t,n,i,u),t===0&&this._addTileFeatures(r.range(1-s/i,c,1,l),r.data,i,n,i,u),t===i-1&&this._addTileFeatures(r.range(0,c,s/i,l),r.data,-1,n,i,u),u.features.length?u:null}getClusterExpansionZoom(e){let t=this._getOriginZoom(e)-1;for(;t<=this.options.maxZoom;){let n=this.getChildren(e);if(t++,n.length!==1)break;e=n[0].properties.cluster_id}return t}_appendLeaves(e,t,n,r,i){let a=this.getChildren(t);for(let t of a){let a=t.properties;if(a&&a.cluster?i+a.point_count<=r?i+=a.point_count:i=this._appendLeaves(e,a.cluster_id,n,r,i):i1,c,l,u;if(s)c=At(t,e,this.clusterProps),l=t[e],u=t[e+1];else{let n=this.points[t[e+Q]];c=n.properties;let[r,i]=n.geometry.coordinates;l=jt(r),u=Mt(i)}let d={type:1,geometry:[[Math.round(this.options.extent*(l*i-n)),Math.round(this.options.extent*(u*i-r))]],tags:c},f;f=s||this.options.generateId?t[e+Q]:this.points[t[e+Q]].id,f!==void 0&&(d.id=f),a.features.push(d)}}_limitZoom(e){return Math.max(this.options.minZoom,Math.min(Math.floor(+e),this.options.maxZoom+1))}_cluster(e,t){let{radius:n,extent:r,reduce:i,minPoints:a}=this.options,o=n/(r*2**t),s=e.data,c=[],l=this.stride;for(let n=0;nt&&(p+=s[n+$])}if(p>f&&p>=a){let e=r*f,a=u*f,o,m=-1,h=((n/l|0)<<5)+(t+1)+this.points.length;for(let r of d){let c=r*l;if(s[c+Z]<=t)continue;s[c+Z]=t;let u=s[c+$];e+=s[c]*u,a+=s[c+1]*u,s[c+Et]=h,i&&(o||(o=this._map(s,n,!0),m=this.clusterProps.length,this.clusterProps.push(o)),i(o,this._map(s,c)))}s[n+Et]=h,c.push(e/p,a/p,1/0,h,-1,p),i&&c.push(m)}else{for(let e=0;e1)for(let e of d){let n=e*l;if(!(s[n+Z]<=t)){s[n+Z]=t;for(let e=0;e>5}_getOriginZoom(e){return(e-this.points.length)%32}_map(e,t,n){if(e[t+$]>1){let r=this.clusterProps[e[t+Dt]];return n?Object.assign({},r):r}let r=this.points[e[t+Q]].properties,i=this.options.map(r);return n&&i===r?Object.assign({},i):i}};function kt(e,t,n){return{type:`Feature`,id:e[t+Q],properties:At(e,t,n),geometry:{type:`Point`,coordinates:[Nt(e[t]),Pt(e[t+1])]}}}function At(e,t,n){let r=e[t+$],i=r>=1e4?`${Math.round(r/1e3)}k`:r>=1e3?`${Math.round(r/100)/10}k`:r,a=e[t+Dt],o=a===-1?{}:Object.assign({},n[a]);return Object.assign(o,{cluster:!0,cluster_id:e[t+Q],point_count:r,point_count_abbreviated:i})}function jt(e){return e/360+.5}function Mt(e){let t=Math.sin(e*Math.PI/180),n=.5-.25*Math.log((1+t)/(1-t))/Math.PI;return n<0?0:n>1?1:n}function Nt(e){return(e-.5)*360}function Pt(e){let t=(180-e*360)*Math.PI/180;return 360*Math.atan(Math.exp(t))/Math.PI-90}var Ft={class:`map-container`},It={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`},Lt={class:`hidden sm:inline`},Rt={key:3,class:`map-legend`},zt={class:`legend-footer`},Bt={key:4,class:`map-attribution`},Vt=E(u({__name:`NetworkMap`,props:{adverts:{},baseLatitude:{default:null},baseLongitude:{default:null},showLegend:{type:Boolean,default:!0}},emits:[`update:showLegend`],setup(e,{expose:t,emit:r}){typeof window<`u`&&!window.chrome&&(window.chrome={runtime:{}});let o=e,s=r,l=()=>{s(`update:showLegend`,!o.showLegend)},u=C(),d=null,f=C(new Map),h=null,_=C(new Map),y=C([]),w=C(!0),T=C(60),E=C(14),D=C(document.documentElement.classList.contains(`dark`)),O=new MutationObserver(()=>{let e=document.documentElement.classList.contains(`dark`);e!==D.value&&(D.value=e,d&&I())}),k=g(()=>o.baseLatitude!==null&&o.baseLongitude!==null&&typeof o.baseLatitude==`number`&&typeof o.baseLongitude==`number`&&o.baseLatitude!==0&&o.baseLongitude!==0&&Math.abs(o.baseLatitude)<=90&&Math.abs(o.baseLongitude)<=180),A=e=>new Date(e*1e3).toLocaleString(),j=e=>e?`${e} dBm`:`N/A`,M=e=>e?`${e} dB`:`N/A`,N=e=>({0:`Transport Flood`,1:`Flood`,2:`Direct`,3:`Transport Direct`})[e||0]||`Unknown`,P=(e,t,n,r)=>{let i=(n-e)*Math.PI/180,a=(r-t)*Math.PI/180,o=Math.sin(i/2)*Math.sin(i/2)+Math.cos(e*Math.PI/180)*Math.cos(n*Math.PI/180)*Math.sin(a/2)*Math.sin(a/2);return 6371*(2*Math.atan2(Math.sqrt(o),Math.sqrt(1-o)))},F=()=>{d&&=(y.value.forEach(e=>{d&&e.remove()}),y.value.length=0,d.remove(),null),f.value.clear(),_.value.clear(),h=null},I=async()=>{let e=d?.getZoom()||11,t=d?.getCenter()||(k.value?[o.baseLatitude,o.baseLongitude]:[0,0]);F(),await a(),await z(),d&&d.setView(t,e)},L=e=>{let t=new Map;return e.filter(e=>e.latitude!==null&&e.longitude!==null).map(e=>{let n=e.latitude,r=e.longitude,i=`${n.toFixed(6)}_${r.toFixed(6)}`,a=t.get(i)||0;if(t.set(i,a+1),a>0){let e=.001,t=a*60*(Math.PI/180);n+=Math.sin(t)*e*(a*.5),r+=Math.cos(t)*e*(a*.5)}return{type:`Feature`,properties:{advert:{...e,jittered_latitude:n,jittered_longitude:r}},geometry:{type:`Point`,coordinates:[r,n]}}})},R=e=>{h=new Ot({radius:T.value,maxZoom:E.value,minPoints:2}),h.load(e)},z=async()=>{if(!u.value||!k.value){console.warn(`Cannot initialize map: missing container or coordinates`);return}F(),await a();let e=o.baseLatitude,t=o.baseLongitude;d=J.default.map(u.value,{center:[e,t],zoom:11,zoomControl:!0,attributionControl:!1,preferCanvas:!1});try{let e=D.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`,t=D.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`,n=J.default.tileLayer(e,{maxZoom:19,attribution:`© OpenStreetMap contributors © CARTO`,errorTileUrl:`data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==`}),r=J.default.tileLayer(t,{maxZoom:19,attribution:``,errorTileUrl:`data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==`});n.addTo(d),r.addTo(d)}catch(e){console.warn(`Error loading tiles:`,e)}try{let n=(e,t=!1)=>{let n=t?16:12;return J.default.divIcon({className:`custom-div-icon`,html:`
`,iconSize:[n+4,n+4],iconAnchor:[(n+4)/2,(n+4)/2]})},r=e=>{let t=e<10?30:e<100?40:50;return J.default.divIcon({className:`custom-cluster-icon`,html:` +
+ ${e} +
+ `,iconSize:[t,t],iconAnchor:[t/2,t/2]})},i=n(`#ef4444`,!0);J.default.marker([e,t],{icon:i}).addTo(d).bindPopup(` +
+ Base Station
+ Base Station
+ ${e.toFixed(6)}, ${t.toFixed(6)} +
+ `);let a={Unknown:`#9CA3AF`,"Chat Node":`#60A5FA`,Repeater:`#A5E5B6`,"Room Server":`#EBA0FC`,"Hybrid Node":`#FFC246`},s=(e,t,n,r,i=0)=>{if(!d)return;let a=e.jittered_latitude||e.latitude,o=e.jittered_longitude||e.longitude;if(a===null||o===null)return;let s=e.route_type||0,c=r,l=3,u=.7,f;s===2?(c=`#A5E5B6`,l=4,u=.9):s===1?(c=`#FFC246`,f=`10, 5`,u=.8):s===3?(c=`#059669`,l=5,u=.95):s===0?(c=`#ea580c`,f=`12, 6`,u=.8):(c=`#9CA3AF`,f=`2, 5`,u=.6);let p=[t,n],m=[a,o],h=J.default.polyline([p,m],{color:c,weight:l,opacity:0,dashArray:f,className:`connection-line`}).addTo(d),g=J.default.polyline([p,p],{color:c,weight:l,opacity:0,dashArray:f,className:`connection-line animated-line`}).addTo(d);setTimeout(()=>{let i=0;g.setStyle({opacity:u+.2});let s=()=>{i++;let c=i/30,f=p[0]+(m[0]-p[0])*c,_=p[1]+(m[1]-p[1])*c;g.setLatLngs([p,[f,_]]),i<30?setTimeout(s,30):setTimeout(()=>{d&&g&&g.remove(),h.setStyle({opacity:u}),h.on(`mouseover`,()=>{h.setStyle({weight:l+2,opacity:Math.min(u+.3,1)})}),h.on(`mouseout`,()=>{h.setStyle({weight:l,opacity:u})});let i=P(t,n,a,o);h.bindPopup(` +
+ Connection to ${e.node_name||`Unknown Node`}
+ Distance: ${i.toFixed(2)} km
+ Route: ${N(e.route_type)}
+ Signal: ${j(e.rssi)} / ${M(e.snr)} +
+ `),y.value.push(h)},200)};s()},i)},c=()=>{if(!d||!h)return;let i=d.getBounds(),o=Math.floor(d.getZoom());_.value.forEach(e=>{d&&e.remove()}),_.value.clear(),y.value.forEach(e=>{d&&e.remove()}),y.value.length=0,h.getClusters([i.getWest(),i.getSouth(),i.getEast(),i.getNorth()],o).forEach(i=>{let[o,c]=i.geometry.coordinates,l=i.properties;if(l.cluster){let n=J.default.marker([c,o],{icon:r(l.point_count||0)}).addTo(d);n.on(`click`,()=>{if(d&&h){let e=h.getClusterExpansionZoom(l.cluster_id);d.setView([c,o],e)}});let i=h.getLeaves(l.cluster_id,1/0).map(e=>`
+ • ${e.properties.advert.node_name||`Unknown Node`} (${e.properties.advert.contact_type}) +
`).join(``);n.bindPopup(` +
+ Cluster: ${l.point_count} nodes
+
+ ${i} +
+
+ Click to zoom in and separate nodes +
+
+ `),_.value.set(`cluster-${l.cluster_id}`,n);let u=P(e,t,c,o),f=Math.min(Math.floor(u*5),200);s({node_name:`Cluster of ${l.point_count} nodes`,contact_type:`Cluster`,route_type:2,rssi:null,snr:null,jittered_latitude:c,jittered_longitude:o,latitude:c,longitude:o},e,t,`#AAE8E8`,f)}else{let r=l.advert,i=a[r.contact_type]||a.Unknown,u=n(i),p=c,m=o,h=P(e,t,p,m),g=J.default.marker([p,m],{icon:u}).addTo(d).bindPopup(` +
+ ${r.node_name||`Unknown Node`}
+ Type: ${r.contact_type}
+ Distance: ${h.toFixed(2)} km
+ Signal: ${j(r.rssi)} / ${M(r.snr)}
+ Route: ${N(r.route_type)}
+ Last Seen: ${A(r.last_seen)} + ${r.jittered_latitude?`
Position adjusted to separate overlapping nodes`:``} +
+ `);f.value.set(r.pubkey,g),_.value.set(`node-${r.pubkey}`,g);let v=Math.min(Math.floor(h*5),200);s({...r,jittered_latitude:p,jittered_longitude:m},e,t,i,v)}})},l=(e,t)=>{let r=0;L(o.adverts).forEach(i=>{let o=i.properties.advert;if(o.latitude!==null&&o.longitude!==null){let i=a[o.contact_type]||a.Unknown,c=n(i),l=o.jittered_latitude||o.latitude,u=o.jittered_longitude||o.longitude,p=J.default.marker([l,u],{icon:c}).addTo(d).bindPopup(` +
+ ${o.node_name||`Unknown Node`}
+ Type: ${o.contact_type}
+ Distance: ${P(e,t,l,u).toFixed(2)} km
+ Signal: ${j(o.rssi)} / ${M(o.snr)}
+ Route: ${N(o.route_type)}
+ Last Seen: ${A(o.last_seen)} + ${o.jittered_latitude?`
Position adjusted to separate overlapping nodes`:``} +
+ `);f.value.set(o.pubkey,p);let m=p.getElement();m&&(m.style.opacity=`0`,m.style.transition=`opacity 0.5s ease-out`),s(o,e,t,i,r),setTimeout(()=>{m&&(m.style.opacity=`1`)},r+1e3),r+=100}})};if(w.value&&o.adverts.length>0)try{R(L(o.adverts));let n=Math.min(14,d.getZoom());d.setZoom(n),setTimeout(()=>{try{c()}catch(n){console.warn(`Error updating clusters:`,n),l(e,t)}},100),d.on(`moveend`,()=>{try{c()}catch(e){console.warn(`Error updating clusters on move:`,e)}}),d.on(`zoomend`,()=>{try{c()}catch(e){console.warn(`Error updating clusters on zoom:`,e)}})}catch(n){console.warn(`Error initializing clustering:`,n),l(e,t)}else l(e,t);setTimeout(()=>{d&&d.invalidateSize()},1e3)}catch(e){console.error(`Error initializing map:`,e)}};return t({highlightNode:e=>{let t=f.value.get(e);if(t){let e=t.getElement();if(e){let t=e.querySelector(`div`);t&&t.classList.add(`marker-highlight`)}}},unhighlightNode:e=>{let t=f.value.get(e);if(t){let e=t.getElement();if(e){let t=e.querySelector(`div`);t&&t.classList.remove(`marker-highlight`)}}},initializeOpenStreetMap:z}),p(()=>o.adverts,()=>{d&&k.value&&setTimeout(()=>{z()},100)},{immediate:!1}),i(()=>{O.observe(document.documentElement,{attributes:!0,attributeFilter:[`class`]}),k.value&&o.adverts.length>0&&setTimeout(()=>{z()},300)}),n(()=>{O.disconnect(),F()}),(t,n)=>(S(),x(`div`,Ft,[k.value?(S(),x(`div`,{key:1,ref_key:`mapContainer`,ref:u,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)):(S(),x(`div`,It,[...n[0]||=[c(`

No valid coordinates available

Configure base station location to view map

`,1)]])),k.value&&e.adverts.length>0?(S(),x(`button`,{key:2,onClick:l,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`},[n[1]||=b(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),b(`span`,Lt,v(e.showLegend?`Hide`:`Show`),1)])):m(``,!0),k.value&&e.adverts.length>0&&e.showLegend?(S(),x(`div`,Rt,[n[2]||=c(`
Node Types
Base Station
Chat Node
Repeater
Room Server
Hybrid Node
Route Types
Direct
Transport Direct
Flood
Transport Flood
`,2),b(`div`,zt,v(e.adverts.length)+` node`+v(e.adverts.length===1?``:`s`)+` visible `,1)])):m(``,!0),k.value?(S(),x(`div`,Bt,` © OpenStreetMap contributors © CARTO `)):m(``,!0)]))}}),[[`__scopeId`,`data-v-61a18eed`]]),Ht={class:`relative`,"data-menu-container":``},Ut=u({__name:`NeighborMenu`,props:{neighbor:{},canPing:{type:Boolean}},emits:[`ping`,`delete`,`show-details`],setup(e,{emit:t}){let r=window.__neighborMenuManager||{activeMenu:null,setActiveMenu:e=>{if(r.activeMenu&&r.activeMenu!==e)try{r.activeMenu.closeMenu()}catch(e){console.warn(`Error closing previous menu:`,e)}r.activeMenu=e}};window.__neighborMenuManager=r;let i=e,c=t,u=C(!1),f=C(),p=C({top:0,left:0}),h=()=>{u.value=!1,document.removeEventListener(`click`,w,!0),document.removeEventListener(`keydown`,T),r.activeMenu===g&&(r.activeMenu=null)},g={closeMenu:h},_=()=>{h(),c(`ping`,i.neighbor)},v=()=>{h(),c(`show-details`,i.neighbor)},y=()=>{h(),c(`delete`,i.neighbor)},w=e=>{e.target.closest(`[data-menu-container]`)||h()},T=e=>{e.key===`Escape`&&h()},E=async()=>{if(!u.value&&f.value){r.setActiveMenu(g);let e=f.value.getBoundingClientRect(),t=window.innerWidth,n=t<1024,i=e.left+144>t-16,o=e.left;n&&i&&(o=e.right-144),o=Math.max(8,o),p.value={top:e.bottom+4,left:o},u.value=!0,await a(),document.addEventListener(`click`,w,!0),document.addEventListener(`keydown`,T)}else h()};return n(()=>{h()}),(e,t)=>(S(),x(`div`,Ht,[b(`button`,{ref_key:`buttonRef`,ref:f,onClick:E,class:s([`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":u.value}]),"data-menu-container":``},[...t[0]||=[b(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),(S(),o(d,{to:`body`},[u.value?(S(),x(`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:l({top:p.value.top+`px`,left:p.value.left+`px`}),"data-menu-container":``},[b(`div`,{class:`py-2`},[b(`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`},[...t[1]||=[b(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),b(`span`,{class:`font-medium`},`Details`,-1)]]),b(`button`,{onClick:_,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`},[...t[2]||=[b(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),b(`span`,{class:`font-medium`},`Ping`,-1)]]),b(`button`,{onClick:y,class:`flex items-center gap-3 w-full px-4 py-3 text-sm text-accent-red hover:bg-accent-red/10 transition-colors`},[...t[3]||=[b(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),b(`span`,{class:`font-medium`},`Delete`,-1)]])])],4)):m(``,!0)]))]))}}),Wt={class:`glass-card/30 backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[12px] p-6 shadow-sm dark:shadow-none`},Gt={class:`flex items-center justify-between mb-4`},Kt={class:`flex items-center gap-3`},qt={class:`text-content-primary dark:text-content-primary text-lg font-semibold`},Jt={class:`bg-background-mute dark:bg-white/10 text-content-secondary dark:text-content-primary text-xs px-2 py-1 rounded-full`},Yt={key:0,class:`text-content-muted dark:text-content-muted`},Xt={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`},Zt={class:`hidden lg:block overflow-x-auto`},Qt={class:`w-full`},$t={class:`bg-background-mute dark:bg-transparent`},en={class:`flex items-center gap-1`},tn={class:`flex items-center gap-1`},nn={class:`flex items-center gap-1`},rn={class:`flex items-center gap-1`},an={class:`flex items-center gap-1`},on={class:`flex items-center gap-1`},sn={class:`flex items-center gap-1`},cn={class:`flex items-center gap-1`},ln={class:`flex items-center gap-1`},un={class:`bg-surface/50 dark:bg-transparent`},dn=[`onMouseenter`,`onMouseleave`],fn=[`onClick`,`title`],pn={key:0,class:`ml-1 text-xs`},mn={key:0,class:`flex items-center gap-3`},hn={class:`text-content-secondary dark:text-content-muted`},gn={class:`flex gap-1`},_n=[`onClick`],vn=[`onClick`],yn={key:1,class:`text-content-muted`},bn={class:`flex items-center gap-2`},xn={class:`flex items-end gap-0.5`},Sn={class:`flex items-center gap-2`},Cn=[`title`],wn=[`title`],Tn={class:`lg:hidden space-y-3`},En=[`onClick`],Dn={class:`flex items-center justify-between mb-3`},On={class:`flex items-center gap-3`},kn={class:`text-content-primary dark:text-content-primary font-medium text-base`},An={class:`flex items-center gap-2`},jn={class:`grid grid-cols-1 gap-3`},Mn={class:`grid grid-cols-2 gap-4`},Nn=[`onClick`,`title`],Pn={key:0,class:`ml-1 text-xs`},Fn={class:`flex items-center gap-2 justify-end`},In={class:`flex items-end gap-0.5`},Ln={class:`grid grid-cols-2 gap-4`},Rn={class:`flex items-center gap-2`},zn=[`title`],Bn={class:`text-content-primary dark:text-content-primary text-sm block text-right`},Vn={key:0,class:`border-t border-white/10 pt-3`},Hn={class:`flex items-center justify-between`},Un={class:`text-content-secondary dark:text-content-muted text-sm font-mono`},Wn={class:`flex gap-2`},Gn=[`onClick`],Kn=[`onClick`],qn={class:`grid grid-cols-3 gap-4 pt-3 border-t border-white/10`},Jn={class:`text-center`},Yn={class:`text-content-primary dark:text-content-primary text-sm font-medium`},Xn={class:`text-center`},Zn={class:`text-content-primary dark:text-content-primary text-sm font-medium`},Qn={class:`text-center`},$n=[`title`],er=u({__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(e,{emit:t}){let n=C(null),{getSignalQuality:i}=F(),a=C(`advert_count`),o=C(`desc`),c=e,u=t,d=e=>new Date(e*1e3).toLocaleString(),f=e=>`${e.slice(0,4)}...${e.slice(-4)}`,p=e=>{switch(e){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`}}},w=e=>e?`${e} dBm`:`N/A`,T=e=>e?`${e} dB`:`N/A`,E=(e,t,n,r)=>{let i=(n-e)*Math.PI/180,a=(r-t)*Math.PI/180,o=Math.sin(i/2)*Math.sin(i/2)+Math.cos(e*Math.PI/180)*Math.cos(n*Math.PI/180)*Math.sin(a/2)*Math.sin(a/2);return 6371*(2*Math.atan2(Math.sqrt(o),Math.sqrt(1-o)))},D=e=>c.baseLatitude===null||c.baseLongitude===null||e.latitude===null||e.longitude===null?`N/A`:`${E(c.baseLatitude,c.baseLongitude,e.latitude,e.longitude).toFixed(1)} km`,O=async e=>{try{return await navigator.clipboard.writeText(e),!0}catch{let t=document.createElement(`textarea`);return t.value=e,document.body.appendChild(t),t.select(),document.execCommand(`copy`),document.body.removeChild(t),!0}},k=e=>{let t=Date.now()-e*1e3,n=Math.floor(t/1e3),r=Math.floor(n/60),i=Math.floor(r/60),a=Math.floor(i/24);return n<60?`${n}s ago`:r<60?`${r}m ago`:i<24?`${i}h ago`:`${a}d ago`},A=e=>{let t=Date.now()-e*1e3,n=Math.floor(t/(1e3*60*60));return n<1?{color:`text-green-600 dark:text-green-400`}:n<26?{color:`text-yellow-600 dark:text-yellow-400`}:{color:`text-red-600 dark:text-red-400`}},j=async(e,t)=>{await O(`${e.toFixed(6)}, ${t.toFixed(6)}`)},M=(e,t)=>{let n=`https://www.google.com/maps?q=${e},${t}`;window.open(n,`_blank`)},N=async e=>{await O(e),n.value=e,setTimeout(()=>{n.value=null},2e3)},P=e=>{let t=i(e);return{bars:t.bars,color:t.color}},I=()=>c.isCompactView?`py-2 px-2`:`py-4 px-3`,L=()=>{u(`toggle-view`)},R=e=>{u(`highlight-node`,e)},z=e=>{u(`unhighlight-node`,e)},B=e=>{u(`menu-ping`,e)},V=e=>{u(`show-details`,e)},H=e=>{u(`menu-delete`,e)},U=e=>{a.value===e?o.value=o.value===`asc`?`desc`:`asc`:(a.value=e,o.value=typeof c.adverts[0]?.[e]==`number`?`desc`:`asc`)},W=g(()=>a.value?[...c.adverts].sort((e,t)=>{let n=e[a.value],r=t[a.value];if(n==null)return 1;if(r==null)return-1;let i=0;return typeof n==`string`&&typeof r==`string`?i=n.localeCompare(r):typeof n==`number`&&typeof r==`number`?i=n-r:typeof n==`boolean`&&typeof r==`boolean`&&(i=n===r?0:n?1:-1),o.value===`asc`?i:-i}):c.adverts);return(t,i)=>(S(),x(`div`,Wt,[b(`div`,Gt,[b(`div`,Kt,[b(`div`,{class:`w-3 h-3 rounded-full border border-white/20`,style:l({backgroundColor:e.color})},null,4),b(`h3`,qt,v(e.contactType),1),b(`span`,Jt,[_(v(e.adverts.length)+` `,1),e.originalCount>0&&e.adverts.lengthU(`node_name`),class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[b(`div`,en,[i[12]||=_(` Node Name `,-1),a.value===`node_name`?(S(),x(`svg`,{key:0,class:s([`w-3 h-3`,o.value===`asc`?``:`rotate-180`]),fill:`currentColor`,viewBox:`0 0 20 20`},[...i[11]||=[b(`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)):m(``,!0)])],2),b(`th`,{onClick:i[1]||=e=>U(`pubkey`),class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[b(`div`,tn,[i[14]||=_(` Public Key `,-1),a.value===`pubkey`?(S(),x(`svg`,{key:0,class:s([`w-3 h-3`,o.value===`asc`?``:`rotate-180`]),fill:`currentColor`,viewBox:`0 0 20 20`},[...i[13]||=[b(`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)):m(``,!0)])],2),b(`th`,{class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5`)},` Location `,2),b(`th`,{class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5`)},` Distance `,2),b(`th`,{onClick:i[2]||=e=>U(`route_type`),class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[b(`div`,nn,[i[16]||=_(` Route Type `,-1),a.value===`route_type`?(S(),x(`svg`,{key:0,class:s([`w-3 h-3`,o.value===`asc`?``:`rotate-180`]),fill:`currentColor`,viewBox:`0 0 20 20`},[...i[15]||=[b(`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)):m(``,!0)])],2),b(`th`,{onClick:i[3]||=e=>U(`zero_hop`),class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[b(`div`,rn,[i[18]||=_(` Zero Hop `,-1),a.value===`zero_hop`?(S(),x(`svg`,{key:0,class:s([`w-3 h-3`,o.value===`asc`?``:`rotate-180`]),fill:`currentColor`,viewBox:`0 0 20 20`},[...i[17]||=[b(`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)):m(``,!0)])],2),b(`th`,{onClick:i[4]||=e=>U(`rssi`),class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[b(`div`,an,[i[20]||=_(` RSSI `,-1),a.value===`rssi`?(S(),x(`svg`,{key:0,class:s([`w-3 h-3`,o.value===`asc`?``:`rotate-180`]),fill:`currentColor`,viewBox:`0 0 20 20`},[...i[19]||=[b(`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)):m(``,!0)])],2),b(`th`,{onClick:i[5]||=e=>U(`snr`),class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[b(`div`,on,[i[22]||=_(` SNR `,-1),a.value===`snr`?(S(),x(`svg`,{key:0,class:s([`w-3 h-3`,o.value===`asc`?``:`rotate-180`]),fill:`currentColor`,viewBox:`0 0 20 20`},[...i[21]||=[b(`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)):m(``,!0)])],2),b(`th`,{onClick:i[6]||=e=>U(`last_seen`),class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[b(`div`,sn,[i[24]||=_(` Last Seen `,-1),a.value===`last_seen`?(S(),x(`svg`,{key:0,class:s([`w-3 h-3`,o.value===`asc`?``:`rotate-180`]),fill:`currentColor`,viewBox:`0 0 20 20`},[...i[23]||=[b(`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)):m(``,!0)])],2),b(`th`,{onClick:i[7]||=e=>U(`first_seen`),class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[b(`div`,cn,[i[26]||=_(` First Seen `,-1),a.value===`first_seen`?(S(),x(`svg`,{key:0,class:s([`w-3 h-3`,o.value===`asc`?``:`rotate-180`]),fill:`currentColor`,viewBox:`0 0 20 20`},[...i[25]||=[b(`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)):m(``,!0)])],2),b(`th`,{onClick:i[8]||=e=>U(`advert_count`),class:s(`text-left text-content-secondary dark:text-content-muted text-xs font-medium py-3 ${I().split(` `)[1]} border-b border-stroke-subtle dark:border-white/5 cursor-pointer hover:text-primary transition-colors select-none`)},[b(`div`,ln,[i[28]||=_(` Advert Count `,-1),a.value===`advert_count`?(S(),x(`svg`,{key:0,class:s([`w-3 h-3`,o.value===`asc`?``:`rotate-180`]),fill:`currentColor`,viewBox:`0 0 20 20`},[...i[27]||=[b(`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)):m(``,!0)])],2)])]),b(`tbody`,un,[(S(!0),x(y,null,r(W.value,e=>(S(),x(`tr`,{key:e.id,class:`hover:bg-background-mute/50 dark:hover:bg-white/5 transition-colors`,onMouseenter:t=>R(e.pubkey),onMouseleave:t=>z(e.pubkey)},[b(`td`,{class:s(I())},[h(Ut,{neighbor:e,onPing:B,onShowDetails:V,onDelete:H},null,8,[`neighbor`])],2),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm`)},v(e.node_name||`Unknown`),3),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm font-mono`)},[b(`button`,{onClick:t=>N(e.pubkey),class:s([`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`,n.value===e.pubkey?`text-green-600 dark:text-green-400 decoration-green-400/60`:``]),title:n.value===e.pubkey?`Copied!`:`Click to copy full public key`},[_(v(f(e.pubkey))+` `,1),n.value===e.pubkey?(S(),x(`span`,pn,`✓`)):m(``,!0)],10,fn)],2),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm`)},[e.latitude!==null&&e.longitude!==null?(S(),x(`div`,mn,[b(`span`,hn,v(e.latitude.toFixed(4))+`, `+v(e.longitude.toFixed(4)),1),b(`div`,gn,[b(`button`,{onClick:t=>j(e.latitude,e.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`},[...i[29]||=[b(`svg`,{width:`14`,height:`14`,viewBox:`0 0 24 24`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},[b(`rect`,{x:`9`,y:`9`,width:`13`,height:`13`,rx:`2`,ry:`2`,stroke:`currentColor`,"stroke-width":`2`}),b(`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,_n),b(`button`,{onClick:t=>M(e.latitude,e.longitude),class:`text-white/60 hover:text-blue-600 dark:text-blue-400 transition-colors cursor-pointer`,title:`Open in Google Maps`},[...i[30]||=[b(`svg`,{width:`14`,height:`14`,viewBox:`0 0 24 24`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},[b(`path`,{d:`M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z`,stroke:`currentColor`,"stroke-width":`2`}),b(`circle`,{cx:`12`,cy:`10`,r:`3`,stroke:`currentColor`,"stroke-width":`2`})],-1)]],8,vn)])])):(S(),x(`span`,yn,`Unknown`))],2),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm`)},v(D(e)),3),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm`)},[b(`span`,{class:s([`inline-block px-2 py-1 rounded-full text-xs border transition-colors`,p(e.route_type).bgColor,p(e.route_type).borderColor,p(e.route_type).textColor])},v(p(e.route_type).text),3)],2),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm`)},[b(`span`,{class:s([`inline-block px-2 py-1 rounded-full text-xs border transition-colors`,e.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`])},v(e.zero_hop?`Zero Hop`:`Multi-Hop`),3)],2),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm`)},[b(`div`,bn,[b(`div`,xn,[(S(),x(y,null,r(5,t=>b(`div`,{key:t,class:s([`w-1 transition-colors`,t<=P(e.rssi).bars?P(e.rssi).color:`text-gray-600`]),style:l({height:`${4+t*2}px`})},[...i[31]||=[b(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],6)),64))]),b(`span`,{class:s(P(e.rssi).color)},v(w(e.rssi)),3)])],2),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm`)},v(T(e.snr)),3),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm`)},[b(`div`,Sn,[b(`div`,{class:s([`w-2 h-2 rounded-full`,A(e.last_seen).color===`text-green-600 dark:text-green-400`?`bg-green-400`:``,A(e.last_seen).color===`text-yellow-600 dark:text-yellow-400`?`bg-yellow-400`:``,A(e.last_seen).color===`text-red-600 dark:text-red-400`?`bg-red-400`:``])},null,2),b(`span`,{class:s([A(e.last_seen).color,`cursor-help`]),title:d(e.last_seen)},v(k(e.last_seen)),11,Cn)])],2),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm`)},[b(`span`,{title:d(e.first_seen),class:`cursor-help`},v(k(e.first_seen)),9,wn)],2),b(`td`,{class:s(`${I()} text-content-primary dark:text-content-primary text-sm text-center`)},v(e.advert_count),3)],40,dn))),128))])])]),b(`div`,Tn,[(S(!0),x(y,null,r(W.value,e=>(S(),x(`div`,{key:e.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:t=>R(e.pubkey)},[b(`div`,Dn,[b(`div`,On,[b(`h4`,kn,v(e.node_name||`Unknown Node`),1),b(`div`,An,[b(`span`,{class:s([`inline-block px-2 py-1 rounded-full text-xs border`,p(e.route_type).bgColor,p(e.route_type).borderColor,p(e.route_type).textColor])},v(p(e.route_type).text),3),b(`span`,{class:s([`inline-block px-2 py-1 rounded-full text-xs border`,e.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`])},v(e.zero_hop?`Zero Hop`:`Multi-Hop`),3)])]),h(Ut,{neighbor:e,onPing:B,onShowDetails:V,onDelete:H},null,8,[`neighbor`])]),b(`div`,jn,[b(`div`,Mn,[b(`div`,null,[i[32]||=b(`div`,{class:`text-content-muted text-xs mb-1`},`Public Key`,-1),b(`button`,{onClick:t=>N(e.pubkey),class:s([`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`,n.value===e.pubkey?`text-green-600 dark:text-green-400 decoration-green-400/60`:``]),title:n.value===e.pubkey?`Copied!`:`Click to copy full public key`},[_(v(f(e.pubkey))+` `,1),n.value===e.pubkey?(S(),x(`span`,Pn,`✓`)):m(``,!0)],10,Nn)]),b(`div`,null,[i[34]||=b(`div`,{class:`text-content-muted text-xs mb-1`},`Signal`,-1),b(`div`,Fn,[b(`div`,In,[(S(),x(y,null,r(5,t=>b(`div`,{key:t,class:s([`w-1.5 transition-colors`,t<=P(e.rssi).bars?P(e.rssi).color:`text-gray-600`]),style:l({height:`${6+t*2}px`})},[...i[33]||=[b(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],6)),64))]),b(`span`,{class:s(`${P(e.rssi).color} text-sm font-medium`)},v(w(e.rssi)),3)])])]),b(`div`,Ln,[b(`div`,null,[i[35]||=b(`div`,{class:`text-content-muted text-xs mb-1`},`Last Seen`,-1),b(`div`,Rn,[b(`div`,{class:s([`w-2 h-2 rounded-full`,A(e.last_seen).color===`text-green-600 dark:text-green-400`?`bg-green-400`:``,A(e.last_seen).color===`text-yellow-600 dark:text-yellow-400`?`bg-yellow-400`:``,A(e.last_seen).color===`text-red-600 dark:text-red-400`?`bg-red-400`:``])},null,2),b(`span`,{class:s(`${A(e.last_seen).color} text-sm`),title:d(e.last_seen)},v(k(e.last_seen)),11,zn)])]),b(`div`,null,[i[36]||=b(`div`,{class:`text-content-muted text-xs mb-1`},`Distance`,-1),b(`span`,Bn,v(D(e)),1)])]),e.latitude!==null&&e.longitude!==null?(S(),x(`div`,Vn,[i[39]||=b(`div`,{class:`text-content-muted text-xs mb-1`},`Location`,-1),b(`div`,Hn,[b(`span`,Un,v(e.latitude.toFixed(4))+`, `+v(e.longitude.toFixed(4)),1),b(`div`,Wn,[b(`button`,{onClick:t=>j(e.latitude,e.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`},[...i[37]||=[b(`svg`,{width:`16`,height:`16`,viewBox:`0 0 24 24`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},[b(`rect`,{x:`9`,y:`9`,width:`13`,height:`13`,rx:`2`,ry:`2`,stroke:`currentColor`,"stroke-width":`2`}),b(`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,Gn),b(`button`,{onClick:t=>M(e.latitude,e.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`},[...i[38]||=[b(`svg`,{width:`16`,height:`16`,viewBox:`0 0 24 24`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},[b(`path`,{d:`M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z`,stroke:`currentColor`,"stroke-width":`2`}),b(`circle`,{cx:`12`,cy:`10`,r:`3`,stroke:`currentColor`,"stroke-width":`2`})],-1)]],8,Kn)])])])):m(``,!0),b(`div`,qn,[b(`div`,Jn,[i[40]||=b(`div`,{class:`text-content-muted text-xs mb-1`},`SNR`,-1),b(`span`,Yn,v(T(e.snr)),1)]),b(`div`,Xn,[i[41]||=b(`div`,{class:`text-content-muted text-xs mb-1`},`Adverts`,-1),b(`span`,Zn,v(e.advert_count),1)]),b(`div`,Qn,[i[42]||=b(`div`,{class:`text-content-muted text-xs mb-1`},`First Seen`,-1),b(`span`,{class:`text-content-primary dark:text-content-primary text-sm`,title:d(e.first_seen)},v(k(e.first_seen)),9,$n)])])])],8,En))),128))])]))}}),tr={class:`space-y-6`},nr={key:0,class:`flex items-center justify-center py-12`},rr={key:1,class:`bg-red-50 dark:bg-accent-red/10 border border-red-300 dark:border-accent-red/20 rounded-[15px] p-6`},ir={class:`flex items-center gap-3`},ar={class:`text-red-500 dark:text-accent-red/80 text-sm`},or={key:0,class:``},sr={class:`flex items-center justify-between`},cr={class:`flex items-center gap-3`},lr={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`},ur={class:`flex items-center gap-2`},dr={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`},fr={class:`bg-background dark:bg-background/30 border border-stroke-subtle dark:border-stroke/10 rounded-lg p-4 mt-4 space-y-4`},pr={class:`grid grid-cols-1 md:grid-cols-3 gap-4`},mr={key:1,class:`text-center py-12`},hr={key:2,class:`text-center py-12`},gr=u({name:`NeighborsView`,__name:`Neighbors`,setup(e){let t=T(),n={0:`Unknown`,1:`Chat Node`,2:`Repeater`,3:`Room Server`,4:`Hybrid Node`},a={0:`#6b7280`,1:`#60a5fa`,2:`#34d399`,3:`#a855f7`,4:`#f59e0b`},o=C({}),l=C(!0),u=C(null),d=C(P(`neighbors_compactView`,!1)),E=C(P(`neighbors_showMapLegend`,typeof window<`u`?window.innerWidth>=1024:!0)),D=C(P(`neighbors_showFilters`,!1)),A=C(P(`neighbors_filters`,{zeroHop:`all`,routeType:`all`,searchText:``}));p(d,e=>N(`neighbors_compactView`,e)),p(E,e=>N(`neighbors_showMapLegend`,e)),p(D,e=>N(`neighbors_showFilters`,e)),p(A,e=>N(`neighbors_filters`,e),{deep:!0});let M=C(!1),F=C(!1),I=C(!1),L=C(null),R=C(null),z=C(null),B=C(null),V=C(!1),H=C(null),W=g(()=>{if(!B.value)return null;let e=B.value;return{id:e.id,pubkey:e.pubkey,node_name:e.node_name,contact_type:e.contact_type,latitude:e.latitude,longitude:e.longitude,rssi:e.rssi,snr:e.snr,route_type:e.route_type,last_seen:e.last_seen,first_seen:e.first_seen,advert_count:e.advert_count,timestamp:e.timestamp,is_repeater:e.is_repeater,is_new_neighbor:e.is_new_neighbor,zero_hop:e.zero_hop}}),G=g(()=>t.stats?.config?.repeater?.latitude),K=g(()=>t.stats?.config?.repeater?.longitude),ee=e=>e.filter(e=>{if(A.value.zeroHop!==`all`){let t=e.zero_hop;if(A.value.zeroHop===`true`&&!t||A.value.zeroHop===`false`&&t)return!1}if(A.value.routeType!==`all`){let t=e.route_type;if(A.value.routeType===`direct`&&t!==2||A.value.routeType===`transport_direct`&&t!==3||A.value.routeType===`flood`&&t!==1||A.value.routeType===`transport_flood`&&t!==0)return!1}if(A.value.searchText){let t=A.value.searchText.toLowerCase(),n=e.node_name?.toLowerCase()||``,r=e.pubkey.toLowerCase();if(!n.includes(t)&&!r.includes(t))return!1}return!0}),te=()=>{A.value={zeroHop:`all`,routeType:`all`,searchText:``}},q=g(()=>A.value.zeroHop!==`all`||A.value.routeType!==`all`||A.value.searchText!==``),ne=g(()=>{let e={};for(let[t,n]of Object.entries(o.value))e[t]=ee(n);return e}),re=g(()=>Object.entries(n).filter(([e])=>ne.value[e]?.length>0).sort(([e],[t])=>parseInt(e)-parseInt(t))),ie=g(()=>Object.values(o.value).flat().filter(e=>{let t=e.latitude,n=e.longitude;return t!=null&&t!==0&&n!=null&&n!==0&&typeof t==`number`&&typeof n==`number`&&!isNaN(t)&&!isNaN(n)&&e.zero_hop===!0})),ae=async e=>{try{let t=await w.get(`/adverts_by_contact_type?contact_type=${encodeURIComponent(e)}&hours=168`);return t.success&&Array.isArray(t.data)?t.data:[]}catch(t){return console.error(`Error fetching adverts for contact type ${e}:`,t),[]}},oe=async()=>{l.value=!0,u.value=null;try{o.value={};for(let[e,t]of Object.entries(n)){let n=await ae(t);n.length>0&&(o.value[e]=n)}}catch(e){console.error(`Error loading adverts:`,e),u.value=e instanceof Error?e.message:`Failed to load neighbor data`}finally{l.value=!1}},se=C(),ce=e=>{se.value?.highlightNode(e)},le=e=>{se.value?.unhighlightNode(e)},ue=async e=>{let n=e;L.value=null,R.value=null,I.value=!0,z.value=n.node_name||`Unknown Node`,F.value=!0;try{let e=t.stats?.config?.mesh?.path_hash_mode??0,r=(e===2?3:e===1?2:1)*2,i=`0x${parseInt(n.pubkey.substring(0,r),16).toString(16).padStart(r,`0`)}`,a=await w.pingNeighbor(i,10);a.success&&a.data?L.value=a.data:(R.value=a.error||`Unknown error occurred`,console.error(`Failed to ping neighbor:`,a.error))}catch(e){console.error(`Error pinging neighbor:`,e),R.value=e instanceof Error?e.message:`Unknown error occurred`}finally{I.value=!1}},de=()=>{F.value=!1,L.value=null,R.value=null,z.value=null},fe=e=>{B.value=e,M.value=!0},pe=e=>{H.value=e,V.value=!0},me=()=>{V.value=!1,H.value=null},he=()=>{M.value=!1,B.value=null},ge=async e=>{try{await w.deleteAdvert(e),await oe(),he()}catch(e){console.error(`Error deleting neighbor:`,e)}};return i(async()=>{await oe()}),(e,t)=>(S(),x(`div`,tr,[l.value?(S(),x(`div`,nr,[...t[7]||=[b(`div`,{class:`text-center`},[b(`div`,{class:`animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4`}),b(`p`,{class:`text-content-secondary dark:text-content-muted`},`Loading neighbor data...`)],-1)]])):u.value?(S(),x(`div`,rr,[b(`div`,ir,[t[9]||=b(`svg`,{class:`w-5 h-5 text-red-600 dark:text-accent-red`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),b(`div`,null,[t[8]||=b(`h3`,{class:`text-red-600 dark:text-accent-red font-medium`},`Error Loading Neighbors`,-1),b(`p`,ar,v(u.value),1)])])])):(S(),x(y,{key:2},[h(Vt,{ref_key:`networkMapRef`,ref:se,adverts:ie.value,"base-latitude":G.value,"base-longitude":K.value,"show-legend":E.value,"onUpdate:showLegend":t[0]||=e=>E.value=e},null,8,[`adverts`,`base-latitude`,`base-longitude`,`show-legend`]),Object.keys(o.value).length>0?(S(),x(`div`,or,[b(`div`,sr,[t[14]||=b(`span`,{class:`text-content-primary dark:text-content-primary text-lg font-semibold`},null,-1),b(`div`,cr,[b(`div`,lr,[b(`button`,{onClick:t[1]||=e=>d.value=!1,class:s([`p-2 rounded-md transition-colors`,d.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`},[...t[10]||=[b(`svg`,{width:`18`,height:`18`,viewBox:`0 0 24 24`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},[b(`rect`,{x:`3`,y:`3`,width:`18`,height:`6`,rx:`2`,stroke:`currentColor`,"stroke-width":`2`}),b(`rect`,{x:`3`,y:`12`,width:`18`,height:`6`,rx:`2`,stroke:`currentColor`,"stroke-width":`2`})],-1)]],2),b(`button`,{onClick:t[2]||=e=>d.value=!0,class:s([`p-2 rounded-md transition-colors`,d.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`},[...t[11]||=[b(`svg`,{width:`18`,height:`18`,viewBox:`0 0 24 24`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},[b(`rect`,{x:`3`,y:`3`,width:`18`,height:`4`,rx:`2`,stroke:`currentColor`,"stroke-width":`2`}),b(`rect`,{x:`3`,y:`10`,width:`18`,height:`4`,rx:`2`,stroke:`currentColor`,"stroke-width":`2`}),b(`rect`,{x:`3`,y:`17`,width:`18`,height:`4`,rx:`2`,stroke:`currentColor`,"stroke-width":`2`})],-1)]],2)]),b(`div`,ur,[b(`button`,{onClick:t[3]||=e=>D.value=!D.value,class:s([`px-3 py-1.5 text-xs rounded-lg transition-colors border`,q.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`])},[t[12]||=b(`svg`,{class:`w-4 h-4 inline mr-1`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[b(`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),t[13]||=_(` Filters `,-1),q.value?(S(),x(`span`,dr,` Active `)):m(``,!0)],2),q.value?(S(),x(`button`,{key:0,onClick:te,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 `)):m(``,!0)])])]),f(b(`div`,fr,[b(`div`,pr,[b(`div`,null,[t[16]||=b(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},`Zero Hop`,-1),f(b(`select`,{"onUpdate:modelValue":t[4]||=e=>A.value.zeroHop=e,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`},[...t[15]||=[b(`option`,{value:`all`},`All Nodes`,-1),b(`option`,{value:`true`},`Zero Hop Only`,-1),b(`option`,{value:`false`},`Multi-Hop Only`,-1)]],512),[[O,A.value.zeroHop]])]),b(`div`,null,[t[18]||=b(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},`Route Type`,-1),f(b(`select`,{"onUpdate:modelValue":t[5]||=e=>A.value.routeType=e,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`},[...t[17]||=[c(``,5)]],512),[[O,A.value.routeType]])]),b(`div`,null,[t[19]||=b(`label`,{class:`block text-xs font-medium text-content-secondary dark:text-content-muted mb-1`},`Search`,-1),f(b(`input`,{"onUpdate:modelValue":t[6]||=e=>A.value.searchText=e,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),[[k,A.value.searchText]])])])],512),[[j,D.value]])])):m(``,!0),(S(!0),x(y,null,r(re.value,([e,t])=>(S(),x(`div`,{key:e,class:`space-y-6`},[h(er,{"contact-type":t,"contact-type-key":e,adverts:ne.value[e],"original-count":o.value[e]?.length||0,color:a[parseInt(e)],"base-latitude":G.value,"base-longitude":K.value,"is-compact-view":d.value,"is-first-table":!1,"show-view-toggle":!1,onHighlightNode:ce,onUnhighlightNode:le,onMenuPing:ue,onMenuDelete:fe,onShowDetails:pe},null,8,[`contact-type`,`contact-type-key`,`adverts`,`original-count`,`color`,`base-latitude`,`base-longitude`,`is-compact-view`])]))),128)),re.value.length===0&&Object.keys(o.value).length===0?(S(),x(`div`,mr,[t[20]||=c(`

No Neighbors Found

No mesh neighbors have been discovered in your area yet.

`,3),b(`button`,{onClick:oe,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 `)])):re.value.length===0&&q.value?(S(),x(`div`,hr,[t[21]||=c(`

No neighbors match your filters

Try adjusting your filter criteria to see more results.

`,3),b(`button`,{onClick:te,class:`px-4 py-2 bg-primary/20 text-primary border border-primary/30 rounded-lg hover:bg-primary/30 transition-colors`},` Clear Filters `)])):m(``,!0)],64)),h(U,{show:M.value,neighbor:W.value,onClose:he,onDelete:ge},null,8,[`show`,`neighbor`]),h(ke,{show:F.value,"node-name":z.value,result:L.value,error:R.value,loading:I.value,onClose:de},null,8,[`show`,`node-name`,`result`,`error`,`loading`]),h(gt,{"is-open":V.value,neighbor:H.value,"base-latitude":G.value,"base-longitude":K.value,onClose:me},null,8,[`is-open`,`neighbor`,`base-latitude`,`base-longitude`])]))}});export{gr as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Neighbors-Cfo189NY.css b/repeater/web/html/assets/Neighbors-Cfo189NY.css new file mode 100644 index 0000000..e379b7d --- /dev/null +++ b/repeater/web/html/assets/Neighbors-Cfo189NY.css @@ -0,0 +1 @@ +.modal-enter-active[data-v-dacea749],.modal-leave-active[data-v-dacea749]{transition:opacity .2s}.modal-enter-from[data-v-dacea749],.modal-leave-to[data-v-dacea749]{opacity:0}.modal-enter-active>div[data-v-dacea749],.modal-leave-active>div[data-v-dacea749]{transition:transform .2s}.modal-enter-from>div[data-v-dacea749],.modal-leave-to>div[data-v-dacea749]{transform:scale(.95)}.packet-enter-active[data-v-dacea749],.packet-leave-active[data-v-dacea749]{transition:all .15s}.packet-enter-from[data-v-dacea749],.packet-leave-to[data-v-dacea749]{opacity:0;transform:translate(-50%)scale(.5)}.custom-scrollbar[data-v-2fb1fa15]::-webkit-scrollbar{width:8px}.custom-scrollbar[data-v-2fb1fa15]::-webkit-scrollbar-track{background:0 0}.custom-scrollbar[data-v-2fb1fa15]::-webkit-scrollbar-thumb{background:#0003;border-radius:4px}.dark .custom-scrollbar[data-v-2fb1fa15]::-webkit-scrollbar-thumb{background:#fff3}.custom-scrollbar[data-v-2fb1fa15]::-webkit-scrollbar-thumb:hover{background:#0000004d}.dark .custom-scrollbar[data-v-2fb1fa15]::-webkit-scrollbar-thumb:hover{background:#ffffff4d}.modal-enter-active[data-v-2fb1fa15],.modal-leave-active[data-v-2fb1fa15]{transition:opacity .3s}.modal-enter-active>div[data-v-2fb1fa15],.modal-leave-active>div[data-v-2fb1fa15]{transition:transform .3s,opacity .3s}.modal-enter-from[data-v-2fb1fa15],.modal-leave-to[data-v-2fb1fa15]{opacity:0}.modal-enter-from>div[data-v-2fb1fa15],.modal-leave-to>div[data-v-2fb1fa15]{opacity:0;transform:scale(.95)}.leaflet-container{background:0 0}.custom-marker{background:0 0!important;border:none!important}.map-container[data-v-61a18eed]{background:0 0;border-radius:15px;position:relative;overflow:hidden}.leaflet-map-container[data-v-61a18eed]{-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);background:linear-gradient(135deg,#09090bcc 0%,#0009 100%)}.map-legend[data-v-61a18eed]{color:#fff;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);z-index:1000;background:#0006;border:1px solid #ffffff1a;border-radius:15px;min-width:150px;max-width:180px;padding:12px;font-size:12px;position:absolute;top:10px;right:10px;box-shadow:0 8px 32px #0000004d}.legend-title[data-v-61a18eed]{color:#fff;margin-bottom:10px;font-size:13px;font-weight:700}.legend-section[data-v-61a18eed]{margin-bottom:10px}.legend-section[data-v-61a18eed]:last-of-type{margin-bottom:8px}.legend-subtitle[data-v-61a18eed]{color:#fffc;text-transform:uppercase;letter-spacing:.5px;margin-bottom:6px;font-size:11px;font-weight:600}.legend-footer[data-v-61a18eed]{color:#fff9;text-align:center;border-top:1px solid #ffffff1a;margin-top:10px;padding-top:8px;font-size:10px}.legend-items[data-v-61a18eed]{flex-direction:column;gap:4px;display:flex}.legend-item[data-v-61a18eed]{align-items:center;gap:6px;display:flex}.legend-icon[data-v-61a18eed]{border:1px solid #fffc;border-radius:50%;flex-shrink:0;width:8px;height:8px;box-shadow:0 1px 2px #0003}.legend-icon.cluster-icon[data-v-61a18eed]{-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);border:1px solid #aae8e8;border-radius:50%;width:16px;height:16px}.legend-line[data-v-61a18eed]{border-radius:1px;flex-shrink:0;width:16px;height:2px;position:relative}.legend-line-dashed[data-v-61a18eed]{background-color:#0000!important;background-image:repeating-linear-gradient(90deg,currentColor 0 4px,#0000 4px 8px)!important}.legend-line-dashed[style*=\#FFC246][data-v-61a18eed]{color:#ffc246!important}.legend-line-dashed[style*=\#ea580c][data-v-61a18eed]{color:#ea580c!important}.marker-highlight{z-index:1000!important;border-radius:50%!important;animation:1s ease-in-out infinite marker-glow-61a18eed!important;position:relative!important;transform:scale(1.2)!important;box-shadow:0 0 0 3px #a5e5b6,0 0 8px #a5e5b6,0 0 16px #a5e5b6!important}@keyframes marker-glow-61a18eed{0%,to{filter:brightness();box-shadow:0 0 0 3px #a5e5b6,0 0 8px #a5e5b6,0 0 16px #a5e5b6}50%{filter:brightness(1.3);box-shadow:0 0 0 5px #a5e5b6,0 0 12px #a5e5b6,0 0 24px #a5e5b6}}@keyframes pulse-highlight-61a18eed{0%{box-shadow:0 0 #3b82f6b3}70%{box-shadow:0 0 0 8px #3b82f600}to{box-shadow:0 0 #3b82f600}}.leaflet-popup-content-wrapper{color:#fff!important;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important;background:#0006!important;border:1px solid #ffffff1a!important;border-radius:15px!important;box-shadow:0 8px 32px #0000004d!important}.leaflet-popup-tip{background:#0006!important;border:1px solid #ffffff1a!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:0 0!important;border:none!important}.custom-cluster-icon div{cursor:pointer!important;transition:all .3s!important}.custom-cluster-icon:hover div{transform:scale(1.1)!important;box-shadow:0 6px 16px #aae8e880!important}.leaflet-control-zoom{overflow:hidden;-webkit-backdrop-filter:blur(20px)!important;backdrop-filter:blur(20px)!important;border:1px solid #ffffff1a!important;border-radius:15px!important}.leaflet-control-zoom a{color:#fff!important;background-color:#0006!important;border-bottom:1px solid #ffffff1a!important;transition:all .2s!important}.leaflet-control-zoom a:hover{color:#fff!important;background-color:#ffffff1a!important}.leaflet-control-attribution{color:#9ca3af!important;background-color:#1f2937cc!important;border-top:1px solid #4b55634d!important;border-radius:4px!important;padding:4px 8px!important;font-size:11px!important}.leaflet-control-attribution a{text-decoration:none;color:#60a5fa!important}.leaflet-control-attribution a:hover{text-decoration:underline;color:#93c5fd!important}.leaflet-bottom.leaflet-left .leaflet-control-attribution{margin-bottom:10px!important;margin-left:10px!important}.map-attribution[data-v-61a18eed]{color:#fff9;-webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);z-index:1000;background:#0006;border:1px solid #ffffff1a;border-radius:15px;padding:4px 8px;font-size:10px;position:absolute;bottom:10px;left:10px}@media (width<=640px){.leaflet-control-attribution{display:none!important}} diff --git a/repeater/web/html/assets/RFNoiseFloor-DhLKjd9G.js b/repeater/web/html/assets/RFNoiseFloor-DhLKjd9G.js new file mode 100644 index 0000000..fe69da0 --- /dev/null +++ b/repeater/web/html/assets/RFNoiseFloor-DhLKjd9G.js @@ -0,0 +1 @@ +import{n as e}from"./index-BFltqMtv.js";export{e as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/RoomServers-Cngso7KV.js b/repeater/web/html/assets/RoomServers-Cngso7KV.js new file mode 100644 index 0000000..4dc1f86 --- /dev/null +++ b/repeater/web/html/assets/RoomServers-Cngso7KV.js @@ -0,0 +1 @@ +import{E as e,S as t,dt as n,f as r,g as i,j as a,k as ee,l as o,m as s,p as c,pt as l,r as u,s as d,u as f,w as p,z as m}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t as h}from"./api-CbM6k1ZB.js";import{f as g,h as _,m as v}from"./index-BFltqMtv.js";import{t as te}from"./ConfirmDialog-PLW-eI8u.js";import{t as ne}from"./MessageDialog-CEzYMZ-3.js";import{n as re,t as ie}from"./preferences-Bv8i60GL.js";var ae={class:`p-6 space-y-6`},oe={class:`relative overflow-hidden rounded-[20px] p-6 mb-6 glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10`},se={class:`relative flex items-center justify-between`},ce={key:0,class:`grid grid-cols-1 md:grid-cols-3 gap-4`},le={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`},ue={class:`relative flex items-center justify-between`},de={class:`text-3xl font-bold text-content-primary dark:text-content-primary mb-1`},fe={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`},pe={class:`relative flex items-center justify-between`},me={class:`text-3xl font-bold text-primary mb-1`},he={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`},ge={class:`relative flex items-center justify-between`},_e={key:0,class:`w-6 h-6 text-accent-green`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},ve={key:1,class:`w-6 h-6 text-accent-yellow`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},ye={class:`glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-6`},be={key:0,class:`flex items-center justify-center py-12`},xe={key:1,class:`flex items-center justify-center py-12`},Se={class:`text-center`},Ce={class:`text-content-secondary dark:text-content-muted text-sm mb-4`},we={key:2,class:`space-y-4`},Te={class:`relative flex items-start justify-between`},Ee={class:`flex-1`},De={class:`flex items-center gap-3 mb-4`},Oe={class:`relative`},ke={key:0,class:`absolute inset-0 bg-accent-green/50 rounded-full animate-ping`},Ae={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`},Me={class:`grid grid-cols-1 md:grid-cols-2 gap-3 text-sm mb-3`},Ne={class:`text-content-primary dark:text-content-primary/90 ml-2`},Pe={class:`flex items-center gap-2`},y={key:0,class:`text-content-primary dark:text-content-primary/90 font-mono ml-2 text-xs`},Fe={key:1,class:`text-content-muted dark:text-content-muted ml-2 text-xs`},Ie=[`onClick`],Le={class:`text-content-primary dark:text-content-primary/90 ml-2`},Re={key:0},ze={class:`text-content-primary dark:text-content-primary/90 ml-2`},Be={key:0,class:`text-accent-green`},Ve={key:1,class:`text-content-muted dark:text-content-muted`},He={key:2,class:`text-primary`},Ue={key:0,class:`text-xs text-content-muted dark:text-content-muted font-mono`},We={class:`ml-4 flex flex-wrap gap-2`},Ge=[`onClick`,`disabled`,`title`],Ke=[`onClick`,`disabled`,`title`],qe=[`onClick`],Je=[`onClick`],Ye={key:3,class:`text-center py-12 text-content-secondary dark:text-content-muted`},Xe={key:1,class:`fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4`},Ze={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`},Qe={class:`space-y-4`},$e={class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},et={key:0},tt={key:1,class:`text-content-secondary dark:text-content-muted text-sm`},nt={class:`grid grid-cols-2 gap-4`},rt={class:`grid grid-cols-2 gap-4`},it={key:2,class:`fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4`},at={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`},ot={class:`space-y-4`},st=[`value`],ct={class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},lt={key:0},ut={key:1,class:`text-content-secondary dark:text-content-muted text-sm`},dt={class:`grid grid-cols-2 gap-4`},ft={class:`grid grid-cols-2 gap-4`},pt={key:0,class:`fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-50 p-4`},mt={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`},ht={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`},gt={class:`relative flex items-center justify-between`},_t={class:`flex items-center gap-4`},vt={class:`text-content-secondary dark:text-content-muted text-sm flex items-center gap-2`},yt={class:`text-primary font-semibold`},bt={class:`flex items-center gap-2`},xt={class:`bg-primary/30 px-1.5 py-0.5 rounded-full text-[10px]`},St={class:`flex-1 overflow-y-auto mb-4 space-y-3`},Ct={key:0,class:`flex items-center justify-center py-12`},wt={key:1,class:`flex items-center justify-center py-12`},Tt={class:`text-center`},Et={class:`text-content-secondary dark:text-content-muted text-sm mb-4`},Dt={key:2,class:`space-y-3`},Ot={class:`relative flex items-start justify-between gap-3`},kt={class:`flex-1 min-w-0`},At={class:`flex items-center gap-2 mb-3`},jt={class:`flex items-center gap-2 flex-wrap`},Mt={key:0,class:`text-primary text-sm font-bold`},Nt={key:1,class:`text-primary/80 text-xs font-mono bg-primary/10 px-2 py-1 rounded-md border border-primary/20`},Pt={key:2,class:`text-content-muted dark:text-content-muted text-xs`},Ft={class:`text-content-secondary dark:text-content-muted text-xs flex items-center gap-1`},It={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`},Lt={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`},Rt=[`onClick`],zt={key:0,class:`text-center pt-4`},Bt={key:1,class:`text-center pt-4`},Vt={key:3,class:`flex items-center justify-center h-full`},Ht={class:`relative overflow-hidden rounded-[15px] border-t border-stroke-subtle dark:border-white/20 pt-4 mt-4`},Ut={class:`relative space-y-3`},Wt={class:`flex gap-3`},Gt={class:`flex-1 relative`},Kt=[`onKeydown`],qt=[`disabled`],Jt={key:1,class:`fixed inset-0 bg-black/70 backdrop-blur-md flex items-center justify-center z-[60] p-4`},Yt={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`},Xt={class:`flex items-center justify-between mb-4 pb-4 border-b border-stroke-subtle dark:border-white/10`},Zt={class:`text-content-secondary dark:text-content-primary/70 text-sm mt-1`},Qt={class:`text-primary`},b={class:`flex-1 overflow-y-auto space-y-3`},$t={key:0,class:`text-center py-12`},en={class:`space-y-2`},tn={class:`flex items-center justify-between`},nn={class:`flex items-center gap-2`},rn={class:`text-content-primary dark:text-content-primary font-semibold`},an={class:`flex items-center gap-2`},on={class:`text-content-secondary dark:text-content-muted text-xs`},sn=[`onClick`],cn={class:`space-y-1 text-xs`},ln={class:`flex items-center gap-2`},un={class:`text-primary font-mono bg-primary/10 px-2 py-0.5 rounded`},dn={class:`flex items-center gap-2`},fn={class:`text-primary font-mono bg-primary/10 px-2 py-0.5 rounded text-[10px] break-all`},pn={class:`flex items-center justify-between text-xs text-content-secondary dark:text-content-muted`},mn={class:`flex items-center gap-4`},hn={key:0},gn={key:1},_n={key:0},x=i({name:`RoomServersView`,__name:`RoomServers`,setup(i){let x=m(!1),S=m(null),C=m(null),w=m(!1),T=m(!1),E=m(null),D=m(!1),O=m(!1),k=m(new Set),A=m(!1),j=m(``),M=m(!1),N=m({message:``,variant:`success`}),P=m(!1),F=m(``),I=m(``),L=m([]),R=m(!1),z=m(null),B=m(``),V=m(ie(`roomServers_messagesLimit`,50)),H=m(0),U=m(!0);ee(V,e=>re(`roomServers_messagesLimit`,e));let W=m([]),G=m(!1),K=m({name:``,identity_key:``,type:`room_server`,settings:{node_name:``,latitude:0,longitude:0,admin_password:``,guest_password:``}});t(async()=>{await q()});async function q(){x.value=!0,S.value=null;try{let e=await h.getIdentities();e.success?C.value=e.data:S.value=e.error||`Failed to load identities`}catch(e){S.value=e instanceof Error?e.message:`Failed to load identities`}finally{x.value=!1}}async function vn(){try{let e=await h.createIdentity(K.value);e.success?(w.value=!1,Y(),await q(),J(e.message||`Identity created successfully!`,`success`)):J(`Failed to create identity: ${e.error}`,`error`)}catch(e){J(`Error creating identity: ${e}`,`error`)}}async function yn(){try{let e=await h.updateIdentity(E.value);e.success?(T.value=!1,E.value=null,await q(),J(e.message||`Identity updated successfully!`,`success`)):J(`Failed to update identity: ${e.error}`,`error`)}catch(e){J(`Error updating identity: ${e}`,`error`)}}function bn(e){j.value=e,A.value=!0}async function xn(){let e=j.value;A.value=!1;try{let t=await h.deleteIdentity(e);t.success?(await q(),J(t.message||`Identity deleted successfully!`,`success`)):J(`Failed to delete identity: ${t.error}`,`error`)}catch(e){J(`Error deleting identity: ${e}`,`error`)}finally{j.value=``}}function J(e,t){N.value={message:e,variant:t},M.value=!0}async function Sn(e){try{let t=await h.sendRoomServerAdvert(e);t.success?J(t.message||`Advert sent for '${e}'!`,`success`):J(`Failed to send advert: ${t.error}`,`error`)}catch(e){J(`Error sending advert: ${e}`,`error`)}}function Cn(e){E.value=JSON.parse(JSON.stringify(e)),E.value.settings||(E.value.settings={}),E.value.settings.admin_password||(E.value.settings.admin_password=``),E.value.settings.guest_password||(E.value.settings.guest_password=``),O.value=!1,T.value=!0}function Y(){K.value={name:``,identity_key:``,type:`room_server`,settings:{node_name:``,latitude:0,longitude:0,admin_password:``,guest_password:``}},D.value=!1}function X(){w.value=!1,T.value=!1,E.value=null,D.value=!1,O.value=!1,Y()}function wn(e){k.value.has(e)?k.value.delete(e):k.value.add(e)}async function Tn(e){F.value=e,P.value=!0,H.value=0,U.value=!0,I.value=C.value?.configured.find(t=>t.name===e)?.hash||``,await Z(),await Q(!0)}async function Z(){try{let e=await h.getACLClients({identity_hash:I.value,identity_name:F.value});e.success&&e.data&&(W.value=e.data.clients||[])}catch(e){console.error(`Failed to fetch ACL clients:`,e)}}async function Q(e=!1){e&&(H.value=0,L.value=[]),R.value=!0,z.value=null;try{let t=await h.getRoomMessages({room_name:F.value,limit:V.value,offset:H.value});if(t.success&&t.data){let n=t.data.messages||[];e?L.value=n:L.value=[...L.value,...n],U.value=n.length===V.value}else z.value=t.error||`Failed to load messages`}catch(e){z.value=e instanceof Error?e.message:`Failed to load messages`}finally{R.value=!1}}async function En(){H.value+=V.value,await Q(!1)}async function $(){if(B.value.trim())try{let e=await h.postRoomMessage({room_name:F.value,message:B.value,author_pubkey:`server`});e.success?(B.value=``,await Q(!0)):J(`Failed to send message: ${e.error}`,`error`)}catch(e){J(`Error sending message: ${e}`,`error`)}}async function Dn(e){if(confirm(`Are you sure you want to delete this message?`))try{let t=await h.deleteRoomMessage({room_name:F.value,message_id:e});t.success?(await Q(!0),J(`Message deleted successfully`,`success`)):J(`Failed to delete message: ${t.error}`,`error`)}catch(e){J(`Error deleting message: ${e}`,`error`)}}function On(){P.value=!1,F.value=``,I.value=``,L.value=[],B.value=``,z.value=null,W.value=[]}function kn(e){return e?new Date(e*1e3).toLocaleString():`Unknown`}async function An(e,t){if(confirm(`Are you sure you want to remove this client from the ACL?`))try{let n=await h.removeACLClient({public_key:e,identity_hash:t});n.success?(await Z(),J(`Client removed successfully`,`success`)):J(`Failed to remove client: ${n.error}`,`error`)}catch(e){J(`Error removing client: ${e}`,`error`)}}return(t,i)=>(p(),f(u,null,[d(`div`,ae,[d(`div`,oe,[i[26]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-br from-primary/20 via-secondary/10 to-accent-purple/20 opacity-50`},null,-1),i[27]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-tl from-accent-green/10 via-transparent to-primary/10 animate-pulse`},null,-1),d(`div`,se,[i[25]||=r(`

Room Servers

Manage room server identities and messages

`,1),d(`button`,{onClick:i[0]||=e=>w.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`},[...i[24]||=[d(`span`,{class:`flex items-center gap-2`},[d(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 4v16m8-8H4`})]),c(` Add Room Server `)],-1)]])])]),C.value&&C.value.total_configured>0?(p(),f(`div`,ce,[d(`div`,le,[i[30]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-br from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity`},null,-1),d(`div`,ue,[d(`div`,null,[i[28]||=d(`div`,{class:`text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide`},` Total Configured `,-1),d(`div`,de,l(C.value.total_configured),1)]),i[29]||=d(`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`},[d(`svg`,{class:`w-6 h-6 text-content-secondary dark:text-content-primary/70`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`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)])]),d(`div`,fe,[i[33]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-br from-primary/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity`},null,-1),d(`div`,pe,[d(`div`,null,[i[31]||=d(`div`,{class:`text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide`},` Currently Registered `,-1),d(`div`,me,l(C.value.total_registered),1)]),i[32]||=d(`div`,{class:`bg-primary/20 p-3 rounded-[12px] group-hover:bg-primary/30 transition-colors`},[d(`svg`,{class:`w-6 h-6 text-primary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`})])],-1)])]),d(`div`,he,[i[37]||=d(`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),d(`div`,ge,[d(`div`,null,[i[34]||=d(`div`,{class:`text-content-secondary dark:text-content-muted text-xs font-medium mb-2 uppercase tracking-wide`},` Status `,-1),d(`div`,{class:n([`text-3xl font-bold`,C.value.total_registered===C.value.total_configured?`text-accent-green`:`text-accent-yellow`])},l(C.value.total_registered===C.value.total_configured?`Synced`:`Out of Sync`),3)]),d(`div`,{class:n([`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?(p(),f(`svg`,_e,[...i[35]||=[d(`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)]])):(p(),f(`svg`,ve,[...i[36]||=[d(`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)])])])):o(``,!0),d(`div`,ye,[x.value?(p(),f(`div`,be,[...i[38]||=[d(`div`,{class:`text-center`},[d(`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`}),d(`div`,{class:`text-content-secondary dark:text-content-primary/70`},` Loading room servers... `)],-1)]])):S.value?(p(),f(`div`,xe,[d(`div`,Se,[i[39]||=d(`div`,{class:`text-red-600 dark:text-red-400 mb-2`},`Failed to load room servers`,-1),d(`div`,Ce,l(S.value),1),d(`button`,{onClick:q,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?(p(),f(`div`,we,[(p(!0),f(u,null,e(C.value.configured,e=>(p(),f(`div`,{key:e.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`},[i[46]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-r from-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity`},null,-1),d(`div`,Te,[d(`div`,Ee,[d(`div`,De,[d(`div`,Oe,[e.registered?(p(),f(`div`,ke)):o(``,!0),d(`div`,{class:n([`relative w-3 h-3 rounded-full`,e.registered?`bg-accent-green`:`bg-accent-red`])},null,2)]),d(`h3`,Ae,l(e.name),1),d(`span`,{class:n([`px-3 py-1 text-xs font-semibold rounded-full`,e.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`])},l(e.registered?`● Active`:`○ Inactive`),3),e.hash?(p(),f(`span`,je,l(e.hash),1)):o(``,!0)]),d(`div`,Me,[d(`div`,null,[i[40]||=d(`span`,{class:`text-content-muted dark:text-content-muted`},`Node Name:`,-1),d(`span`,Ne,l(e.settings?.node_name||`Not set`),1)]),d(`div`,Pe,[i[41]||=d(`span`,{class:`text-content-muted dark:text-content-muted`},`Identity Key:`,-1),k.value.has(e.name)?(p(),f(`span`,y,l(e.identity_key),1)):(p(),f(`span`,Fe,` •••••••••••••••• `)),d(`button`,{onClick:t=>wn(e.name),class:`text-primary/70 hover:text-primary text-xs underline`},l(k.value.has(e.name)?`Hide`:`Show`),9,Ie)]),d(`div`,null,[i[42]||=d(`span`,{class:`text-content-muted dark:text-content-muted`},`Location:`,-1),d(`span`,Le,l(e.settings?.latitude||0)+`, `+l(e.settings?.longitude||0),1)]),e.settings?.admin_password||e.settings?.guest_password?(p(),f(`div`,Re,[i[43]||=d(`span`,{class:`text-content-muted dark:text-content-muted`},`Passwords:`,-1),d(`span`,ze,[e.settings?.admin_password?(p(),f(`span`,Be,`Admin`)):o(``,!0),e.settings?.admin_password&&e.settings?.guest_password?(p(),f(`span`,Ve,` / `)):o(``,!0),e.settings?.guest_password?(p(),f(`span`,He,`Guest`)):o(``,!0)])])):o(``,!0)]),e.address?(p(),f(`div`,Ue,` Address: `+l(e.address),1)):o(``,!0)]),d(`div`,We,[d(`button`,{onClick:t=>Tn(e.name),disabled:!e.registered,class:n([`group px-4 py-2 rounded-[10px] text-xs font-medium transition-all duration-200 flex items-center gap-2`,e.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:e.registered?`Manage messages for this room`:`Room server must be active to manage messages`},[...i[44]||=[d(`svg`,{class:`w-4 h-4 group-hover:rotate-12 transition-transform`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`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),c(` Messages `,-1)]],10,Ge),d(`button`,{onClick:t=>Sn(e.name),disabled:!e.registered,class:n([`group px-4 py-2 rounded-[10px] text-xs font-medium transition-all duration-200 flex items-center gap-2`,e.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:e.registered?`Send advert for this room server`:`Room server must be active to send advert`},[...i[45]||=[d(`svg`,{class:`w-4 h-4 group-hover:scale-110 transition-transform`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M13 10V3L4 14h7v7l9-11h-7z`})],-1),c(` Send Advert `,-1)]],10,Ke),d(`button`,{onClick:t=>Cn(e),class:`px-3 py-1 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors`},` Edit `,8,qe),d(`button`,{onClick:t=>bn(e.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,Je)])])]))),128))])):(p(),f(`div`,Ye,[i[47]||=d(`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`},[d(`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),i[48]||=d(`p`,{class:`text-lg mb-2`},`No room servers configured`,-1),i[49]||=d(`p`,{class:`text-sm mb-4`},`Add your first room server to get started`,-1),d(`button`,{onClick:i[1]||=e=>w.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 `)]))]),w.value?(p(),f(`div`,Xe,[d(`div`,Ze,[i[60]||=d(`h2`,{class:`text-xl font-bold text-content-primary dark:text-content-primary mb-4`},` Add Room Server `,-1),d(`div`,Qe,[d(`div`,null,[i[50]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Name *`,-1),a(d(`input`,{"onUpdate:modelValue":i[2]||=e=>K.value.name=e,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),[[g,K.value.name]])]),d(`div`,null,[d(`label`,$e,[i[51]||=c(` Identity Key (Optional) `,-1),d(`button`,{onClick:i[3]||=e=>D.value=!D.value,type:`button`,class:`ml-2 text-primary/70 hover:text-primary text-xs underline`},l(D.value?`Hide`:`Show/Edit`),1)]),D.value?(p(),f(`div`,et,[a(d(`input`,{"onUpdate:modelValue":i[4]||=e=>K.value.identity_key=e,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),[[g,K.value.identity_key]]),i[52]||=d(`p`,{class:`text-content-secondary dark:text-content-muted text-xs mt-1`},` Leave empty to automatically generate a secure key `,-1)])):(p(),f(`div`,tt,` Will be auto-generated if not provided `))]),d(`div`,null,[i[53]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Node Name`,-1),a(d(`input`,{"onUpdate:modelValue":i[5]||=e=>K.value.settings.node_name=e,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),[[g,K.value.settings.node_name]])]),d(`div`,nt,[d(`div`,null,[i[54]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Latitude`,-1),a(d(`input`,{"onUpdate:modelValue":i[6]||=e=>K.value.settings.latitude=e,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),[[g,K.value.settings.latitude,void 0,{number:!0}]])]),d(`div`,null,[i[55]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Longitude`,-1),a(d(`input`,{"onUpdate:modelValue":i[7]||=e=>K.value.settings.longitude=e,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),[[g,K.value.settings.longitude,void 0,{number:!0}]])])]),d(`div`,rt,[d(`div`,null,[i[56]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Admin Password (Optional)`,-1),a(d(`input`,{"onUpdate:modelValue":i[8]||=e=>K.value.settings.admin_password=e,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),[[g,K.value.settings.admin_password]]),i[57]||=d(`p`,{class:`text-content-secondary dark:text-content-muted text-xs mt-1`},` Full access to room server `,-1)]),d(`div`,null,[i[58]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Guest Password (Optional)`,-1),a(d(`input`,{"onUpdate:modelValue":i[9]||=e=>K.value.settings.guest_password=e,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),[[g,K.value.settings.guest_password]]),i[59]||=d(`p`,{class:`text-content-secondary dark:text-content-muted text-xs mt-1`},` Read-only access `,-1)])])]),d(`div`,{class:`flex justify-end gap-3 mt-6`},[d(`button`,{onClick:X,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 `),d(`button`,{onClick:vn,class:`px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors`},` Create `)])])])):o(``,!0),T.value&&E.value?(p(),f(`div`,it,[d(`div`,at,[i[72]||=d(`h2`,{class:`text-xl font-bold text-content-primary dark:text-content-primary mb-4`},` Edit Room Server `,-1),d(`div`,ot,[d(`div`,null,[i[61]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Current Name`,-1),d(`input`,{value:E.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,st)]),d(`div`,null,[i[62]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`New Name (optional)`,-1),a(d(`input`,{"onUpdate:modelValue":i[10]||=e=>E.value.new_name=e,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),[[g,E.value.new_name]])]),d(`div`,null,[d(`label`,ct,[i[63]||=c(` Identity Key (Optional) `,-1),d(`button`,{onClick:i[11]||=e=>O.value=!O.value,type:`button`,class:`ml-2 text-primary/70 hover:text-primary text-xs underline`},l(O.value?`Hide`:`Show/Edit`),1)]),O.value?(p(),f(`div`,lt,[a(d(`input`,{"onUpdate:modelValue":i[12]||=e=>E.value.identity_key=e,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),[[g,E.value.identity_key]]),i[64]||=d(`p`,{class:`text-content-secondary dark:text-content-muted text-xs mt-1`},` Leave empty to keep the current identity key `,-1)])):(p(),f(`div`,ut,` Click "Show/Edit" to change the identity key `))]),d(`div`,null,[i[65]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Node Name`,-1),a(d(`input`,{"onUpdate:modelValue":i[13]||=e=>E.value.settings.node_name=e,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),[[g,E.value.settings.node_name]])]),d(`div`,dt,[d(`div`,null,[i[66]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Latitude`,-1),a(d(`input`,{"onUpdate:modelValue":i[14]||=e=>E.value.settings.latitude=e,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),[[g,E.value.settings.latitude,void 0,{number:!0}]])]),d(`div`,null,[i[67]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Longitude`,-1),a(d(`input`,{"onUpdate:modelValue":i[15]||=e=>E.value.settings.longitude=e,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),[[g,E.value.settings.longitude,void 0,{number:!0}]])])]),d(`div`,ft,[d(`div`,null,[i[68]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Admin Password`,-1),a(d(`input`,{"onUpdate:modelValue":i[16]||=e=>E.value.settings.admin_password=e,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),[[g,E.value.settings.admin_password]]),i[69]||=d(`p`,{class:`text-content-secondary dark:text-content-muted text-xs mt-1`},` Full access to room server `,-1)]),d(`div`,null,[i[70]||=d(`label`,{class:`block text-content-secondary dark:text-content-primary/70 text-sm mb-2`},`Guest Password`,-1),a(d(`input`,{"onUpdate:modelValue":i[17]||=e=>E.value.settings.guest_password=e,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),[[g,E.value.settings.guest_password]]),i[71]||=d(`p`,{class:`text-content-secondary dark:text-content-muted text-xs mt-1`},` Read-only access `,-1)])])]),d(`div`,{class:`flex justify-end gap-3 mt-6`},[d(`button`,{onClick:X,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 `),d(`button`,{onClick:yn,class:`px-4 py-2 bg-primary/20 hover:bg-primary/30 text-primary rounded-lg border border-primary/50 transition-colors`},` Update `)])])])):o(``,!0)]),s(te,{show:A.value,title:`Delete Room Server`,message:`Are you sure you want to delete '${j.value}'? This action cannot be undone.`,"confirm-text":`Delete`,"cancel-text":`Cancel`,variant:`danger`,onClose:i[18]||=e=>A.value=!1,onConfirm:xn},null,8,[`show`,`message`]),s(ne,{show:M.value,message:N.value.message,variant:N.value.variant,onClose:i[19]||=e=>M.value=!1},null,8,[`show`,`message`,`variant`]),P.value?(p(),f(`div`,pt,[d(`div`,mt,[d(`div`,ht,[i[79]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-r from-secondary/20 via-primary/20 to-accent-purple/20`},null,-1),i[80]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-br from-transparent via-white/5 to-transparent`},null,-1),d(`div`,gt,[d(`div`,_t,[i[75]||=r(`
`,1),d(`div`,null,[i[74]||=d(`h2`,{class:`text-2xl font-bold text-content-primary dark:text-content-primary mb-1`},` Room Messages `,-1),d(`p`,vt,[i[73]||=d(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`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),d(`span`,yt,l(F.value),1)])])]),d(`div`,bt,[d(`button`,{onClick:i[20]||=e=>G.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`},[i[76]||=d(`svg`,{class:`w-4 h-4 group-hover:scale-110 transition-transform`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`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),i[77]||=d(`span`,{class:`hidden sm:inline`},`Sessions`,-1),d(`span`,xt,l(W.value.length),1)]),d(`button`,{onClick:On,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`},[...i[78]||=[d(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])])])]),d(`div`,St,[R.value&&L.value.length===0?(p(),f(`div`,Ct,[...i[81]||=[d(`div`,{class:`text-center`},[d(`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`}),d(`div`,{class:`text-content-secondary dark:text-content-primary/70`},` Loading messages... `)],-1)]])):z.value?(p(),f(`div`,wt,[d(`div`,Tt,[i[82]||=d(`div`,{class:`text-red-600 dark:text-red-400 mb-2`},`Failed to load messages`,-1),d(`div`,Et,l(z.value),1),d(`button`,{onClick:i[21]||=e=>Q(!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 `)])])):L.value.length>0?(p(),f(`div`,Dt,[(p(!0),f(u,null,e(L.value,(e,t)=>(p(),f(`div`,{key:e.id||t,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`},[i[87]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-r from-secondary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity`},null,-1),d(`div`,Ot,[d(`div`,kt,[d(`div`,At,[d(`div`,jt,[i[84]||=d(`div`,{class:`w-6 h-6 rounded-full bg-gradient-to-br from-primary/30 to-secondary/30 flex items-center justify-center`},[d(`svg`,{class:`w-3 h-3 text-content-secondary dark:text-content-primary/70`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`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),e.author_name?(p(),f(`span`,Mt,l(e.author_name),1)):o(``,!0),e.author_pubkey?(p(),f(`span`,Nt,l(e.author_pubkey.substring(0,8))+`... `,1)):(p(),f(`span`,Pt,` Anonymous `)),i[85]||=d(`span`,{class:`text-content-muted dark:text-content-muted/60 text-xs`},`•`,-1),d(`span`,Ft,[i[83]||=d(`svg`,{class:`w-3 h-3`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`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),c(` `+l(kn(e.timestamp)),1)]),e.id?(p(),f(`span`,It,` #`+l(e.id),1)):o(``,!0)])]),d(`div`,Lt,l(e.message_text),1)]),d(`button`,{onClick:t=>Dn(e.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`},[...i[86]||=[d(`svg`,{class:`w-4 h-4 group-hover/delete:rotate-12 transition-transform`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`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,Rt)])]))),128)),U.value&&!R.value?(p(),f(`div`,zt,[d(`button`,{onClick:En,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`},[...i[88]||=[d(`svg`,{class:`w-4 h-4 group-hover:translate-y-1 transition-transform`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M19 9l-7 7-7-7`})],-1),c(` Load More Messages `,-1)]])])):R.value?(p(),f(`div`,Bt,[...i[89]||=[d(`div`,{class:`flex items-center justify-center gap-2 text-content-secondary dark:text-content-muted text-sm`},[d(`div`,{class:`animate-spin w-4 h-4 border-2 border-stroke-subtle dark:border-stroke/20 border-t-primary rounded-full`}),c(` Loading... `)],-1)]])):o(``,!0)])):(p(),f(`div`,Vt,[...i[90]||=[r(`

No messages yet

Be the first to start the conversation

`,1)]]))]),d(`div`,Ht,[i[93]||=d(`div`,{class:`absolute inset-0 bg-gradient-to-t from-primary/5 to-transparent pointer-events-none`},null,-1),d(`div`,Ut,[d(`div`,Wt,[d(`div`,Gt,[a(d(`textarea`,{"onUpdate:modelValue":i[22]||=e=>B.value=e,onKeydown:[v(_($,[`ctrl`]),[`enter`]),v(_($,[`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,Kt),[[g,B.value]])]),d(`button`,{onClick:$,disabled:!B.value.trim(),class:n([`group px-6 py-3 rounded-[12px] transition-all duration-200 flex items-center justify-center gap-2 font-medium`,B.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`])},[...i[91]||=[d(`svg`,{class:`w-5 h-5 group-hover:translate-x-1 transition-transform`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 19l9 2-9-18-9 18 9-2zm0 0v-8`})],-1),d(`span`,{class:`hidden sm:inline`},`Send`,-1)]],10,qt)]),i[92]||=d(`p`,{class:`text-content-secondary dark:text-content-muted/60 text-xs flex items-center gap-2`},[d(`svg`,{class:`w-3 h-3`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`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`})]),c(` Press Ctrl+Enter to send message quickly `)],-1)])])])])):o(``,!0),G.value?(p(),f(`div`,Jt,[d(`div`,Yt,[d(`div`,Xt,[d(`div`,null,[i[95]||=d(`h2`,{class:`text-xl font-bold text-content-primary dark:text-content-primary`},` Active Sessions `,-1),d(`p`,Zt,[i[94]||=c(` Room: `,-1),d(`span`,Qt,l(F.value),1)])]),d(`button`,{onClick:i[23]||=e=>G.value=!1,class:`text-content-secondary dark:text-content-primary/70 hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...i[96]||=[d(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[d(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])]),d(`div`,b,[W.value.length===0?(p(),f(`div`,$t,[...i[97]||=[d(`div`,{class:`text-content-secondary dark:text-content-muted`},`No active sessions found`,-1)]])):o(``,!0),(p(!0),f(u,null,e(W.value,(e,t)=>(p(),f(`div`,{key:e.public_key_full||t,class:`glass-card backdrop-blur-xl rounded-[10px] p-4 border border-stroke-subtle dark:border-white/10`},[d(`div`,en,[d(`div`,tn,[d(`div`,nn,[d(`span`,rn,l(e.identity_name||`Unknown`),1),d(`span`,{class:n([`px-2 py-0.5 text-xs font-medium rounded`,e.permissions===`admin`?`bg-accent-green/20 text-accent-green`:`bg-secondary/20 text-secondary`])},l(e.permissions),3)]),d(`div`,an,[d(`span`,on,l(e.identity_type),1),d(`button`,{onClick:t=>An(e.public_key_full,e.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,sn)])]),d(`div`,cn,[d(`div`,ln,[i[98]||=d(`span`,{class:`text-content-secondary dark:text-content-muted`},`Short Key:`,-1),d(`code`,un,l(e.public_key),1)]),d(`div`,dn,[i[99]||=d(`span`,{class:`text-content-secondary dark:text-content-muted`},`Full Key:`,-1),d(`code`,fn,l(e.public_key_full),1)])]),d(`div`,pn,[d(`div`,mn,[e.address?(p(),f(`span`,hn,`📍 `+l(e.address),1)):o(``,!0),e.last_login_success?(p(),f(`span`,gn,`Last Login: `+l(new Date(e.last_login_success*1e3).toLocaleString()),1)):o(``,!0)]),e.last_activity?(p(),f(`span`,_n,`Active: `+l(Math.floor((Date.now()/1e3-e.last_activity)/60))+`m ago`,1)):o(``,!0)])])]))),128))])])])):o(``,!0)],64))}});export{x as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Sessions-DhR0b50N.js b/repeater/web/html/assets/Sessions-DhR0b50N.js new file mode 100644 index 0000000..7b41131 --- /dev/null +++ b/repeater/web/html/assets/Sessions-DhR0b50N.js @@ -0,0 +1 @@ +import{E as e,S as t,dt as n,g as r,j as ee,l as i,o as a,p as te,pt as o,r as s,s as c,u as l,w as u,z as d}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t as f}from"./api-CbM6k1ZB.js";import{d as ne}from"./index-BFltqMtv.js";var re={class:`p-6 space-y-6`},ie={key:0,class:`grid grid-cols-1 md:grid-cols-4 gap-4`},ae={class:`glass-card rounded-[15px] p-4`},oe={class:`text-2xl font-bold text-content-primary dark:text-content-primary`},se={class:`glass-card rounded-[15px] p-4`},ce={class:`text-2xl font-bold text-cyan-500 dark:text-primary`},le={class:`glass-card rounded-[15px] p-4`},ue={class:`text-2xl font-bold text-green-700 dark:text-green-500 dark:text-accent-green`},de={class:`glass-card rounded-[15px] p-4`},fe={class:`text-2xl font-bold text-yellow-500 dark:text-secondary`},pe={class:`glass-card rounded-[15px] p-6`},me={class:`flex flex-wrap border-b border-stroke-subtle dark:border-stroke/10 mb-6`},he=[`onClick`],p={class:`flex items-center gap-2`},m={key:0,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={key:1,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},g={key:2,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},_={class:`min-h-[400px]`},v={key:0,class:`flex items-center justify-center py-12`},y={key:1,class:`flex items-center justify-center py-12`},b={class:`text-center`},x={class:`text-content-secondary dark:text-content-muted text-sm mb-4`},S={key:2,class:`space-y-4`},C={key:0,class:`text-center py-12 text-content-secondary dark:text-content-muted`},w={key:1,class:`space-y-4`},T={class:`flex items-start justify-between`},E={class:`flex-1 min-w-0`},D={class:`flex items-center gap-2 flex-wrap mb-3`},O={class:`text-lg font-semibold text-content-primary dark:text-content-primary truncate`},k={class:`flex flex-wrap items-center gap-x-4 gap-y-2 text-sm`},A={key:0,class:`flex items-center gap-1.5`},j={class:`text-content-secondary dark:text-content-muted`},M={key:1,class:`flex items-center gap-1.5`},N={class:`text-content-secondary dark:text-content-muted`},P={key:2,class:`text-content-secondary dark:text-content-muted font-mono text-xs`},F={key:3,class:`text-content-muted dark:text-content-muted font-mono text-xs`},I={key:0,class:`text-content-muted dark:text-content-muted text-xs mt-2 mb-0`},L={class:`grid grid-cols-2 md:grid-cols-4 gap-4 mt-4`},R={class:`text-content-primary dark:text-content-primary font-medium`},ge={class:`text-cyan-500 dark:text-primary font-medium`},z={class:`mt-3 flex items-center gap-2`},_e={key:3,class:`space-y-4`},ve={key:0,class:`text-center py-12 text-content-secondary dark:text-content-muted`},ye={key:1,class:`overflow-x-auto`},be={class:`w-full`},xe={class:`py-3`},Se={class:`font-mono text-sm text-content-primary dark:text-content-primary`},Ce={class:`py-3`},we={class:`font-mono text-xs text-content-secondary dark:text-content-muted`},Te={class:`py-3`},Ee={class:`text-sm text-content-primary dark:text-content-primary`},De={class:`text-xs text-content-muted dark:text-content-muted`},Oe={class:`py-3`},ke={class:`py-3`},Ae={class:`text-sm text-content-secondary dark:text-content-muted`},je={class:`py-3`},Me=[`onClick`],Ne={key:4,class:`space-y-4`},Pe={class:`mb-4`},Fe=[`value`],Ie={key:0,class:`text-center py-12 text-content-secondary dark:text-content-muted`},Le={key:1,class:`grid grid-cols-1 gap-4`},Re={class:`flex items-start justify-between`},ze={class:`flex-1`},Be={class:`flex items-center gap-3 mb-3`},Ve={class:`text-content-primary dark:text-content-primary font-mono text-sm`},He={class:`grid grid-cols-1 md:grid-cols-2 gap-3 text-sm`},Ue={class:`text-content-primary dark:text-content-primary/90 font-mono ml-2`},We={class:`text-content-primary dark:text-content-primary/90 ml-2`},Ge={class:`text-content-primary dark:text-content-primary/90 ml-2`},Ke={class:`text-content-primary dark:text-content-primary/90 ml-2`},qe=[`onClick`],Je={class:`flex justify-end`},Ye=[`disabled`],B=r({name:`SessionsView`,__name:`Sessions`,setup(r){let B=d(`overview`),V=d(!1),H=d(!1),U=d(null),W=d(null),G=d([]),K=d(null),q=d(null),Xe=[{id:`overview`,label:`Overview`,icon:`overview`},{id:`clients`,label:`Authenticated Clients`,icon:`clients`},{id:`identities`,label:`By Identity`,icon:`identities`}];t(async()=>{await J(),V.value=!0});async function J(){H.value=!0,U.value=null;try{let e=await f.getACLInfo();e.success&&(W.value=e.data);let t=await f.getACLClients();t.success&&t.data&&(G.value=t.data.clients||[]);let n=await f.getACLStats();n.success&&(K.value=n.data)}catch(e){U.value=e instanceof Error?e.message:`Failed to load ACL data`,console.error(`Error fetching ACL data:`,e)}finally{H.value=!1}}async function Y(e,t){if(confirm(`Are you sure you want to remove this client from the ACL?`))try{let n=await f.removeACLClient({public_key:e,identity_hash:t});n.success?await J():alert(`Failed to remove client: ${n.error}`)}catch(e){alert(`Error removing client: ${e}`)}}function X(e){return e?new Date(e*1e3).toLocaleString():`Never`}function Ze(e){B.value=e}let Z=a(()=>q.value?G.value.filter(e=>e.identity_name===q.value):G.value),Q=a(()=>W.value&&W.value.acls||[]);function Qe(e){return e?.type===`companion`}function $e(e){return e===`repeater`?`bg-cyan-500/20 dark:bg-primary/20 text-cyan-700 dark:text-primary`:e===`companion`?`bg-violet-500/20 dark:bg-violet-400/20 text-violet-700 dark:text-violet-300`:`bg-yellow-100 dark:bg-yellow-500/20 dark:bg-secondary/20 text-yellow-700 dark:text-secondary`}function $(e){return e==null?`N/A`:typeof e==`boolean`?e?`✓`:`✗`:String(e)}return(t,r)=>(u(),l(`div`,re,[r[22]||=c(`div`,null,[c(`h1`,{class:`text-2xl font-bold text-content-primary dark:text-content-primary`},` Sessions & Access Control `),c(`p`,{class:`text-content-secondary dark:text-content-muted mt-2`},` Manage authenticated clients and access control lists `),c(`p`,{class:`text-content-muted dark:text-content-muted text-sm mt-1`},` Repeater, room servers, and companion identities; companions do not accept client logins. `)],-1),K.value?(u(),l(`div`,ie,[c(`div`,ae,[r[1]||=c(`div`,{class:`text-content-secondary dark:text-content-muted text-sm mb-1`},` Total Identities `,-1),c(`div`,oe,o(K.value.total_identities),1)]),c(`div`,se,[r[2]||=c(`div`,{class:`text-content-secondary dark:text-content-muted text-sm mb-1`},` Authenticated Clients `,-1),c(`div`,ce,o(K.value.total_clients),1)]),c(`div`,le,[r[3]||=c(`div`,{class:`text-content-secondary dark:text-content-muted text-sm mb-1`},`Admin Clients`,-1),c(`div`,ue,o(K.value.admin_clients),1)]),c(`div`,de,[r[4]||=c(`div`,{class:`text-content-secondary dark:text-content-muted text-sm mb-1`},`Guest Clients`,-1),c(`div`,fe,o(K.value.guest_clients),1)])])):i(``,!0),c(`div`,pe,[c(`div`,me,[(u(),l(s,null,e(Xe,e=>c(`button`,{key:e.id,onClick:t=>Ze(e.id),class:n([`px-4 py-2 text-sm font-medium transition-colors duration-200 border-b-2 mr-6 mb-2`,B.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`])},[c(`div`,p,[e.icon===`overview`?(u(),l(`svg`,m,[...r[5]||=[c(`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`?(u(),l(`svg`,h,[...r[6]||=[c(`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`?(u(),l(`svg`,g,[...r[7]||=[c(`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)]])):i(``,!0),te(` `+o(e.label),1)])],10,he)),64))]),c(`div`,_,[H.value&&!V.value?(u(),l(`div`,v,[...r[8]||=[c(`div`,{class:`text-center`},[c(`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`}),c(`div`,{class:`text-content-secondary dark:text-content-muted`},`Loading ACL data...`)],-1)]])):U.value?(u(),l(`div`,y,[c(`div`,b,[r[9]||=c(`div`,{class:`text-red-500 dark:text-red-400 mb-2`},`Failed to load ACL data`,-1),c(`div`,x,o(U.value),1),c(`button`,{onClick:J,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 `)])])):B.value===`overview`?(u(),l(`div`,S,[Q.value.length===0?(u(),l(`div`,C,` No identities configured `)):(u(),l(`div`,w,[(u(!0),l(s,null,e(Q.value,e=>(u(),l(`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`},[c(`div`,T,[c(`div`,E,[c(`div`,D,[c(`h3`,O,o(e.name),1),c(`span`,{class:n([`px-2 py-0.5 text-xs font-medium rounded shrink-0`,$e(e.type)])},o(e.type),3)]),Qe(e)?(u(),l(s,{key:0},[c(`div`,k,[e.registered===void 0?i(``,!0):(u(),l(`span`,A,[c(`span`,{class:n([`w-2 h-2 rounded-full shrink-0`,e.registered?`bg-accent-green`:`bg-accent-red`]),"aria-hidden":``},null,2),c(`span`,j,`Registered: `+o(e.registered?`Active`:`Inactive`),1)])),e.active===void 0?i(``,!0):(u(),l(`span`,M,[c(`span`,{class:n([`w-2 h-2 rounded-full shrink-0`,e.active?`bg-accent-green`:`bg-accent-red`]),"aria-hidden":``},null,2),c(`span`,N,`Bridge: `+o(e.active?`Connected`:`Disconnected`),1)])),e.client_ip?(u(),l(`span`,P,` Client: `+o(e.client_ip),1)):i(``,!0),e.hash?(u(),l(`span`,F,` Hash: `+o(e.hash),1)):i(``,!0)]),e.last_seen==null?i(``,!0):(u(),l(`p`,I,` Last seen: `+o(X(e.last_seen)),1))],64)):(u(),l(s,{key:1},[c(`div`,L,[c(`div`,null,[r[10]||=c(`div`,{class:`text-content-secondary dark:text-content-muted text-xs mb-1`},` Max Clients `,-1),c(`div`,R,o($(e.max_clients)),1)]),c(`div`,null,[r[11]||=c(`div`,{class:`text-content-secondary dark:text-content-muted text-xs mb-1`},` Authenticated `,-1),c(`div`,ge,o($(e.authenticated_clients)),1)]),c(`div`,null,[r[12]||=c(`div`,{class:`text-content-secondary dark:text-content-muted text-xs mb-1`},` Admin Password `,-1),c(`div`,{class:n(e.has_admin_password?`text-green-700 dark:text-green-500 dark:text-accent-green`:`text-red-500 dark:text-accent-red`)},o(e.has_admin_password==null?`N/A`:e.has_admin_password?`✓ Set`:`✗ Not Set`),3)]),c(`div`,null,[r[13]||=c(`div`,{class:`text-content-secondary dark:text-content-muted text-xs mb-1`},` Guest Password `,-1),c(`div`,{class:n(e.has_guest_password?`text-green-700 dark:text-green-500 dark:text-accent-green`:`text-red-500 dark:text-accent-red`)},o(e.has_guest_password==null?`N/A`:e.has_guest_password?`✓ Set`:`✗ Not Set`),3)])]),c(`div`,z,[r[14]||=c(`span`,{class:`text-content-secondary dark:text-content-muted text-xs`},`Read-Only Access:`,-1),c(`span`,{class:n(e.allow_read_only?`text-green-700 dark:text-green-500 dark:text-accent-green`:`text-red-500 dark:text-accent-red`)},o(e.allow_read_only==null?`N/A`:e.allow_read_only?`Allowed`:`Disabled`),3)])],64))])])]))),128))]))])):B.value===`clients`?(u(),l(`div`,_e,[G.value.length===0?(u(),l(`div`,ve,` No authenticated clients `)):(u(),l(`div`,ye,[c(`table`,be,[r[15]||=c(`thead`,null,[c(`tr`,{class:`border-b border-stroke-subtle dark:border-stroke/10`},[c(`th`,{class:`text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3`},` Client `),c(`th`,{class:`text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3`},` Address `),c(`th`,{class:`text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3`},` Identity `),c(`th`,{class:`text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3`},` Permissions `),c(`th`,{class:`text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3`},` Last Activity `),c(`th`,{class:`text-left text-content-secondary dark:text-content-muted text-sm font-medium pb-3`},` Actions `)])],-1),c(`tbody`,null,[(u(!0),l(s,null,e(G.value,e=>(u(),l(`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`},[c(`td`,xe,[c(`div`,Se,o(e.public_key),1)]),c(`td`,Ce,[c(`div`,we,o(e.address),1)]),c(`td`,Te,[c(`div`,Ee,o(e.identity_name),1),c(`div`,De,o(e.identity_hash),1)]),c(`td`,Oe,[c(`span`,{class:n([`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`])},o(e.permissions),3)]),c(`td`,ke,[c(`div`,Ae,o(X(e.last_activity)),1)]),c(`td`,je,[c(`button`,{onClick:t=>Y(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,Me)])]))),128))])])]))])):B.value===`identities`?(u(),l(`div`,Ne,[c(`div`,Pe,[r[17]||=c(`label`,{class:`block text-content-secondary dark:text-content-muted text-sm mb-2`},`Filter by Identity`,-1),ee(c(`select`,{"onUpdate:modelValue":r[0]||=e=>q.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`},[r[16]||=c(`option`,{value:null},`All Identities`,-1),(u(!0),l(s,null,e(Q.value,e=>(u(),l(`option`,{key:e.name,value:e.name},o(e.name)+` (`+o(e.authenticated_clients??0)+` clients) `,9,Fe))),128))],512),[[ne,q.value]])]),Z.value.length===0?(u(),l(`div`,Ie,` No clients for selected identity `)):(u(),l(`div`,Le,[(u(!0),l(s,null,e(Z.value,e=>(u(),l(`div`,{key:e.public_key_full,class:`glass-card rounded-[10px] p-4 border border-stroke-subtle dark:border-white/10`},[c(`div`,Re,[c(`div`,ze,[c(`div`,Be,[c(`span`,{class:n([`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`])},o(e.permissions),3),c(`span`,Ve,o(e.public_key),1)]),c(`div`,He,[c(`div`,null,[r[18]||=c(`span`,{class:`text-content-secondary dark:text-content-muted`},`Address:`,-1),c(`span`,Ue,o(e.address),1)]),c(`div`,null,[r[19]||=c(`span`,{class:`text-content-secondary dark:text-content-muted`},`Identity:`,-1),c(`span`,We,o(e.identity_name)+` (`+o(e.identity_hash)+`)`,1)]),c(`div`,null,[r[20]||=c(`span`,{class:`text-content-secondary dark:text-content-muted`},`Last Activity:`,-1),c(`span`,Ge,o(X(e.last_activity)),1)]),c(`div`,null,[r[21]||=c(`span`,{class:`text-content-secondary dark:text-content-muted`},`Last Login:`,-1),c(`span`,Ke,o(X(e.last_login_success)),1)])])]),c(`button`,{onClick:t=>Y(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,qe)])]))),128))]))])):i(``,!0)])]),c(`div`,Je,[c(`button`,{onClick:J,disabled:H.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`},o(H.value?`Refreshing...`:`Refresh Data`),9,Ye)])]))}});export{B as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Setup-DiRq9fgD.css b/repeater/web/html/assets/Setup-DiRq9fgD.css new file mode 100644 index 0000000..3a9839e --- /dev/null +++ b/repeater/web/html/assets/Setup-DiRq9fgD.css @@ -0,0 +1 @@ +.glass-card[data-v-a201f2f2]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:#ffffff0d;border:1px solid #ffffff1a}.modal-enter-active[data-v-a201f2f2],.modal-leave-active[data-v-a201f2f2]{transition:opacity .3s}.modal-enter-from[data-v-a201f2f2],.modal-leave-to[data-v-a201f2f2]{opacity:0}.modal-enter-active .glass-card[data-v-a201f2f2],.modal-leave-active .glass-card[data-v-a201f2f2]{transition:transform .3s}.modal-enter-from .glass-card[data-v-a201f2f2],.modal-leave-to .glass-card[data-v-a201f2f2]{transform:scale(.9)}.slide-enter-active[data-v-a201f2f2],.slide-leave-active[data-v-a201f2f2]{transition:all .3s}.slide-enter-from[data-v-a201f2f2],.slide-leave-to[data-v-a201f2f2]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-a201f2f2{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.6;transform:translate(20px,-20px)scale(1.05)rotate(-24.22deg)}}@keyframes float-slower-a201f2f2{0%,to{opacity:.75;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.5;transform:translate(-30px,20px)scale(1.08)rotate(-24.22deg)}}@keyframes float-slowest-a201f2f2{0%,to{opacity:.8;transform:translate(0)scale(1)rotate(-24.22deg)}50%{opacity:.55;transform:translate(25px,25px)scale(1.1)rotate(-24.22deg)}}.animate-pulse-slow[data-v-a201f2f2]{will-change:transform, opacity;animation:15s ease-in-out infinite float-slow-a201f2f2}.animate-pulse-slower[data-v-a201f2f2]{will-change:transform, opacity;animation:18s ease-in-out infinite float-slower-a201f2f2}.animate-pulse-slowest[data-v-a201f2f2]{will-change:transform, opacity;animation:20s ease-in-out infinite float-slowest-a201f2f2} diff --git a/repeater/web/html/assets/Setup-DvdSE7ue.js b/repeater/web/html/assets/Setup-DvdSE7ue.js new file mode 100644 index 0000000..fd7586e --- /dev/null +++ b/repeater/web/html/assets/Setup-DvdSE7ue.js @@ -0,0 +1 @@ +import{A as e,E as t,K as n,S as r,dt as i,f as a,ft as o,g as s,j as c,l,m as u,o as d,p as f,pt as p,r as m,s as h,u as g,w as _,x as v,z as y}from"./runtime-core.esm-bundler-HnidnMFy.js";import{i as b,o as x}from"./vue-router-Cr0wB7EX.js";import{t as S}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{c as C,d as w,f as T,h as ee,t as te}from"./index-BFltqMtv.js";var ne=x(`setup`,()=>{let e=y(1),t=y(5),n=y(`pyRpt${Math.floor(Math.random()*1e4).toString().padStart(4,`0`)}`),r=y(null),i=y(null),a=y(``),o=y(``),s=y(!1),c=y({frequency:`915.0`,spreading_factor:`7`,bandwidth:`125`,coding_rate:`5`}),l=y([]),u=y([]),f=y(!1),p=y(!1),m=y(null),h=d(()=>{switch(e.value){case 1:return!0;case 2:return n.value.trim().length>0;case 3:return r.value!==null;case 4:return s.value?c.value.frequency&&c.value.spreading_factor&&c.value.bandwidth&&c.value.coding_rate:i.value!==null;case 5:return a.value.length>=6&&a.value===o.value;default:return!1}}),g=d(()=>e.value>1),_=d(()=>e.value===t.value);async function v(){f.value=!0,m.value=null;try{let e=await(await fetch(`/api/hardware_options`)).json();if(e.error)throw Error(e.error);l.value=e.hardware||[]}catch(e){m.value=e instanceof Error?e.message:`Failed to load hardware options`,console.error(`Error fetching hardware options:`,e)}finally{f.value=!1}}async function b(){f.value=!0,m.value=null;try{let e=await(await fetch(`/api/radio_presets`)).json();if(e.error)throw Error(e.error);u.value=e.presets||[]}catch(e){m.value=e instanceof Error?e.message:`Failed to load radio presets`,console.error(`Error fetching radio presets:`,e)}finally{f.value=!1}}async function x(){if(!h.value)return{success:!1,error:`Please complete all required fields`};p.value=!0,m.value=null;try{let e=s.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}:i.value,t=await(await fetch(`/api/setup_wizard`,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify({node_name:n.value.trim(),hardware_key:r.value?.key,radio_preset:e,admin_password:a.value})})).json();if(!t.success)throw Error(t.error||`Setup failed`);return{success:!0,data:t}}catch(e){let t=e instanceof Error?e.message:`Failed to complete setup`;return m.value=t,{success:!1,error:t}}finally{p.value=!1}}function S(){h.value&&e.value=1&&n<=t.value&&(e.value=n)}function T(){e.value=1,n.value=`pyRpt${Math.floor(Math.random()*1e4).toString().padStart(4,`0`)}`,r.value=null,i.value=null,s.value=!1,c.value={frequency:`915.0`,spreading_factor:`7`,bandwidth:`125`,coding_rate:`5`},a.value=``,o.value=``,m.value=null}return{currentStep:e,totalSteps:t,nodeName:n,selectedHardware:r,selectedRadioPreset:i,useCustomRadio:s,customRadio:c,adminPassword:a,confirmPassword:o,hardwareOptions:l,radioPresets:u,isLoading:f,isSubmitting:p,error:m,canGoNext:h,canGoBack:g,isLastStep:_,fetchHardwareOptions:v,fetchRadioPresets:b,completeSetup:x,nextStep:S,previousStep:C,goToStep:w,reset:T}}),re={class:`min-h-screen bg-background dark:bg-background overflow-hidden relative flex items-center justify-center p-4`},ie={class:`absolute top-4 right-4 z-20`},ae={class:`w-full max-w-4xl relative z-10`},oe={class:`mb-8`},se={class:`flex justify-between mb-2`},ce={class:`text-content-secondary dark:text-content-muted text-sm`},E={class:`text-content-secondary dark:text-content-muted text-sm`},D={class:`h-2 bg-stroke-subtle dark:bg-stroke/10 rounded-full overflow-hidden`},O={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`},k={class:`flex justify-center mb-8`},A={class:`flex gap-2`},j={class:`mb-8`},M={class:`text-2xl sm:text-3xl font-bold text-content-primary dark:text-content-primary mb-2 text-center`},N={key:0,class:`space-y-6 mt-8`},P={key:1,class:`space-y-6 mt-8`},F={class:`max-w-md mx-auto`},I={key:2,class:`space-y-6 mt-8`},L={key:0,class:`text-center text-content-secondary dark:text-content-muted`},R={key:1,class:`text-center text-content-secondary dark:text-content-muted`},z={key:2,class:`grid grid-cols-1 md:grid-cols-2 gap-4 max-w-3xl mx-auto`},B=[`onClick`],V={class:`font-medium text-content-primary dark:text-content-primary mb-1`},H={class:`text-sm text-content-secondary dark:text-content-muted`},U={key:3,class:`space-y-6 mt-8`},W={key:0,class:`text-center text-content-secondary dark:text-content-muted`},G={key:1,class:`text-center text-content-secondary dark:text-content-muted`},le={key:2,class:`max-w-5xl mx-auto`},ue={class:`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-4`},de=[`onClick`],fe={class:`relative z-10`},pe={class:`font-medium text-content-primary dark:text-content-primary mb-1 flex items-start justify-between gap-2`},me={class:`flex items-center gap-2`},he={class:`text-2xl`},ge={key:0,class:`text-primary flex-shrink-0`},_e={class:`text-xs text-content-secondary dark:text-content-muted mb-3`},ve={class:`grid grid-cols-2 gap-2 text-xs`},ye={class:`bg-gray-50 dark:bg-white/5 rounded px-2 py-1`},be={class:`text-content-primary dark:text-content-primary/80 font-medium`},xe={class:`bg-gray-50 dark:bg-white/5 rounded px-2 py-1`},Se={class:`text-content-primary dark:text-content-primary/80 font-medium`},Ce={class:`bg-gray-50 dark:bg-white/5 rounded px-2 py-1`},K={class:`text-content-primary dark:text-content-primary/80 font-medium`},we={class:`bg-gray-50 dark:bg-white/5 rounded px-2 py-1`},Te={class:`text-content-primary dark:text-content-primary/80 font-medium`},Ee={class:`border-t border-stroke-subtle dark:border-stroke/10 pt-6`},De={class:`flex items-center justify-between mb-2`},Oe={key:0,class:`text-primary`},ke={key:0,class:`mt-4 grid grid-cols-2 gap-4`},Ae={key:4,class:`space-y-6 mt-8`},je={class:`max-w-md mx-auto space-y-4`},Me={key:0,class:`text-red-600 dark:text-red-400 text-sm`},Ne={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`},Pe={class:`flex justify-between gap-4`},Fe={key:1},Ie=[`disabled`],Le={key:0,class:`w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin`},Re={key:1},ze={key:2},Be={key:3},Ve={key:4,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},He={class:`flex justify-center mb-6`},Ue={key:0,class:`w-16 h-16 rounded-full bg-green-100 dark:bg-green-500/20 flex items-center justify-center`},We={key:1,class:`w-16 h-16 rounded-full bg-red-100 dark:bg-red-500/20 flex items-center justify-center`},Ge={class:`text-2xl font-bold text-content-primary dark:text-content-primary text-center mb-4`},Ke={class:`text-content-secondary dark:text-content-primary/70 text-center mb-6`},q=S(s({name:`SetupView`,__name:`Setup`,setup(s){let x=ne(),S=b(),q=y(!1),J=y(``),Y=y(``),X=y(`success`),Z=null,qe=e=>{let t=e.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`)?`🇻🇳`:`🌍`};r(async()=>{await Promise.all([x.fetchHardwareOptions(),x.fetchRadioPresets()])});let Q=d(()=>x.currentStep/x.totalSteps*100);async function Je(){if(x.isLastStep){let e=await x.completeSetup();e.success?(X.value=`success`,J.value=`Setup Complete!`,Y.value=`Your repeater has been configured successfully. The service is restarting now...`,q.value=!0,Xe()):(X.value=`error`,J.value=`Setup Failed`,Y.value=e.error||`An unknown error occurred`,q.value=!0)}else x.nextStep()}function Ye(){x.previousStep()}function $(){q.value=!1,X.value===`success`&&(Z||S.push(`/login`))}function Xe(){let e=0;function t(){e++,fetch(`/api/status`,{method:`GET`}).then(e=>{e.ok?(Z=null,q.value=!1,S.push(`/login`)):n()}).catch(()=>{n()})}function n(){e<30?Z=setTimeout(t,1e3):(Z=null,q.value=!1,S.push(`/login`))}Z=setTimeout(t,2e3)}v(()=>{Z&&=(clearTimeout(Z),null)});let Ze=[`Welcome`,`Repeater Name`,`Hardware Selection`,`Radio Configuration`,`Security Setup`];return(r,s)=>(_(),g(`div`,re,[h(`div`,ie,[u(te)]),s[36]||=h(`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),s[37]||=h(`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),s[38]||=h(`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),h(`div`,ae,[h(`div`,oe,[h(`div`,se,[h(`span`,ce,`Step `+p(n(x).currentStep)+` of `+p(n(x).totalSteps),1),h(`span`,E,p(Math.round(Q.value))+`% Complete`,1)]),h(`div`,D,[h(`div`,{class:`h-full bg-gradient-to-r from-primary to-primary/80 transition-all duration-500`,style:o({width:`${Q.value}%`})},null,4)])]),h(`div`,O,[h(`div`,k,[h(`div`,A,[(_(!0),g(m,null,t(n(x).totalSteps,e=>(_(),g(`div`,{key:e,class:i([`w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium transition-all`,e===n(x).currentStep?`bg-primary text-white`:e

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)]])):n(x).currentStep===2?(_(),g(`div`,P,[s[12]||=h(`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),h(`div`,F,[s[10]||=h(`label`,{class:`block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2`},`Repeater Name`,-1),c(h(`input`,{"onUpdate:modelValue":s[0]||=e=>n(x).nodeName=e,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),[[T,n(x).nodeName]]),s[11]||=h(`p`,{class:`text-content-secondary dark:text-content-muted text-xs mt-2`},` Use letters, numbers, hyphens, or underscores (3-32 characters) `,-1)])])):n(x).currentStep===3?(_(),g(`div`,I,[s[13]||=h(`p`,{class:`text-content-secondary dark:text-content-primary/70 text-center mb-6`},` Select your hardware board type `,-1),n(x).isLoading?(_(),g(`div`,L,` Loading hardware options... `)):n(x).hardwareOptions.length===0?(_(),g(`div`,R,` No hardware options available `)):(_(),g(`div`,z,[(_(!0),g(m,null,t(n(x).hardwareOptions,e=>(_(),g(`button`,{key:e.key,onClick:t=>n(x).selectedHardware=e,class:i([`p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm`,n(x).selectedHardware?.key===e.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`])},[h(`div`,V,p(e.name),1),h(`div`,H,p(e.description||e.key),1)],10,B))),128))]))])):n(x).currentStep===4?(_(),g(`div`,U,[s[28]||=h(`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),n(x).isLoading?(_(),g(`div`,W,` Loading radio presets... `)):n(x).radioPresets.length===0?(_(),g(`div`,G,` No radio presets available `)):(_(),g(`div`,le,[h(`div`,ue,[(_(!0),g(m,null,t(n(x).radioPresets,e=>(_(),g(`button`,{key:e.title,onClick:t=>{n(x).selectedRadioPreset=e,n(x).useCustomRadio=!1},class:i([`p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm relative overflow-hidden`,!n(x).useCustomRadio&&n(x).selectedRadioPreset?.title===e.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`])},[h(`div`,fe,[h(`div`,pe,[h(`span`,me,[h(`span`,he,p(qe(e.title)),1),h(`span`,null,p(e.title),1)]),!n(x).useCustomRadio&&n(x).selectedRadioPreset?.title===e.title?(_(),g(`div`,ge,[...s[14]||=[h(`svg`,{class:`w-5 h-5`,fill:`currentColor`,viewBox:`0 0 20 20`},[h(`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)]])):l(``,!0)]),h(`div`,_e,p(e.description),1),h(`div`,ve,[h(`div`,ye,[s[15]||=h(`div`,{class:`text-content-muted dark:text-content-muted`},`Freq`,-1),h(`div`,be,p(e.frequency),1)]),h(`div`,xe,[s[16]||=h(`div`,{class:`text-content-muted dark:text-content-muted`},`BW`,-1),h(`div`,Se,p(e.bandwidth),1)]),h(`div`,Ce,[s[17]||=h(`div`,{class:`text-content-muted dark:text-content-muted`},`SF`,-1),h(`div`,K,p(e.spreading_factor),1)]),h(`div`,we,[s[18]||=h(`div`,{class:`text-content-muted dark:text-content-muted`},`CR`,-1),h(`div`,Te,p(e.coding_rate),1)])])])],10,de))),128))]),h(`div`,Ee,[h(`button`,{onClick:s[1]||=e=>{n(x).useCustomRadio=!n(x).useCustomRadio,n(x).useCustomRadio&&(n(x).selectedRadioPreset=null)},class:i([`w-full p-4 rounded-[12px] border transition-all duration-300 text-left backdrop-blur-sm`,n(x).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`])},[h(`div`,De,[s[20]||=h(`div`,{class:`font-medium text-content-primary dark:text-content-primary flex items-center gap-2`},[h(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[h(`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`})]),f(` Custom Configuration `)],-1),n(x).useCustomRadio?(_(),g(`div`,Oe,[...s[19]||=[h(`svg`,{class:`w-5 h-5`,fill:`currentColor`,viewBox:`0 0 20 20`},[h(`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)]])):l(``,!0)]),s[21]||=h(`div`,{class:`text-xs text-content-secondary dark:text-content-muted`},` Manually configure frequency, bandwidth, spreading factor, and coding rate `,-1)],2),u(C,{name:`slide`},{default:e(()=>[n(x).useCustomRadio?(_(),g(`div`,ke,[h(`div`,null,[s[22]||=h(`label`,{class:`block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2`},`Frequency (MHz)`,-1),c(h(`input`,{"onUpdate:modelValue":s[2]||=e=>n(x).customRadio.frequency=e,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),[[T,n(x).customRadio.frequency]])]),h(`div`,null,[s[23]||=h(`label`,{class:`block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2`},`Bandwidth (kHz)`,-1),c(h(`input`,{"onUpdate:modelValue":s[3]||=e=>n(x).customRadio.bandwidth=e,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),[[T,n(x).customRadio.bandwidth]])]),h(`div`,null,[s[25]||=h(`label`,{class:`block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2`},`Spreading Factor`,-1),c(h(`select`,{"onUpdate:modelValue":s[4]||=e=>n(x).customRadio.spreading_factor=e,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`},[...s[24]||=[h(`option`,{value:`7`},`7`,-1),h(`option`,{value:`8`},`8`,-1),h(`option`,{value:`9`},`9`,-1),h(`option`,{value:`10`},`10`,-1),h(`option`,{value:`11`},`11`,-1),h(`option`,{value:`12`},`12`,-1)]],512),[[w,n(x).customRadio.spreading_factor]])]),h(`div`,null,[s[27]||=h(`label`,{class:`block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2`},`Coding Rate`,-1),c(h(`select`,{"onUpdate:modelValue":s[5]||=e=>n(x).customRadio.coding_rate=e,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`},[...s[26]||=[h(`option`,{value:`5`},`4/5`,-1),h(`option`,{value:`6`},`4/6`,-1),h(`option`,{value:`7`},`4/7`,-1),h(`option`,{value:`8`},`4/8`,-1)]],512),[[w,n(x).customRadio.coding_rate]])])])):l(``,!0)]),_:1})])]))])):n(x).currentStep===5?(_(),g(`div`,Ae,[s[32]||=h(`p`,{class:`text-content-secondary dark:text-content-primary/70 text-center mb-6`},` Set a secure admin password to protect your repeater `,-1),h(`div`,je,[h(`div`,null,[s[29]||=h(`label`,{class:`block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2`},`Admin Password`,-1),c(h(`input`,{"onUpdate:modelValue":s[6]||=e=>n(x).adminPassword=e,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),[[T,n(x).adminPassword]])]),h(`div`,null,[s[30]||=h(`label`,{class:`block text-content-primary dark:text-content-primary/90 text-sm font-medium mb-2`},`Confirm Password`,-1),c(h(`input`,{"onUpdate:modelValue":s[7]||=e=>n(x).confirmPassword=e,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),[[T,n(x).confirmPassword]])]),n(x).adminPassword&&n(x).confirmPassword&&n(x).adminPassword!==n(x).confirmPassword?(_(),g(`div`,Me,` Passwords do not match `)):l(``,!0),s[31]||=h(`div`,{class:`bg-yellow-500/10 border border-yellow-500/30 rounded-lg p-3 text-sm text-yellow-800 dark:text-yellow-200`},[h(`strong`,null,`Important:`),f(` Remember this password - you'll need it to access the dashboard. `)],-1)])])):l(``,!0)]),n(x).error?(_(),g(`div`,Ne,p(n(x).error),1)):l(``,!0),h(`div`,Pe,[n(x).canGoBack?(_(),g(`button`,{key:0,onClick:Ye,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 `)):(_(),g(`div`,Fe)),h(`button`,{onClick:Je,disabled:!n(x).canGoNext||n(x).isSubmitting,class:i([`px-8 py-3 rounded-[12px] font-semibold transition-all duration-300 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed`,n(x).canGoNext&&!n(x).isSubmitting?`bg-primary hover:bg-primary/90 text-white border border-primary hover:border-primary/80`:`bg-background-mute dark:bg-stroke/5 text-content-muted dark:text-content-muted border border-stroke-subtle dark:border-stroke/10`])},[n(x).isSubmitting?(_(),g(`div`,Le)):l(``,!0),n(x).isSubmitting?(_(),g(`span`,Re,`Setting up...`)):n(x).isLastStep?(_(),g(`span`,ze,`Complete Setup`)):(_(),g(`span`,Be,`Next`)),!n(x).isSubmitting&&!n(x).isLastStep?(_(),g(`svg`,Ve,[...s[33]||=[h(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M9 5l7 7-7 7`},null,-1)]])):l(``,!0)],10,Ie)])])]),u(C,{name:`modal`},{default:e(()=>[q.value?(_(),g(`div`,{key:0,class:`fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm`,onClick:$},[h(`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:s[8]||=ee(()=>{},[`stop`])},[h(`div`,He,[X.value===`success`?(_(),g(`div`,Ue,[...s[34]||=[h(`svg`,{class:`w-8 h-8 text-green-600 dark:text-green-400`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[h(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`})],-1)]])):(_(),g(`div`,We,[...s[35]||=[h(`svg`,{class:`w-8 h-8 text-red-600 dark:text-red-400`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[h(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]]))]),h(`h3`,Ge,p(J.value),1),h(`p`,Ke,p(Y.value),1),h(`button`,{onClick:$,class:i([`w-full px-6 py-3 rounded-lg font-medium transition-all`,X.value===`success`?`bg-primary hover:bg-primary/90 text-white`:`bg-accent-red hover:bg-accent-red/90 text-white`])},p(X.value===`success`?`Continue to Login`:`Close`),3)])])):l(``,!0)]),_:1})]))}}),[[`__scopeId`,`data-v-a201f2f2`]]);export{q as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Statistics-CsAO5q_U.css b/repeater/web/html/assets/Statistics-CsAO5q_U.css new file mode 100644 index 0000000..692c975 --- /dev/null +++ b/repeater/web/html/assets/Statistics-CsAO5q_U.css @@ -0,0 +1 @@ +.plotly-chart[data-v-54d032e1]{background:0 0!important} diff --git a/repeater/web/html/assets/Statistics-S4HgWYku.js b/repeater/web/html/assets/Statistics-S4HgWYku.js new file mode 100644 index 0000000..e191221 --- /dev/null +++ b/repeater/web/html/assets/Statistics-S4HgWYku.js @@ -0,0 +1 @@ +import{r as e}from"./chunk-DECur_0Z.js";import{E as t,H as n,I as r,K as i,S as a,b as o,f as s,ft as c,g as l,j as u,k as d,l as f,m as p,o as m,pt as h,r as g,s as _,u as v,w as y,x as ee,z as b}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t as x}from"./api-CbM6k1ZB.js";import{t as te}from"./packets-C-dzvp0W.js";import{t as ne}from"./websocket-nXR7EYbj.js";import{t as S}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{a as re,d as ie}from"./index-BFltqMtv.js";import{t as C}from"./plotly.min-Dl7ekyci.js";import{n as ae,t as oe}from"./preferences-Bv8i60GL.js";import{_ as se,a as w,c as ce,d as le,f as ue,g as de,h as fe,i as pe,l as me,m as he,n as ge,o as _e,r as ve,s as ye,t as be,u as xe}from"./chart-B1uYMRrx.js";import{t as T}from"./chartjs-adapter-date-fns.esm-DnBoPdP1.js";var E=e(C(),1),Se={class:`p-3 sm:p-6 space-y-4 sm:space-y-6`},Ce={class:`flex flex-col sm:flex-row sm:justify-between sm:items-center gap-3`},we={class:`flex items-center gap-2 sm:gap-3`},Te=[`value`],Ee={class:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4`},De={class:`glass-card rounded-[15px] p-3 sm:p-6`},Oe={class:`relative h-40 sm:h-48 rounded-lg p-2 sm:p-4`},ke={key:0,class:`absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-xs z-20`},Ae={key:1,class:`absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 z-20`},je={class:`grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6 items-stretch`},Me={class:`glass-card rounded-[15px] p-3 sm:p-6 flex flex-col`},Ne={class:`relative flex-1 min-h-[12rem] sm:min-h-[16rem] rounded-lg`},Pe={key:0,class:`absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-xs z-20`},Fe={class:`glass-card rounded-[15px] p-3 sm:p-6 flex flex-col`},Ie={class:`flex-1 flex flex-col justify-evenly`},Le={key:0,class:`flex items-center justify-center flex-1`},Re={key:1,class:`flex items-center justify-center flex-1`},ze={class:`w-28 sm:w-32 text-sm text-content-primary dark:text-content-primary truncate`},Be={class:`flex-1 h-12 bg-background-mute dark:bg-stroke/10 rounded overflow-hidden`},Ve={class:`w-20 text-sm text-content-secondary dark:text-content-muted text-right tabular-nums`},He={key:0,class:`glass-card rounded-[15px] p-6 sm:p-8 text-center`},Ue={key:1,class:`glass-card rounded-[15px] p-6 sm:p-8 text-center`},We={class:`text-content-secondary dark:text-content-muted text-sm`},D=S(l({name:`StatisticsView`,__name:`Statistics`,setup(e){w.register(pe,me,xe,ce,ye,ge,_e,le,de,se,fe,be,ve,he,ue);let l=te(),S=ne(),C=b(!1),D=()=>{let 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)`}},O=b(oe(`statistics_selectedHours`,24)),Ge=[{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`}];d(O,e=>ae(`statistics_selectedHours`,e));let k=b(null),A=b(null),j=b([]),M=b(null),N=b([]),P=b([]),F=b(!0),I=b(null),L=b({packetRate:!0,packetType:!0,noiseFloor:!1,routePie:!0,sparklines:!0}),R=b(!1),z=b(!1),B=b(!1),V=b(null),H=b(null),U=b(null),W=b(null),G=b(null),K=b(null),q=b(null),J=m(()=>{let e=l.packetStats;return e?{totalRx:e.total_packets||0,totalTx:e.transmitted_packets||0}:{totalRx:0,totalTx:0}}),Y=(e,t)=>{if(e.length===0)return[];let n=Math.round(t*60*60*1e3/72),r=new Map;return e.forEach(([e,t])=>{let i=e;e>0x38d7ea4c68000?i=e/1e3:e>1e9&&e<0xe8d4a51000&&(i=e*1e3);let a=Math.floor(i/n)*n;r.has(a)||r.set(a,[]),r.get(a).push(t)}),Array.from(r.entries()).sort((e,t)=>e[0]-t[0]).map(([,e])=>e.reduce((e,t)=>e+t,0)/e.length)},X=m(()=>{let e=[],t=[];if(k.value?.series){let n=k.value.series.find(e=>e.type===`rx_count`),r=k.value.series.find(e=>e.type===`tx_count`);n?.data&&(e=Y(n.data,O.value)),r?.data&&(t=Y(r.data,O.value))}return{totalPackets:e,transmittedPackets:t,droppedPackets:[],crcErrors:Y(P.value.map(e=>[e.timestamp>0xe8d4a51000?e.timestamp:e.timestamp*1e3,e.count]),O.value)}}),Z=async()=>{try{F.value=!0,I.value=null,await Promise.all([l.fetchPacketStats({hours:O.value}),l.fetchSystemStats()]),F.value=!1,Ke()}catch(e){I.value=e instanceof Error?e.message:`Failed to fetch data`,F.value=!1}},Ke=async()=>{L.value={packetRate:!0,packetType:!0,noiseFloor:!0,routePie:!0,sparklines:!0};let e=[qe(),Je(),Ye(),Xe(),Ze()];try{await Promise.allSettled(e),await o(),!W.value||!G.value?setTimeout(()=>{Q()},100):Q()}catch(e){console.error(`Error loading chart data:`,e)}},qe=async()=>{try{let e=await x.get(`/metrics_graph_data`,{hours:O.value,resolution:`average`,metrics:`rx_count,tx_count`});e?.success&&(k.value=e.data)}catch{k.value=null}},Je=async()=>{try{let e=await x.get(`/packet_type_graph_data`,{hours:O.value,resolution:`average`,types:`all`});e?.success&&e.data&&(j.value=e.data.series||[])}catch{j.value=[]}},Ye=async()=>{try{let e=await x.get(`/route_stats`,{hours:O.value});e?.success&&e.data&&(M.value=e.data)}catch{M.value=null}},Xe=async()=>{try{let e={hours:O.value},t=await x.get(`/noise_floor_history`,e);if(t.success&&t.data){let e=t.data.history||[];Array.isArray(e)&&e.length>0&&(A.value={chart_data:e.map(e=>({timestamp:e.timestamp||Date.now()/1e3,noise_floor_dbm:e.noise_floor_dbm||e.noise_floor||-120}))},$e())}}catch{A.value={chart_data:[]}}},Ze=async()=>{try{let e=await x.get(`/crc_error_history`,{hours:O.value});e?.success&&e.data&&(P.value=e.data.history||[])}catch{P.value=[]}},Qe=()=>{L.value={packetRate:!0,packetType:!0,noiseFloor:!0,routePie:!0,sparklines:!0},$(),R.value=!1,z.value=!1,B.value=!1,Z()},$e=()=>{N.value=[],A.value?.chart_data&&A.value.chart_data.length>0&&(N.value=A.value.chart_data.map(e=>({timestamp:e.timestamp*1e3,snr:null,rssi:null,noiseFloor:e.noise_floor_dbm})))},Q=()=>{if(!C.value){C.value=!0;try{et(),tt(),nt(),rt(),setTimeout(()=>{L.value={packetRate:!1,packetType:!1,noiseFloor:!1,routePie:!1,sparklines:!1},setTimeout(()=>{let e=n(V.value),t=n(H.value),r=n(U.value);e&&e.update(`none`),t&&t.update(`none`),r&&r.update(`none`)},50)},100)}catch(e){console.error(`Error creating/updating charts:`,e),$()}finally{C.value=!1}}},$=()=>{try{V.value&&=(V.value.destroy(),null),H.value&&=(H.value.destroy(),null),U.value&&=(U.value.destroy(),null),q.value&&E.default.purge(q.value)}catch(e){console.error(`Error destroying charts:`,e)}},et=()=>{if(!W.value)return;let e=W.value.getContext(`2d`);if(!e)return;let t=[],n=[];if(k.value?.series){let e=k.value.series.find(e=>e.type===`rx_count`),r=k.value.series.find(e=>e.type===`tx_count`);e?.data&&(t=e.data.map(([e,t])=>{let n=e;return n=e>0x38d7ea4c68000?e/1e3:e>0xe8d4a51000?e:e>1e9?e*1e3:Date.now(),{x:n,y:t}})),r?.data&&(n=r.data.map(([e,t])=>{let n=e;return n=e>0x38d7ea4c68000?e/1e3:e>0xe8d4a51000?e:e>1e9?e*1e3:Date.now(),{x:n,y:t}}))}if(t.length===0&&n.length===0){R.value=!0;return}R.value=!1,V.value&&=(V.value.destroy(),null);let i=Math.round(O.value*60*60*1e3/72),a=e=>{if(e.length===0)return[];let t=new Map;return e.forEach(e=>{let n=Math.floor(e.x/i)*i;t.has(n)||t.set(n,[]),t.get(n).push(e.y)}),Array.from(t.entries()).map(([e,t])=>({x:e,y:t.reduce((e,t)=>e+t,0)/t.length})).sort((e,t)=>e.x-t.x)},o=(e,t=3)=>{if(e.lengthe+t.y,0)/o.length;n.push({x:e[r].x,y:s})}return n},s=o(a(t)),c=o(a(n)),l=[...s.map(e=>e.y),...c.map(e=>e.y)],u=Math.min(...l),d=Math.max(...l),f=d-u||d*.1||.001,p=Math.max(0,u-f*.05),m=d+f*.05;try{let t=JSON.parse(JSON.stringify(s));V.value=r(new w(e,{type:`line`,data:{datasets:[{label:`TX/hr`,data:JSON.parse(JSON.stringify(c)),borderColor:`#F59E0B`,backgroundColor:`#F59E0B`,borderWidth:2,fill:`origin`,tension:.4,pointRadius:0,pointHoverRadius:3,order:1},{label:`RX/hr`,data:t,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(e){let t=e[0]?.parsed?.x;return t==null?``:new Date(t).toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`})},label:function(e){let t=e.dataset?.label||``,n=e.parsed?.y;return n==null?t:`${t}: ${n.toFixed(3)}`}}}},scales:{x:{type:`time`,time:{unit:`hour`,displayFormats:{hour:`HH:mm`}},min:Date.now()-O.value*3600*1e3,max:Date.now(),grid:{color:D().gridColor},ticks:{color:D().tickColor,maxTicksLimit:8}},y:{beginAtZero:!1,grid:{color:D().gridColor},ticks:{color:D().tickColor,callback:function(e){return typeof e==`number`?e.toFixed(3):e}},min:p,max:m}}}}))}catch(e){console.error(`Error creating packet rate chart:`,e),R.value=!0}},tt=()=>{if(!G.value)return;let e=G.value.getContext(`2d`);if(!e)return;let t=[],n=[],i=[`#60A5FA`,`#34D399`,`#FBBF24`,`#A78BFA`,`#F87171`,`#06B6D4`,`#84CC16`,`#F472B6`,`#10B981`];if(j.value.length>0)j.value.forEach(e=>{let r=e.data?e.data.reduce((e,t)=>e+t[1],0):0;r>0&&(t.push(e.name.replace(/\([^)]*\)/g,``).trim()),n.push(r))});else{z.value=!0;return}z.value=!1,H.value&&=(H.value.destroy(),null);try{let a=JSON.parse(JSON.stringify(t)),o=JSON.parse(JSON.stringify(n));H.value=r(new w(e,{type:`bar`,data:{labels:a,datasets:[{data:o,backgroundColor:i.slice(0,o.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)`}}}}}))}catch(e){console.error(`Error creating packet type chart:`,e),z.value=!0}},nt=()=>{if(!K.value)return;let e=K.value.getContext(`2d`);if(!e)return;let t=N.value.map(e=>({x:e.timestamp,y:e.noiseFloor})).filter(e=>e.y!==null&&e.y!==void 0),i=t.map(e=>e.y),a=i.length>0?Math.min(...i):-120,o=i.length>0?Math.max(...i):-110,s=o-a||1,c=a-s*.05,l=o+s*.05;if(U.value)try{let e=n(U.value),r=JSON.parse(JSON.stringify(t));e.data.datasets[0]&&(e.data.datasets[0].data=r),e.options?.scales?.x&&(e.options.scales.x.min=Date.now()-O.value*3600*1e3,e.options.scales.x.max=Date.now()),e.update(`active`);return}catch{U.value.destroy(),U.value=null}U.value=r(new w(e,{type:`scatter`,data:{datasets:[{label:`Noise Floor (dBm)`,data:JSON.parse(JSON.stringify(t)),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:D().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(e){let t=e[0]?.parsed?.x;return t==null?``:new Date(t).toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`})},label:function(e){let t=e.dataset?.label||``,n=e.parsed?.y;return n==null?t:`${t}: ${n.toFixed(1)} dBm`}}}},scales:{x:{type:`time`,time:{unit:`hour`,displayFormats:{hour:`HH:mm`}},min:Date.now()-O.value*3600*1e3,max:Date.now(),grid:{color:D().gridColor},ticks:{color:D().tickColor,maxTicksLimit:8}},y:{type:`linear`,display:!0,title:{display:!0,text:`Noise Floor (dBm)`,color:D().titleColor},grid:{color:`rgba(245, 158, 11, 0.2)`},ticks:{color:`#F59E0B`,callback:function(e){return typeof e==`number`?e.toFixed(1):e}},min:c,max:l}}}}))},rt=()=>{if(!q.value)return;if(!M.value||!M.value.route_totals){B.value=!0;return}B.value=!1;let e=M.value.route_totals,t=Object.keys(e),n=Object.values(e),r=[`#3B82F6`,`#10B981`,`#F59E0B`,`#A78BFA`,`#F87171`];try{let e=JSON.parse(JSON.stringify(t)),i=JSON.parse(JSON.stringify(n)),a=i.reduce((e,t)=>e+t,0),o=i.map(e=>e/a*100),s=e.map((e,t)=>({type:`bar`,name:e,x:[o[t]],y:[``],orientation:`h`,marker:{color:r[t%r.length]},text:o[t]>=5?`${e} ${o[t].toFixed(0)}%`:``,textposition:`inside`,textfont:{color:`white`,size:11},hoverinfo:`none`,insidetextanchor:`middle`}));E.default.newPlot(q.value,s,{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},{responsive:!0,displayModeBar:!1,staticPlot:!0})}catch(e){console.error(`Error creating route treemap chart:`,e),B.value=!0}};return a(async()=>{await o(),Z(),window.addEventListener(`resize`,()=>{setTimeout(()=>{n(V.value)?.resize(),n(H.value)?.resize(),n(U.value)?.resize(),q.value&&E.default.Plots&&E.default.Plots.resize(q.value)},100)})}),ee(()=>{V.value?.destroy(),H.value?.destroy(),U.value?.destroy(),q.value&&E.default.purge(q.value),window.removeEventListener(`resize`,()=>{})}),re(Z,{intervalMs:3e4,enabled:()=>!S.isConnected,immediate:!1}),(e,n)=>(y(),v(`div`,Se,[_(`div`,Ce,[n[2]||=_(`h2`,{class:`text-xl sm:text-2xl font-bold text-content-primary dark:text-content-primary`},` Statistics `,-1),_(`div`,we,[n[1]||=_(`label`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},`Time Range:`,-1),u(_(`select`,{"onUpdate:modelValue":n[0]||=e=>O.value=e,onChange:Qe,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`},[(y(),v(g,null,t(Ge,e=>_(`option`,{key:e.value,value:e.value,class:`bg-white dark:bg-gray-800 text-content-primary dark:text-content-primary`},h(e.label),9,Te)),64))],544),[[ie,O.value]])])]),_(`div`,Ee,[p(T,{title:`Total RX`,value:J.value.totalRx,color:`#AAE8E8`,data:X.value.totalPackets,loading:L.value.sparklines,variant:`classic`},null,8,[`value`,`data`,`loading`]),p(T,{title:`Total TX`,value:J.value.totalTx,color:`#FFC246`,data:X.value.transmittedPackets,loading:L.value.sparklines,variant:`classic`},null,8,[`value`,`data`,`loading`]),p(T,{title:`CRC Errors`,value:P.value.reduce((e,t)=>e+t.count,0),color:`#F59E0B`,data:X.value.crcErrors,loading:L.value.sparklines,variant:`classic`},null,8,[`value`,`data`,`loading`]),p(T,{title:`Packet Hash Cache`,value:i(l).systemStats?.duplicate_cache_size??0,color:`#9F7AEA`,data:[],loading:!1,variant:`smooth`,subtitle:`Entries expire after ${(()=>{let e=i(l).systemStats?.cache_ttl??3600,t=Math.floor(e/60);return t>=60?`${Math.floor(t/60)}h`:`${t}m`})()}`},null,8,[`value`,`subtitle`])]),_(`div`,De,[n[6]||=_(`h3`,{class:`text-content-primary dark:text-content-primary text-lg sm:text-xl font-semibold mb-3 sm:mb-4`},` Performance Metrics `,-1),_(`div`,null,[n[5]||=s(`

Packet Rate (RX/TX PER HOUR)

RX/hr
TX/hr
`,2),_(`div`,Oe,[_(`canvas`,{ref_key:`packetRateCanvasRef`,ref:W,class:`w-full h-full relative z-10`},null,512),L.value.packetRate?(y(),v(`div`,ke,[...n[3]||=[_(`div`,{class:`text-center`},[_(`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`}),_(`div`,{class:`text-content-secondary dark:text-content-muted text-[10px] sm:text-xs`},` Loading packet rate data... `)],-1)]])):f(``,!0),R.value&&!L.value.packetRate?(y(),v(`div`,Ae,[...n[4]||=[_(`div`,{class:`text-center`},[_(`div`,{class:`text-red-700 dark:text-red-400 text-sm font-semibold mb-1`},` No Data Available `),_(`div`,{class:`text-content-secondary dark:text-content-muted text-xs`},` Packet rate data not found `)],-1)]])):f(``,!0)])])]),_(`div`,je,[_(`div`,Me,[n[8]||=_(`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),_(`div`,Ne,[_(`canvas`,{ref_key:`signalMetricsCanvasRef`,ref:K,class:`w-full h-full`},null,512),L.value.noiseFloor?(y(),v(`div`,Pe,[...n[7]||=[_(`div`,{class:`text-center`},[_(`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`}),_(`div`,{class:`text-content-secondary dark:text-content-muted text-[10px] sm:text-xs`},` Loading noise floor data... `)],-1)]])):f(``,!0)])]),_(`div`,Fe,[n[11]||=_(`h3`,{class:`text-content-primary dark:text-content-primary text-lg sm:text-xl font-semibold mb-3 sm:mb-4`},` Route Distribution `,-1),_(`div`,Ie,[L.value.routePie?(y(),v(`div`,Le,[...n[9]||=[_(`div`,{class:`text-center`},[_(`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`}),_(`div`,{class:`text-content-secondary dark:text-content-muted text-xs`},` Loading route data... `)],-1)]])):B.value?(y(),v(`div`,Re,[...n[10]||=[_(`div`,{class:`text-center`},[_(`div`,{class:`text-red-700 dark:text-red-400 text-sm font-semibold mb-1`},` No Data Available `),_(`div`,{class:`text-content-secondary dark:text-content-muted text-xs`},` Route statistics not found `)],-1)]])):M.value?.route_totals?(y(!0),v(g,{key:2},t(M.value.route_totals,(e,t,n)=>(y(),v(`div`,{key:t,class:`flex items-center gap-3`},[_(`div`,ze,h(t),1),_(`div`,Be,[_(`div`,{class:`h-full rounded transition-all duration-300`,style:c({width:`${e/Math.max(...Object.values(M.value.route_totals))*100}%`,backgroundColor:[`#3B82F6`,`#10B981`,`#F59E0B`,`#A78BFA`,`#F87171`][n%5]})},null,4)]),_(`div`,Ve,h(e.toLocaleString()),1)]))),128)):f(``,!0)])])]),F.value?(y(),v(`div`,He,[...n[12]||=[_(`div`,{class:`text-content-secondary dark:text-content-muted mb-2 text-sm`},` Loading statistics... `,-1),_(`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)]])):f(``,!0),I.value?(y(),v(`div`,Ue,[n[13]||=_(`div`,{class:`text-red-700 dark:text-red-400 mb-2 text-sm font-semibold`},` Failed to load statistics `,-1),_(`p`,We,h(I.value),1),_(`button`,{onClick:Z,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 `)])):f(``,!0)]))}}),[[`__scopeId`,`data-v-54d032e1`]]);export{D as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/SystemStats-4wDqjB6x.js b/repeater/web/html/assets/SystemStats-4wDqjB6x.js new file mode 100644 index 0000000..ad0856b --- /dev/null +++ b/repeater/web/html/assets/SystemStats-4wDqjB6x.js @@ -0,0 +1,2 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/plotly.min-Dl7ekyci.js","assets/chunk-DECur_0Z.js"])))=>i.map(i=>d[i]); +import{r as e}from"./chunk-DECur_0Z.js";import{E as t,H as n,I as r,S as i,b as a,dt as o,g as s,l as c,m as l,o as u,pt as d,r as f,s as p,u as m,w as h,x as ee,z as g}from"./runtime-core.esm-bundler-HnidnMFy.js";import{a as _,t as v}from"./api-CbM6k1ZB.js";import{t as y}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{_ as te,a as b,c as ne,f as re,g as ie,h as ae,i as oe,l as se,m as ce,n as le,o as ue,r as de,s as fe,t as pe,u as me}from"./chart-B1uYMRrx.js";import{t as x}from"./chartjs-adapter-date-fns.esm-DnBoPdP1.js";var he={class:`p-6 space-y-6`},ge={class:`grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4`},_e={class:`grid grid-cols-1 lg:grid-cols-2 gap-6`},ve={class:`glass-card rounded-[15px] p-6`},ye={class:`relative h-32 bg-gray-100/50 dark:bg-white/5 rounded-lg p-4 mb-4 chart-container`},be={key:0,class:`absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-sm z-20`},xe={key:1,class:`absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 z-20`},Se={key:0,class:`grid grid-cols-2 gap-4 text-sm`},Ce={class:`text-content-primary dark:text-content-primary font-semibold`},S={class:`text-content-primary dark:text-content-primary font-semibold`},C={class:`text-content-primary dark:text-content-primary font-semibold`},w={class:`text-content-primary dark:text-content-primary font-semibold`},T={class:`glass-card rounded-[15px] p-6`},E={class:`relative h-32 bg-gray-100/50 dark:bg-white/5 rounded-lg p-4 mb-4 chart-container`},D={key:0,class:`absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 backdrop-blur-sm z-20`},O={key:1,class:`absolute inset-0 flex items-center justify-center bg-white/50 dark:bg-white/5 z-20`},k={key:0,class:`grid grid-cols-2 gap-4 text-sm`},A={class:`text-content-primary dark:text-content-primary font-semibold`},we={class:`text-content-primary dark:text-content-primary font-semibold`},Te={class:`text-content-primary dark:text-content-primary font-semibold`},Ee={class:`text-content-primary dark:text-content-primary font-semibold`},De={class:`grid grid-cols-1 lg:grid-cols-2 gap-6`},Oe={class:`glass-card rounded-[15px] p-6`},ke={class:`relative h-48`},Ae={key:0,class:`grid grid-cols-3 gap-4 text-sm mt-4`},je={class:`text-center`},Me={class:`text-content-primary dark:text-content-primary font-semibold`},Ne={class:`text-center`},Pe={class:`font-semibold text-red-500 dark:text-red-400`},Fe={class:`text-center`},Ie={class:`font-semibold text-green-700 dark:text-green-400`},Le={class:`glass-card rounded-[15px] p-6`},Re={key:0,class:`space-y-4`},ze={class:`grid grid-cols-2 gap-4 text-sm`},Be={class:`text-content-primary dark:text-content-primary font-semibold`},Ve={class:`text-content-primary dark:text-content-primary font-semibold`},He={class:`text-content-primary dark:text-content-primary font-semibold`},Ue={class:`text-content-primary dark:text-content-primary font-semibold`},We={key:0,class:`pt-4 border-t border-stroke-subtle dark:border-stroke/10`},Ge={class:`grid grid-cols-2 gap-2 text-sm`},j={class:`text-content-secondary dark:text-content-muted`},Ke={class:`text-content-primary dark:text-content-primary font-semibold ml-1`},qe={class:`glass-card rounded-[15px] p-6`},Je={key:0,class:`overflow-x-auto`},Ye={class:`w-full text-sm`},Xe={class:`text-content-secondary dark:text-content-primary/80 py-2 transition-all duration-300`},Ze={class:`text-content-primary dark:text-content-primary font-semibold py-2 transition-all duration-300`},Qe={class:`text-center text-orange-500 dark:text-orange-400 py-2 transition-all duration-300`},$e={class:`text-center text-green-700 dark:text-green-400 py-2 transition-all duration-300`},et={class:`text-right text-content-secondary dark:text-content-primary/80 py-2 transition-all duration-300`},tt={key:0,class:`mt-4 text-center text-content-secondary dark:text-content-muted text-sm transition-all duration-300`},nt={key:1,class:`text-center text-content-secondary dark:text-content-muted py-8`},rt={key:0,class:`glass-card rounded-[15px] p-8 text-center`},it={key:1,class:`glass-card rounded-[15px] p-8 text-center`},at={class:`text-content-secondary dark:text-content-muted text-sm`},M=y(s({name:`SystemStatsView`,__name:`SystemStats`,setup(s){b.register(oe,se,me,ne,fe,le,ue,ie,te,ae,pe,de,ce,re);let y=g(null),M=g(!0),N=g(null),P=g(null),F=g(null),I=g([]),L=g(null),R=g({cpuChart:!0,memoryChart:!0,diskChart:!1,processChart:!0}),z=g(!1),B=g(!1),V=g(null),H=g(null),U=g(null),W=g(null),G=g(null),K=u(()=>P.value?{cpuUsage:P.value.cpu.usage_percent,memoryUsage:P.value.memory.usage_percent,diskUsage:P.value.disk.usage_percent,uptime:P.value.system.uptime}:{cpuUsage:0,memoryUsage:0,diskUsage:0,uptime:0}),q=u(()=>I.value.length===0?{cpu:[],memory:[],disk:[],network:[]}:{cpu:I.value.map(e=>e.cpu.usage_percent),memory:I.value.map(e=>e.memory.usage_percent),disk:I.value.map(e=>e.disk.usage_percent),network:I.value.map(e=>e.network.bytes_recv/1024/1024)}),J=e=>{let t=[`B`,`KB`,`MB`,`GB`,`TB`];if(e===0)return`0 B`;let n=Math.floor(Math.log(e)/Math.log(1024));return parseFloat((e/1024**n).toFixed(2))+` `+t[n]},ot=e=>{let t=Math.floor(e/86400),n=Math.floor(e%86400/3600),r=Math.floor(e%3600/60);return t>0?`${t}d ${n}h ${r}m`:n>0?`${n}h ${r}m`:`${r}m`},st=async()=>{try{let e=await v.get(`/hardware_stats`);if(e?.success&&e.data){let t=e.data;if(P.value=t,I.value.length===0)for(let e=0;e<12;e++)I.value.push(JSON.parse(JSON.stringify(t)));else I.value.push(t),I.value.length>20&&I.value.shift()}}catch(e){console.error(`Failed to fetch hardware stats:`,e),N.value=`Failed to fetch hardware stats`}},ct=async()=>{try{let e=await v.get(`/hardware_processes`);e?.success&&e.data&&(L.value=F.value,F.value=e.data)}catch(e){console.error(`Failed to fetch process stats:`,e)}},Y=(e,t)=>{if(!L.value)return!1;let n=L.value.processes.find(t=>t.pid===e.pid);return n?n[t]!==e[t]:!0},X=async()=>{try{M.value=!0,N.value=null,await Promise.all([st(),ct()]),M.value=!1,await a(),Z()}catch(e){N.value=e instanceof Error?e.message:`Failed to fetch system data`,M.value=!1}},Z=()=>{P.value&&(lt(),ut(),dt())},lt=()=>{if(!U.value||!P.value){R.value.cpuChart=!1;return}let e=U.value.getContext(`2d`);if(!e){R.value.cpuChart=!1;return}let t=P.value.cpu.usage_percent,n=100-t;if(V.value)try{V.value.data.datasets[0].data=[t,n],V.value.update(`none`);return}catch(e){console.warn(`Failed to update CPU chart, recreating...`,e),V.value.destroy(),V.value=null}let i=document.documentElement.classList.contains(`dark`),a=i?`rgba(255, 255, 255, 0.1)`:`rgba(0, 0, 0, 0.1)`,o=i?`rgba(255, 255, 255, 0.2)`:`rgba(0, 0, 0, 0.2)`,s=i?`rgba(255, 255, 255, 0.6)`:`rgba(0, 0, 0, 0.6)`;try{V.value=r(new b(e,{type:`doughnut`,data:{labels:[`Used`,`Available`],datasets:[{data:[t,n],backgroundColor:[`#FFC246`,a],borderColor:[`#FFC246`,o],borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,cutout:`70%`,animation:{animateRotate:!1,animateScale:!1,duration:0},plugins:{legend:{display:!1},tooltip:{callbacks:{label:function(e){return`${e.label}: ${e.parsed.toFixed(1)}%`}}}}},plugins:[{id:`centerText`,beforeDraw:function(e){let n=e.ctx;n.save();let r=(e.chartArea.left+e.chartArea.right)/2,i=(e.chartArea.top+e.chartArea.bottom)/2;n.textAlign=`center`,n.textBaseline=`middle`,n.fillStyle=`#FFC246`,n.font=`bold 18px sans-serif`,n.fillText(`${t.toFixed(1)}%`,r,i-5),n.fillStyle=s,n.font=`10px sans-serif`,n.fillText(`CPU`,r,i+12),n.restore()}}]})),z.value=!1,R.value.cpuChart=!1}catch(e){console.error(`Error creating CPU chart:`,e),z.value=!0,R.value.cpuChart=!1}},ut=()=>{if(!W.value||!P.value){R.value.memoryChart=!1;return}let e=W.value.getContext(`2d`);if(!e){R.value.memoryChart=!1;return}let t=P.value.memory.usage_percent,n=100-t;if(H.value)try{H.value.data.datasets[0].data=[t,n],H.value.update(`none`);return}catch(e){console.warn(`Failed to update Memory chart, recreating...`,e),H.value.destroy(),H.value=null}let i=document.documentElement.classList.contains(`dark`),a=i?`rgba(255, 255, 255, 0.1)`:`rgba(0, 0, 0, 0.1)`,o=i?`rgba(255, 255, 255, 0.2)`:`rgba(0, 0, 0, 0.2)`,s=i?`rgba(255, 255, 255, 0.6)`:`rgba(0, 0, 0, 0.6)`;try{H.value=r(new b(e,{type:`doughnut`,data:{labels:[`Used`,`Available`],datasets:[{data:[t,n],backgroundColor:[`#A5E5B6`,a],borderColor:[`#A5E5B6`,o],borderWidth:2}]},options:{responsive:!0,maintainAspectRatio:!1,cutout:`70%`,animation:{animateRotate:!1,animateScale:!1,duration:0},plugins:{legend:{display:!1},tooltip:{callbacks:{label:function(e){return`${e.label}: ${e.parsed.toFixed(1)}%`}}}}},plugins:[{id:`centerText`,beforeDraw:function(e){let n=e.ctx;n.save();let r=(e.chartArea.left+e.chartArea.right)/2,i=(e.chartArea.top+e.chartArea.bottom)/2;n.textAlign=`center`,n.textBaseline=`middle`,n.fillStyle=`#A5E5B6`,n.font=`bold 18px sans-serif`,n.fillText(`${t.toFixed(1)}%`,r,i-5),n.fillStyle=s,n.font=`10px sans-serif`,n.fillText(`Memory`,r,i+12),n.restore()}}]})),B.value=!1,R.value.memoryChart=!1}catch(e){console.error(`Error creating Memory chart:`,e),B.value=!0,R.value.memoryChart=!1}},dt=()=>{if(!G.value||!P.value)return;let t=document.documentElement.classList.contains(`dark`)?`rgba(255, 255, 255, 0.8)`:`rgba(0, 0, 0, 0.8)`;try{_(()=>import(`./plotly.min-Dl7ekyci.js`).then(t=>e(t.t(),1)).then(e=>{let n=e.default||e,r=P.value.disk,i=[{type:`pie`,labels:[`Used`,`Free`],values:[r.used,r.free],marker:{colors:[`#FB787B`,`#A5E5B6`]},hovertemplate:`%{label}
Size: %{value}
Percentage: %{percent}`,textinfo:`label+percent`,textposition:`auto`,hole:.4}],a={title:{text:``,font:{color:t}},paper_bgcolor:`rgba(0,0,0,0)`,plot_bgcolor:`rgba(0,0,0,0)`,font:{color:t,size:11},margin:{t:20,b:20,l:20,r:20},showlegend:!0,legend:{orientation:`h`,x:0,y:-.2,font:{color:t,size:10}}};n.newPlot(G.value,i,a,{responsive:!0,displayModeBar:!1,staticPlot:!1})}),__vite__mapDeps([0,1]))}catch(e){console.error(`Error creating disk chart:`,e)}},Q=()=>{try{if(V.value&&=(V.value.destroy(),null),H.value&&=(H.value.destroy(),null),G.value)try{_(()=>import(`./plotly.min-Dl7ekyci.js`).then(t=>e(t.t(),1)).then(e=>{let t=e?.default||e;t?.purge&&t.purge(G.value)}),__vite__mapDeps([0,1])).catch(()=>{})}catch{}}catch(e){console.error(`Error destroying charts:`,e)}},$=new MutationObserver(e=>{e.forEach(e=>{e.attributeName===`class`&&(Q(),a(()=>{Z()}))})});return i(async()=>{await a(),X(),y.value=window.setInterval(X,5e3),$.observe(document.documentElement,{attributes:!0,attributeFilter:[`class`]}),window.addEventListener(`resize`,()=>{setTimeout(()=>{n(V.value)?.resize(),n(H.value)?.resize();try{_(()=>import(`./plotly.min-Dl7ekyci.js`).then(t=>e(t.t(),1)).then(e=>{let t=e?.default||e;t?.Plots&&t.Plots.resize(G.value)}),__vite__mapDeps([0,1])).catch(()=>{})}catch{}},100)})}),ee(()=>{y.value&&clearInterval(y.value),$.disconnect(),Q(),window.removeEventListener(`resize`,()=>{})}),(e,n)=>(h(),m(`div`,he,[n[28]||=p(`div`,{class:`flex justify-between items-center`},[p(`h2`,{class:`text-2xl font-bold text-content-primary dark:text-content-primary`},` System Statistics `),p(`div`,{class:`text-content-secondary dark:text-content-muted text-sm`},` Updates every 5 seconds `)],-1),p(`div`,ge,[l(x,{title:`CPU Usage`,value:`${K.value.cpuUsage.toFixed(1)}%`,color:`#FFC246`,data:q.value.cpu},null,8,[`value`,`data`]),l(x,{title:`Memory Usage`,value:`${K.value.memoryUsage.toFixed(1)}%`,color:`#A5E5B6`,data:q.value.memory},null,8,[`value`,`data`]),l(x,{title:`Disk Usage`,value:`${K.value.diskUsage.toFixed(1)}%`,color:`#FB787B`,data:q.value.disk},null,8,[`value`,`data`]),l(x,{title:`Uptime`,value:ot(K.value.uptime),color:`#EBA0FC`,data:q.value.network},null,8,[`value`,`data`])]),p(`div`,_e,[p(`div`,ve,[n[6]||=p(`h3`,{class:`text-content-primary dark:text-content-primary text-xl font-semibold mb-4`},` CPU Performance `,-1),p(`div`,ye,[p(`canvas`,{ref_key:`cpuCanvasRef`,ref:U,class:`w-full h-full relative z-10`},null,512),R.value.cpuChart?(h(),m(`div`,be,[...n[0]||=[p(`div`,{class:`text-center`},[p(`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`}),p(`div`,{class:`text-content-secondary dark:text-content-muted text-xs`},` Loading CPU data... `)],-1)]])):c(``,!0),z.value&&!R.value.cpuChart?(h(),m(`div`,xe,[...n[1]||=[p(`div`,{class:`text-center`},[p(`div`,{class:`text-red-500 dark:text-red-400 text-sm mb-1`},`No Data Available`),p(`div`,{class:`text-content-secondary dark:text-content-muted text-xs`},` CPU data not found `)],-1)]])):c(``,!0)]),P.value?(h(),m(`div`,Se,[p(`div`,null,[n[2]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`CPU Count`,-1),p(`div`,Ce,d(P.value.cpu.count)+` cores `,1)]),p(`div`,null,[n[3]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Frequency`,-1),p(`div`,S,d(P.value.cpu.frequency.toFixed(0))+` MHz `,1)]),p(`div`,null,[n[4]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Load (1m)`,-1),p(`div`,C,d(P.value.cpu.load_avg[`1min`].toFixed(2)),1)]),p(`div`,null,[n[5]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Load (5m)`,-1),p(`div`,w,d(P.value.cpu.load_avg[`5min`].toFixed(2)),1)])])):c(``,!0)]),p(`div`,T,[n[13]||=p(`h3`,{class:`text-content-primary dark:text-content-primary text-xl font-semibold mb-4`},` Memory Usage `,-1),p(`div`,E,[p(`canvas`,{ref_key:`memoryCanvasRef`,ref:W,class:`w-full h-full relative z-10`},null,512),R.value.memoryChart?(h(),m(`div`,D,[...n[7]||=[p(`div`,{class:`text-center`},[p(`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`}),p(`div`,{class:`text-content-secondary dark:text-content-muted text-xs`},` Loading memory data... `)],-1)]])):c(``,!0),B.value&&!R.value.memoryChart?(h(),m(`div`,O,[...n[8]||=[p(`div`,{class:`text-center`},[p(`div`,{class:`text-red-500 dark:text-red-400 text-sm mb-1`},`No Data Available`),p(`div`,{class:`text-content-secondary dark:text-content-muted text-xs`},` Memory data not found `)],-1)]])):c(``,!0)]),P.value?(h(),m(`div`,k,[p(`div`,null,[n[9]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Total`,-1),p(`div`,A,d(J(P.value.memory.total)),1)]),p(`div`,null,[n[10]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Used`,-1),p(`div`,we,d(J(P.value.memory.used)),1)]),p(`div`,null,[n[11]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Available`,-1),p(`div`,Te,d(J(P.value.memory.available)),1)]),p(`div`,null,[n[12]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Usage`,-1),p(`div`,Ee,d(P.value.memory.usage_percent.toFixed(1))+`% `,1)])])):c(``,!0)])]),p(`div`,De,[p(`div`,Oe,[n[17]||=p(`h3`,{class:`text-content-primary dark:text-content-primary text-xl font-semibold mb-4`},` Storage Usage `,-1),p(`div`,ke,[p(`div`,{ref_key:`diskCanvasRef`,ref:G,class:`w-full h-full`},null,512)]),P.value?(h(),m(`div`,Ae,[p(`div`,je,[n[14]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Total`,-1),p(`div`,Me,d(J(P.value.disk.total)),1)]),p(`div`,Ne,[n[15]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Used`,-1),p(`div`,Pe,d(J(P.value.disk.used)),1)]),p(`div`,Fe,[n[16]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Free`,-1),p(`div`,Ie,d(J(P.value.disk.free)),1)])])):c(``,!0)]),p(`div`,Le,[n[23]||=p(`h3`,{class:`text-content-primary dark:text-content-primary text-xl font-semibold mb-4`},` Network Statistics `,-1),P.value?(h(),m(`div`,Re,[p(`div`,ze,[p(`div`,null,[n[18]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Bytes Sent`,-1),p(`div`,Be,d(J(P.value.network.bytes_sent)),1)]),p(`div`,null,[n[19]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Bytes Received`,-1),p(`div`,Ve,d(J(P.value.network.bytes_recv)),1)]),p(`div`,null,[n[20]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Packets Sent`,-1),p(`div`,He,d(P.value.network.packets_sent.toLocaleString()),1)]),p(`div`,null,[n[21]||=p(`div`,{class:`text-content-secondary dark:text-content-muted`},`Packets Received`,-1),p(`div`,Ue,d(P.value.network.packets_recv.toLocaleString()),1)])]),P.value.temperatures&&Object.keys(P.value.temperatures).length>0?(h(),m(`div`,We,[n[22]||=p(`div`,{class:`text-content-secondary dark:text-content-muted mb-2`},` System Temperatures `,-1),p(`div`,Ge,[(h(!0),m(f,null,t(P.value.temperatures,(e,t)=>(h(),m(`div`,{key:t},[p(`span`,j,d(t)+`:`,1),p(`span`,Ke,d(e.toFixed(1))+`°C`,1)]))),128))])])):c(``,!0)])):c(``,!0)])]),p(`div`,qe,[n[25]||=p(`h3`,{class:`text-content-primary dark:text-content-primary text-xl font-semibold mb-4`},` Top Processes `,-1),F.value?.processes&&F.value.processes.length>0?(h(),m(`div`,Je,[p(`table`,Ye,[n[24]||=p(`thead`,null,[p(`tr`,{class:`border-b border-stroke-subtle dark:border-stroke/10`},[p(`th`,{class:`text-left text-content-secondary dark:text-content-muted py-2`},`PID`),p(`th`,{class:`text-left text-content-secondary dark:text-content-muted py-2`},`Name`),p(`th`,{class:`text-center text-content-secondary dark:text-content-muted py-2`},`CPU %`),p(`th`,{class:`text-center text-content-secondary dark:text-content-muted py-2`},` Memory % `),p(`th`,{class:`text-right text-content-secondary dark:text-content-muted py-2`},`Memory`)])],-1),p(`tbody`,null,[(h(!0),m(f,null,t(F.value.processes.slice(0,10),e=>(h(),m(`tr`,{key:e.pid,class:`border-b border-stroke-subtle dark:border-white/5 process-row`},[p(`td`,Xe,d(e.pid),1),p(`td`,Ze,d(e.name),1),p(`td`,Qe,[p(`span`,{class:o([`cpu-value`,{"value-updated":Y(e,`cpu_percent`)}])},d(e.cpu_percent.toFixed(1))+`% `,3)]),p(`td`,$e,[p(`span`,{class:o([`memory-value`,{"value-updated":Y(e,`memory_percent`)}])},d(e.memory_percent.toFixed(1))+`% `,3)]),p(`td`,et,[p(`span`,{class:o({"value-updated":Y(e,`memory_mb`)})},d(e.memory_mb.toFixed(1))+` MB `,3)])]))),128))])]),F.value.total_processes?(h(),m(`div`,tt,` Showing top 10 of `+d(F.value.total_processes)+` total processes `,1)):c(``,!0)])):M.value?c(``,!0):(h(),m(`div`,nt,` No process data available `))]),M.value?(h(),m(`div`,rt,[...n[26]||=[p(`div`,{class:`text-content-secondary dark:text-content-muted mb-2`},` Loading system statistics... `,-1),p(`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)]])):c(``,!0),N.value?(h(),m(`div`,it,[n[27]||=p(`div`,{class:`text-red-500 dark:text-red-400 mb-2`},`Failed to load system statistics`,-1),p(`p`,at,d(N.value),1),p(`button`,{onClick:X,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 `)])):c(``,!0)]))}}),[[`__scopeId`,`data-v-fda01968`]]);export{M as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/SystemStats-Dnc1_s5j.css b/repeater/web/html/assets/SystemStats-Dnc1_s5j.css new file mode 100644 index 0000000..04344e0 --- /dev/null +++ b/repeater/web/html/assets/SystemStats-Dnc1_s5j.css @@ -0,0 +1 @@ +.glass-card[data-v-fda01968]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:#ffffffbf;border:1px solid #0000000f;box-shadow:0 2px 8px #0000000a}.dark .glass-card[data-v-fda01968]{box-shadow:none;background:#0000004d;border:1px solid #ffffff1a}.chart-updating[data-v-fda01968]{animation:.8s ease-in-out subtle-pulse-fda01968}@keyframes subtle-pulse-fda01968{0%{transform:scale(1)}50%{transform:scale(1.02)}to{transform:scale(1)}}.chart-container[data-v-fda01968]{transition:all .3s;position:relative}.chart-container[data-v-fda01968]:hover{background:#0000000a}.dark .chart-container[data-v-fda01968]:hover{background:#ffffff14}.process-row[data-v-fda01968]{transition:all .3s}.process-row[data-v-fda01968]:hover{background:#00000005;transform:translate(2px)}.dark .process-row[data-v-fda01968]:hover{background:#ffffff0d}.process-row-enter-active[data-v-fda01968],.process-row-leave-active[data-v-fda01968]{transition:all .4s}.process-row-enter-from[data-v-fda01968]{opacity:0;transform:translateY(-10px)scale(.95)}.process-row-leave-to[data-v-fda01968]{opacity:0;transform:translateY(10px)scale(.95)}.process-row-move[data-v-fda01968]{transition:transform .4s}.cpu-value[data-v-fda01968],.memory-value[data-v-fda01968]{border-radius:4px;padding:2px 6px;transition:all .3s}.cpu-value[data-v-fda01968]:hover,.memory-value[data-v-fda01968]:hover{background:#f59e0b1a;transform:scale(1.05)}@keyframes value-update-fda01968{0%{background:#f59e0b4d}to{background:0 0}}.value-updated[data-v-fda01968]{animation:.6s ease-out value-update-fda01968} diff --git a/repeater/web/html/assets/Terminal-Dpu_GlNL.js b/repeater/web/html/assets/Terminal-Dpu_GlNL.js new file mode 100644 index 0000000..28d7aac --- /dev/null +++ b/repeater/web/html/assets/Terminal-Dpu_GlNL.js @@ -0,0 +1,198 @@ +import{C as e,S as t,dt as n,g as r,j as i,k as a,l as o,pt as s,s as c,u as l,w as u,z as d}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t as f}from"./api-CbM6k1ZB.js";import{t as p}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{t as m}from"./useTheme-DMOVV09x.js";import{f as h,h as g,m as _}from"./index-BFltqMtv.js";var v=Object.defineProperty,y=Object.getOwnPropertyDescriptor,b=(e,t)=>{for(var n in t)v(e,n,{get:t[n],enumerable:!0})},x=(e,t,n,r)=>{for(var i=r>1?void 0:r?y(t,n):t,a=e.length-1,o;a>=0;a--)(o=e[a])&&(i=(r?o(t,n,i):o(i))||i);return r&&i&&v(t,n,i),i},S=(e,t)=>(n,r)=>t(n,r,e),ee=`Terminal input`,te={get:()=>ee,set:e=>ee=e},C=`Too much output to announce, navigate to rows manually to read`,w={get:()=>C,set:e=>C=e};function T(e){return e.replace(/\r?\n/g,`\r`)}function E(e,t){return t?`\x1B[200~`+e+`\x1B[201~`:e}function D(e,t){e.clipboardData&&e.clipboardData.setData(`text/plain`,t.selectionText),e.preventDefault()}function ne(e,t,n,r){e.stopPropagation(),e.clipboardData&&O(e.clipboardData.getData(`text/plain`),t,n,r)}function O(e,t,n,r){e=T(e),e=E(e,n.decPrivateModes.bracketedPasteMode&&r.rawOptions.ignoreBracketedPasteMode!==!0),n.triggerDataEvent(e,!0),t.value=``}function re(e,t,n){let r=n.getBoundingClientRect(),i=e.clientX-r.left-10,a=e.clientY-r.top-10;t.style.width=`20px`,t.style.height=`20px`,t.style.left=`${i}px`,t.style.top=`${a}px`,t.style.zIndex=`1000`,t.focus()}function k(e,t,n,r,i){re(e,t,n),i&&r.rightClickSelect(e),t.value=r.selectionText,t.select()}function A(e){return e>65535?(e-=65536,String.fromCharCode((e>>10)+55296)+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)}function j(e,t=0,n=e.length){let r=``;for(let i=t;i65535?(t-=65536,r+=String.fromCharCode((t>>10)+55296)+String.fromCharCode(t%1024+56320)):r+=String.fromCharCode(t)}return r}var ie=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){let n=e.length;if(!n)return 0;let r=0,i=0;if(this._interim){let n=e.charCodeAt(i++);56320<=n&&n<=57343?t[r++]=(this._interim-55296)*1024+n-56320+65536:(t[r++]=this._interim,t[r++]=n),this._interim=0}for(let a=i;a=n)return this._interim=i,r;let o=e.charCodeAt(a);56320<=o&&o<=57343?t[r++]=(i-55296)*1024+o-56320+65536:(t[r++]=i,t[r++]=o);continue}i!==65279&&(t[r++]=i)}return r}},ae=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){let n=e.length;if(!n)return 0;let r=0,i,a,o,s,c=0,l=0;if(this.interim[0]){let i=!1,a=this.interim[0];a&=(a&224)==192?31:(a&240)==224?15:7;let o=0,s;for(;(s=this.interim[++o]&63)&&o<4;)a<<=6,a|=s;let c=(this.interim[0]&224)==192?2:(this.interim[0]&240)==224?3:4,u=c-o;for(;l=n)return 0;if(s=e[l++],(s&192)!=128){l--,i=!0;break}else this.interim[o++]=s,a<<=6,a|=s&63}i||(c===2?a<128?l--:t[r++]=a:c===3?a<2048||a>=55296&&a<=57343||a===65279||(t[r++]=a):a<65536||a>1114111||(t[r++]=a)),this.interim.fill(0)}let u=n-4,d=l;for(;d=n)return this.interim[0]=i,r;if(a=e[d++],(a&192)!=128){d--;continue}if(c=(i&31)<<6|a&63,c<128){d--;continue}t[r++]=c}else if((i&240)==224){if(d>=n)return this.interim[0]=i,r;if(a=e[d++],(a&192)!=128){d--;continue}if(d>=n)return this.interim[0]=i,this.interim[1]=a,r;if(o=e[d++],(o&192)!=128){d--;continue}if(c=(i&15)<<12|(a&63)<<6|o&63,c<2048||c>=55296&&c<=57343||c===65279)continue;t[r++]=c}else if((i&248)==240){if(d>=n)return this.interim[0]=i,r;if(a=e[d++],(a&192)!=128){d--;continue}if(d>=n)return this.interim[0]=i,this.interim[1]=a,r;if(o=e[d++],(o&192)!=128){d--;continue}if(d>=n)return this.interim[0]=i,this.interim[1]=a,this.interim[2]=o,r;if(s=e[d++],(s&192)!=128){d--;continue}if(c=(i&7)<<18|(a&63)<<12|(o&63)<<6|s&63,c<65536||c>1114111)continue;t[r++]=c}}return r}},oe=``,se=` `,ce=class e{constructor(){this.fg=0,this.bg=0,this.extended=new le}static toColorRGB(e){return[e>>>16&255,e>>>8&255,e&255]}static fromColorRGB(e){return(e[0]&255)<<16|(e[1]&255)<<8|e[2]&255}clone(){let t=new e;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)==50331648}isBgRGB(){return(this.bg&50331648)==50331648}isFgPalette(){return(this.fg&50331648)==16777216||(this.fg&50331648)==33554432}isBgPalette(){return(this.bg&50331648)==16777216||(this.bg&50331648)==33554432}isFgDefault(){return(this.fg&50331648)==0}isBgDefault(){return(this.bg&50331648)==0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==16777216||(this.extended.underlineColor&50331648)==33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},le=class e{constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}clone(){return new e(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}},M=class e extends ce{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new le,this.combinedData=``}static fromCharData(t){let n=new e;return n.setFromCharData(t),n}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?A(this.content&2097151):``}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(e){this.fg=e[0],this.bg=0;let t=!1;if(e[1].length>2)t=!0;else if(e[1].length===2){let n=e[1].charCodeAt(0);if(55296<=n&&n<=56319){let r=e[1].charCodeAt(1);56320<=r&&r<=57343?this.content=(n-55296)*1024+r-56320+65536|e[2]<<22:t=!0}else t=!0}else this.content=e[1].charCodeAt(0)|e[2]<<22;t&&(this.combinedData=e[1],this.content=2097152|e[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}},ue=`di$target`,de=`di$dependencies`,fe=new Map;function pe(e){return e[de]||[]}function N(e){if(fe.has(e))return fe.get(e);let t=function(e,n,r){if(arguments.length!==3)throw Error(`@IServiceName-decorator can only be used to decorate a parameter`);me(t,e,r)};return t._id=e,fe.set(e,t),t}function me(e,t,n){t[ue]===t?t[de].push({id:e,index:n}):(t[de]=[{id:e,index:n}],t[ue]=t)}var P=N(`BufferService`),he=N(`CoreMouseService`),ge=N(`CoreService`),_e=N(`CharsetService`),ve=N(`InstantiationService`),ye=N(`LogService`),be=N(`OptionsService`),xe=N(`OscLinkService`),Se=N(`UnicodeService`),Ce=N(`DecorationService`),we=class{constructor(e,t,n){this._bufferService=e,this._optionsService=t,this._oscLinkService=n}provideLinks(e,t){let n=this._bufferService.buffer.lines.get(e-1);if(!n){t(void 0);return}let r=[],i=this._optionsService.rawOptions.linkHandler,a=new M,o=n.getTrimmedLength(),s=-1,c=-1,l=!1;for(let t=0;ti?i.activate(e,t,a):Te(e,t),hover:(e,t)=>i?.hover?.(e,t,a),leave:(e,t)=>i?.leave?.(e,t,a)})}l=!1,a.hasExtendedAttrs()&&a.extended.urlId?(c=t,s=a.extended.urlId):(c=-1,s=-1)}}t(r)}};we=x([S(0,P),S(1,be),S(2,xe)],we);function Te(e,t){if(confirm(`Do you want to navigate to ${t}? + +WARNING: This link could potentially be dangerous`)){let e=window.open();if(e){try{e.opener=null}catch{}e.location.href=t}else console.warn(`Opening link blocked as opener could not be cleared`)}}var Ee=N(`CharSizeService`),De=N(`CoreBrowserService`),Oe=N(`MouseService`),ke=N(`RenderService`),Ae=N(`SelectionService`),je=N(`CharacterJoinerService`),Me=N(`ThemeService`),Ne=N(`LinkProviderService`),Pe=new class{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout(()=>{throw e.stack?Be.isErrorNoTelemetry(e)?new Be(e.message+` + +`+e.stack):Error(e.message+` + +`+e.stack):e},0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach(t=>{t(e)})}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}};function Fe(e){Le(e)||Pe.onUnexpectedError(e)}var Ie=`Canceled`;function Le(e){return e instanceof Re?!0:e instanceof Error&&e.name===Ie&&e.message===Ie}var Re=class extends Error{constructor(){super(Ie),this.name=this.message}};function ze(e){return Error(e?`Illegal argument: ${e}`:`Illegal argument`)}var Be=class e extends Error{constructor(e){super(e),this.name=`CodeExpectedError`}static fromError(t){if(t instanceof e)return t;let n=new e;return n.message=t.message,n.stack=t.stack,n}static isErrorNoTelemetry(e){return e.name===`CodeExpectedError`}},Ve=class e extends Error{constructor(t){super(t||`An unexpected bug occurred.`),Object.setPrototypeOf(this,e.prototype)}};function He(e,t,n=0,r=e.length){let i=n,a=r;for(;i{function t(e){return e<0}e.isLessThan=t;function n(e){return e<=0}e.isLessThanOrEqual=n;function r(e){return e>0}e.isGreaterThan=r;function i(e){return e===0}e.isNeitherLessOrGreaterThan=i,e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0})(Ge||={});function Ke(e,t){return(n,r)=>t(e(n),e(r))}var qe=(e,t)=>e-t,Je=class e{constructor(e){this.iterate=e}forEach(e){this.iterate(t=>(e(t),!0))}toArray(){let e=[];return this.iterate(t=>(e.push(t),!0)),e}filter(t){return new e(e=>this.iterate(n=>t(n)?e(n):!0))}map(t){return new e(e=>this.iterate(n=>e(t(n))))}some(e){let t=!1;return this.iterate(n=>(t=e(n),!t)),t}findFirst(e){let t;return this.iterate(n=>e(n)?(t=n,!1):!0),t}findLast(e){let t;return this.iterate(n=>(e(n)&&(t=n),!0)),t}findLastMaxBy(e){let t,n=!0;return this.iterate(r=>((n||Ge.isGreaterThan(e(r,t)))&&(n=!1,t=r),!0)),t}};Je.empty=new Je(e=>{});function Ye(e,t){let n=Object.create(null);for(let r of e){let e=t(r),i=n[e];i||=n[e]=[],i.push(r)}return n}var Xe=class{constructor(){this.map=new Map}add(e,t){let n=this.map.get(e);n||(n=new Set,this.map.set(e,n)),n.add(t)}delete(e,t){let n=this.map.get(e);n&&(n.delete(t),n.size===0&&this.map.delete(e))}forEach(e,t){let n=this.map.get(e);n&&n.forEach(t)}get(e){return this.map.get(e)||new Set}};function Ze(e,t){let n=this,r=!1,i;return function(){if(r)return i;if(r=!0,t)try{i=e.apply(n,arguments)}finally{t()}else i=e.apply(n,arguments);return i}}var Qe;(e=>{function t(e){return e&&typeof e==`object`&&typeof e[Symbol.iterator]==`function`}e.is=t;let n=Object.freeze([]);function r(){return n}e.empty=r;function*i(e){yield e}e.single=i;function a(e){return t(e)?e:i(e)}e.wrap=a;function o(e){return e||n}e.from=o;function*s(e){for(let t=e.length-1;t>=0;t--)yield e[t]}e.reverse=s;function c(e){return!e||e[Symbol.iterator]().next().done===!0}e.isEmpty=c;function l(e){return e[Symbol.iterator]().next().value}e.first=l;function u(e,t){let n=0;for(let r of e)if(t(r,n++))return!0;return!1}e.some=u;function d(e,t){for(let n of e)if(t(n))return n}e.find=d;function*f(e,t){for(let n of e)t(n)&&(yield n)}e.filter=f;function*p(e,t){let n=0;for(let r of e)yield t(r,n++)}e.map=p;function*m(e,t){let n=0;for(let r of e)yield*t(r,n++)}e.flatMap=m;function*h(...e){for(let t of e)yield*t}e.concat=h;function g(e,t,n){let r=n;for(let n of e)r=t(r,n);return r}e.reduce=g;function*_(e,t,n=e.length){for(t<0&&(t+=e.length),n<0?n+=e.length:n>e.length&&(n=e.length);tt.source!==null&&!this.getRootParent(t,e).isSingleton).flatMap(([e])=>e)}computeLeakingDisposables(e=10,t){let n;if(t)n=t;else{let e=new Map,t=[...this.livingDisposables.values()].filter(t=>t.source!==null&&!this.getRootParent(t,e).isSingleton);if(t.length===0)return;let r=new Set(t.map(e=>e.value));if(n=t.filter(e=>!(e.parent&&r.has(e.parent))),n.length===0)throw Error(`There are cyclic diposable chains!`)}if(!n)return;function r(e){function t(e,t){for(;e.length>0&&t.some(t=>typeof t==`string`?t===e[0]:e[0].match(t));)e.shift()}let n=e.source.split(` +`).map(e=>e.trim().replace(`at `,``)).filter(e=>e!==``);return t(n,[`Error`,/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),n.reverse()}let i=new Xe;for(let e of n){let t=r(e);for(let n=0;n<=t.length;n++)i.add(t.slice(0,n).join(` +`),e)}n.sort(Ke(e=>e.idx,qe));let a=``,o=0;for(let t of n.slice(0,e)){o++;let e=r(t),s=[];for(let t=0;tr(e)[t]),e=>e);delete o[e[t]];for(let[e,t]of Object.entries(o))s.unshift(` - stacktraces of ${t.length} other leaks continue with ${e}`);s.unshift(a)}a+=` + + +==================== Leaking disposable ${o}/${n.length}: ${t.value.constructor.name} ==================== +${s.join(` +`)} +============================================================ + +`}return n.length>e&&(a+=` + + +... and ${n.length-e} more leaking disposables + +`),{leaks:n,details:a}}};tt.idx=0;function nt(e){et=e}if($e){let e=`__is_disposable_tracked__`;nt(new class{trackDisposable(t){let n=Error(`Potentially leaked disposable`).stack;setTimeout(()=>{t[e]||console.log(n)},3e3)}setParent(t,n){if(t&&t!==I.None)try{t[e]=!0}catch{}}markAsDisposed(t){if(t&&t!==I.None)try{t[e]=!0}catch{}}markAsSingleton(e){}})}function rt(e){return et?.trackDisposable(e),e}function it(e){et?.markAsDisposed(e)}function at(e,t){et?.setParent(e,t)}function ot(e,t){if(et)for(let n of e)et.setParent(n,t)}function st(e){return et?.markAsSingleton(e),e}function ct(e){if(Qe.is(e)){let t=[];for(let n of e)if(n)try{n.dispose()}catch(e){t.push(e)}if(t.length===1)throw t[0];if(t.length>1)throw AggregateError(t,`Encountered errors while disposing of store`);return Array.isArray(e)?[]:e}else if(e)return e.dispose(),e}function lt(...e){let t=F(()=>ct(e));return ot(e,t),t}function F(e){let t=rt({dispose:Ze(()=>{it(t),e()})});return t}var ut=class e{constructor(){this._toDispose=new Set,this._isDisposed=!1,rt(this)}dispose(){this._isDisposed||(it(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(this._toDispose.size!==0)try{ct(this._toDispose)}finally{this._toDispose.clear()}}add(t){if(!t)return t;if(t===this)throw Error(`Cannot register a disposable on itself!`);return at(t,this),this._isDisposed?e.DISABLE_DISPOSED_WARNING||console.warn(Error(`Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!`).stack):this._toDispose.add(t),t}delete(e){if(e){if(e===this)throw Error(`Cannot dispose a disposable on itself!`);this._toDispose.delete(e),e.dispose()}}deleteAndLeak(e){e&&this._toDispose.has(e)&&(this._toDispose.delete(e),at(e,null))}};ut.DISABLE_DISPOSED_WARNING=!1;var dt=ut,I=class{constructor(){this._store=new dt,rt(this),at(this._store,this)}dispose(){it(this),this._store.dispose()}_register(e){if(e===this)throw Error(`Cannot register a disposable on itself!`);return this._store.add(e)}};I.None=Object.freeze({dispose(){}});var ft=class{constructor(){this._isDisposed=!1,rt(this)}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),e&&at(e,this),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,it(this),this._value?.dispose(),this._value=void 0}clearAndLeak(){let e=this._value;return this._value=void 0,e&&at(e,null),e}},pt=typeof window==`object`?window:globalThis,mt=class e{constructor(t){this.element=t,this.next=e.Undefined,this.prev=e.Undefined}};mt.Undefined=new mt(void 0);var L=mt,ht=class{constructor(){this._first=L.Undefined,this._last=L.Undefined,this._size=0}get size(){return this._size}isEmpty(){return this._first===L.Undefined}clear(){let e=this._first;for(;e!==L.Undefined;){let t=e.next;e.prev=L.Undefined,e.next=L.Undefined,e=t}this._first=L.Undefined,this._last=L.Undefined,this._size=0}unshift(e){return this._insert(e,!1)}push(e){return this._insert(e,!0)}_insert(e,t){let n=new L(e);if(this._first===L.Undefined)this._first=n,this._last=n;else if(t){let e=this._last;this._last=n,n.prev=e,e.next=n}else{let e=this._first;this._first=n,n.next=e,e.prev=n}this._size+=1;let r=!1;return()=>{r||(r=!0,this._remove(n))}}shift(){if(this._first!==L.Undefined){let e=this._first.element;return this._remove(this._first),e}}pop(){if(this._last!==L.Undefined){let e=this._last.element;return this._remove(this._last),e}}_remove(e){if(e.prev!==L.Undefined&&e.next!==L.Undefined){let t=e.prev;t.next=e.next,e.next.prev=t}else e.prev===L.Undefined&&e.next===L.Undefined?(this._first=L.Undefined,this._last=L.Undefined):e.next===L.Undefined?(this._last=this._last.prev,this._last.next=L.Undefined):e.prev===L.Undefined&&(this._first=this._first.next,this._first.prev=L.Undefined);--this._size}*[Symbol.iterator](){let e=this._first;for(;e!==L.Undefined;)yield e.element,e=e.next}},gt=globalThis.performance&&typeof globalThis.performance.now==`function`,_t=class e{static create(t){return new e(t)}constructor(e){this._now=gt&&e===!1?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return this._stopTime===-1?this._now()-this._startTime:this._stopTime-this._startTime}},vt=!1,yt=!1,bt=!1,xt;(e=>{e.None=()=>I.None;function t(e){if(bt){let{onDidAddListener:t}=e,n=Dt.create(),r=0;e.onDidAddListener=()=>{++r===2&&(console.warn(`snapshotted emitter LIKELY used public and SHOULD HAVE BEEN created with DisposableStore. snapshotted here`),n.print()),t?.()}}}function n(e,t){return f(e,()=>{},0,void 0,!0,void 0,t)}e.defer=n;function r(e){return(t,n=null,r)=>{let i=!1,a;return a=e(e=>{if(!i)return a?a.dispose():i=!0,t.call(n,e)},null,r),i&&a.dispose(),a}}e.once=r;function i(e,t,n){return u((n,r=null,i)=>e(e=>n.call(r,t(e)),null,i),n)}e.map=i;function a(e,t,n){return u((n,r=null,i)=>e(e=>{t(e),n.call(r,e)},null,i),n)}e.forEach=a;function o(e,t,n){return u((n,r=null,i)=>e(e=>t(e)&&n.call(r,e),null,i),n)}e.filter=o;function s(e){return e}e.signal=s;function c(...e){return(t,n=null,r)=>d(lt(...e.map(e=>e(e=>t.call(n,e)))),r)}e.any=c;function l(e,t,n,r){let a=n;return i(e,e=>(a=t(a,e),a),r)}e.reduce=l;function u(e,n){let r,i={onWillAddFirstListener(){r=e(a.fire,a)},onDidRemoveLastListener(){r?.dispose()}};n||t(i);let a=new R(i);return n?.add(a),a.event}function d(e,t){return t instanceof Array?t.push(e):t&&t.add(e),e}function f(e,n,r=100,i=!1,a=!1,o,s){let c,l,u,d=0,f,p={leakWarningThreshold:o,onWillAddFirstListener(){c=e(e=>{d++,l=n(l,e),i&&!u&&(m.fire(l),l=void 0),f=()=>{let e=l;l=void 0,u=void 0,(!i||d>1)&&m.fire(e),d=0},typeof r==`number`?(clearTimeout(u),u=setTimeout(f,r)):u===void 0&&(u=0,queueMicrotask(f))})},onWillRemoveListener(){a&&d>0&&f?.()},onDidRemoveLastListener(){f=void 0,c.dispose()}};s||t(p);let m=new R(p);return s?.add(m),m.event}e.debounce=f;function p(t,n=0,r){return e.debounce(t,(e,t)=>e?(e.push(t),e):[t],n,void 0,!0,void 0,r)}e.accumulate=p;function m(e,t=(e,t)=>e===t,n){let r=!0,i;return o(e,e=>{let n=r||!t(e,i);return r=!1,i=e,n},n)}e.latch=m;function h(t,n,r){return[e.filter(t,n,r),e.filter(t,e=>!n(e),r)]}e.split=h;function g(e,t=!1,n=[],r){let i=n.slice(),a=e(e=>{i?i.push(e):s.fire(e)});r&&r.add(a);let o=()=>{i?.forEach(e=>s.fire(e)),i=null},s=new R({onWillAddFirstListener(){a||(a=e(e=>s.fire(e)),r&&r.add(a))},onDidAddFirstListener(){i&&(t?setTimeout(o):o())},onDidRemoveLastListener(){a&&a.dispose(),a=null}});return r&&r.add(s),s.event}e.buffer=g;function _(e,t){return(n,r,i)=>{let a=t(new y);return e(function(e){let t=a.evaluate(e);t!==v&&n.call(r,t)},void 0,i)}}e.chain=_;let v=Symbol(`HaltChainable`);class y{constructor(){this.steps=[]}map(e){return this.steps.push(e),this}forEach(e){return this.steps.push(t=>(e(t),t)),this}filter(e){return this.steps.push(t=>e(t)?t:v),this}reduce(e,t){let n=t;return this.steps.push(t=>(n=e(n,t),n)),this}latch(e=(e,t)=>e===t){let t=!0,n;return this.steps.push(r=>{let i=t||!e(r,n);return t=!1,n=r,i?r:v}),this}evaluate(e){for(let t of this.steps)if(e=t(e),e===v)break;return e}}function b(e,t,n=e=>e){let r=(...e)=>i.fire(n(...e)),i=new R({onWillAddFirstListener:()=>e.on(t,r),onDidRemoveLastListener:()=>e.removeListener(t,r)});return i.event}e.fromNodeEventEmitter=b;function x(e,t,n=e=>e){let r=(...e)=>i.fire(n(...e)),i=new R({onWillAddFirstListener:()=>e.addEventListener(t,r),onDidRemoveLastListener:()=>e.removeEventListener(t,r)});return i.event}e.fromDOMEventEmitter=x;function S(e){return new Promise(t=>r(e)(t))}e.toPromise=S;function ee(e){let t=new R;return e.then(e=>{t.fire(e)},()=>{t.fire(void 0)}).finally(()=>{t.dispose()}),t.event}e.fromPromise=ee;function te(e,t){return e(e=>t.fire(e))}e.forward=te;function C(e,t,n){return t(n),e(e=>t(e))}e.runAndSubscribe=C;class w{constructor(e,n){this._observable=e,this._counter=0,this._hasChanged=!1;let r={onWillAddFirstListener:()=>{e.addObserver(this)},onDidRemoveLastListener:()=>{e.removeObserver(this)}};n||t(r),this.emitter=new R(r),n&&n.add(this.emitter)}beginUpdate(e){this._counter++}handlePossibleChange(e){}handleChange(e,t){this._hasChanged=!0}endUpdate(e){this._counter--,this._counter===0&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}function T(e,t){return new w(e,t).emitter.event}e.fromObservable=T;function E(e){return(t,n,r)=>{let i=0,a=!1,o={beginUpdate(){i++},endUpdate(){i--,i===0&&(e.reportChanges(),a&&(a=!1,t.call(n)))},handlePossibleChange(){},handleChange(){a=!0}};e.addObserver(o),e.reportChanges();let s={dispose(){e.removeObserver(o)}};return r instanceof dt?r.add(s):Array.isArray(r)&&r.push(s),s}}e.fromObservableLight=E})(xt||={});var St=class e{constructor(t){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${t}_${e._idPool++}`,e.all.add(this)}start(e){this._stopWatch=new _t,this.listenerCount=e}stop(){if(this._stopWatch){let e=this._stopWatch.elapsed();this.durations.push(e),this.elapsedOverall+=e,this.invocationCount+=1,this._stopWatch=void 0}}};St.all=new Set,St._idPool=0;var Ct=St,wt=-1,Tt=class e{constructor(t,n,r=(e._idPool++).toString(16).padStart(3,`0`)){this._errorHandler=t,this.threshold=n,this.name=r,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(e,t){let n=this.threshold;if(n<=0||t{let t=this._stacks.get(e.value)||0;this._stacks.set(e.value,t-1)}}getMostFrequentStack(){if(!this._stacks)return;let e,t=0;for(let[n,r]of this._stacks)(!e||t{if(e instanceof jt)t(e);else for(let n=0;n{e.length!==0&&(console.warn(`[LEAKING LISTENERS] GC'ed these listeners that were NOT yet disposed:`),console.warn(e.join(` +`)),e.length=0)},3e3),Pt=new FinalizationRegistry(t=>{typeof t==`string`&&e.push(t)})}var R=class{constructor(e){this._size=0,this._options=e,this._leakageMon=wt>0||this._options?.leakWarningThreshold?new Et(e?.onListenerError??Fe,this._options?.leakWarningThreshold??wt):void 0,this._perfMon=this._options?._profName?new Ct(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){if(!this._disposed){if(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners){if(yt){let e=this._listeners;queueMicrotask(()=>{Nt(e,e=>e.stack?.print())})}this._listeners=void 0,this._size=0}this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose()}}get event(){return this._event??=(e,t,n)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){let e=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(e);let t=this._leakageMon.getMostFrequentStack()??[`UNKNOWN stack`,-1],n=new kt(`${e}. HINT: Stack shows most frequent listener (${t[1]}-times)`,t[0]);return(this._options?.onListenerError||Fe)(n),I.None}if(this._disposed)return I.None;t&&(e=e.bind(t));let r=new jt(e),i;this._leakageMon&&this._size>=Math.ceil(this._leakageMon.threshold*.2)&&(r.stack=Dt.create(),i=this._leakageMon.check(r.stack,this._size+1)),yt&&(r.stack=Dt.create()),this._listeners?this._listeners instanceof jt?(this._deliveryQueue??=new Ft,this._listeners=[this._listeners,r]):this._listeners.push(r):(this._options?.onWillAddFirstListener?.(this),this._listeners=r,this._options?.onDidAddFirstListener?.(this)),this._size++;let a=F(()=>{Pt?.unregister(a),i?.(),this._removeListener(r)});if(n instanceof dt?n.add(a):Array.isArray(n)&&n.push(a),Pt){let e=Error().stack.split(` +`).slice(2,3).join(` +`).trim(),t=/(file:|vscode-file:\/\/vscode-app)?(\/[^:]*:\d+:\d+)/.exec(e);Pt.register(a,t?.[2]??e,a)}return a},this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(this._size===1){this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),this._size=0;return}let t=this._listeners,n=t.indexOf(e);if(n===-1)throw console.log(`disposed?`,this._disposed),console.log(`size?`,this._size),console.log(`arr?`,JSON.stringify(this._listeners)),Error(`Attempted to dispose unknown listener`);this._size--,t[n]=void 0;let r=this._deliveryQueue.current===this;if(this._size*Mt<=t.length){let e=0;for(let n=0;n0}},Ft=class{constructor(){this.i=-1,this.end=0}enqueue(e,t,n){this.i=0,this.end=n,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}},It=class{constructor(){this.mapWindowIdToZoomLevel=new Map,this._onDidChangeZoomLevel=new R,this.onDidChangeZoomLevel=this._onDidChangeZoomLevel.event,this.mapWindowIdToZoomFactor=new Map,this._onDidChangeFullscreen=new R,this.onDidChangeFullscreen=this._onDidChangeFullscreen.event,this.mapWindowIdToFullScreen=new Map}getZoomLevel(e){return this.mapWindowIdToZoomLevel.get(this.getWindowId(e))??0}setZoomLevel(e,t){if(this.getZoomLevel(t)===e)return;let n=this.getWindowId(t);this.mapWindowIdToZoomLevel.set(n,e),this._onDidChangeZoomLevel.fire(n)}getZoomFactor(e){return this.mapWindowIdToZoomFactor.get(this.getWindowId(e))??1}setZoomFactor(e,t){this.mapWindowIdToZoomFactor.set(this.getWindowId(t),e)}setFullscreen(e,t){if(this.isFullscreen(t)===e)return;let n=this.getWindowId(t);this.mapWindowIdToFullScreen.set(n,e),this._onDidChangeFullscreen.fire(n)}isFullscreen(e){return!!this.mapWindowIdToFullScreen.get(this.getWindowId(e))}getWindowId(e){return e.vscodeWindowId}};It.INSTANCE=new It;var Lt=It;function Rt(e,t,n){typeof t==`string`&&(t=e.matchMedia(t)),t.addEventListener(`change`,n)}Lt.INSTANCE.onDidChangeZoomLevel;function zt(e){return Lt.INSTANCE.getZoomFactor(e)}Lt.INSTANCE.onDidChangeFullscreen;var Bt=typeof navigator==`object`?navigator.userAgent:``,Vt=Bt.indexOf(`Firefox`)>=0,Ht=Bt.indexOf(`AppleWebKit`)>=0,Ut=Bt.indexOf(`Chrome`)>=0,Wt=!Ut&&Bt.indexOf(`Safari`)>=0;Bt.indexOf(`Electron/`),Bt.indexOf(`Android`);var Gt=!1;if(typeof pt.matchMedia==`function`){let e=pt.matchMedia(`(display-mode: standalone) or (display-mode: window-controls-overlay)`),t=pt.matchMedia(`(display-mode: fullscreen)`);Gt=e.matches,Rt(pt,e,({matches:e})=>{Gt&&t.matches||(Gt=e)})}function Kt(){return Gt}var qt=`en`,Jt=!1,Yt=!1,Xt=!1,Zt=!1,Qt=!1,$t=qt,en,tn=globalThis,nn;typeof tn.vscode<`u`&&typeof tn.vscode.process<`u`?nn=tn.vscode.process:typeof process<`u`&&typeof process?.versions?.node==`string`&&(nn=process);var rn=typeof nn?.versions?.electron==`string`&&nn?.type===`renderer`;if(typeof nn==`object`){Jt=nn.platform===`win32`,Yt=nn.platform===`darwin`,Xt=nn.platform===`linux`,Xt&&nn.env.SNAP&&nn.env.SNAP_REVISION,nn.env.CI||nn.env.BUILD_ARTIFACTSTAGINGDIRECTORY,$t=qt;let e=nn.env.VSCODE_NLS_CONFIG;if(e)try{let t=JSON.parse(e);t.userLocale,t.osLocale,$t=t.resolvedLanguage||qt,t.languagePack?.translationsConfigFile}catch{}Zt=!0}else typeof navigator==`object`&&!rn?(en=navigator.userAgent,Jt=en.indexOf(`Windows`)>=0,Yt=en.indexOf(`Macintosh`)>=0,(en.indexOf(`Macintosh`)>=0||en.indexOf(`iPad`)>=0||en.indexOf(`iPhone`)>=0)&&navigator.maxTouchPoints&&navigator.maxTouchPoints,Xt=en.indexOf(`Linux`)>=0,en?.indexOf(`Mobi`),Qt=!0,$t=globalThis._VSCODE_NLS_LANGUAGE||qt,navigator.language.toLowerCase()):console.error(`Unable to resolve platform.`);var an=Jt,on=Yt,sn=Xt,cn=Zt;Qt&&typeof tn.importScripts==`function`&&tn.origin;var ln=en,un=$t,dn;(e=>{function t(){return un}e.value=t;function n(){return un.length===2?un===`en`:un.length>=3?un[0]===`e`&&un[1]===`n`&&un[2]===`-`:!1}e.isDefaultVariant=n;function r(){return un===`en`}e.isDefault=r})(dn||={});var fn=typeof tn.postMessage==`function`&&!tn.importScripts;(()=>{if(fn){let e=[];tn.addEventListener(`message`,t=>{if(t.data&&t.data.vscodeScheduleAsyncWork)for(let n=0,r=e.length;n{let r=++t;e.push({id:r,callback:n}),tn.postMessage({vscodeScheduleAsyncWork:r},`*`)}}return e=>setTimeout(e)})();var pn=!!(ln&&ln.indexOf(`Chrome`)>=0);ln&&ln.indexOf(`Firefox`),!pn&&ln&&ln.indexOf(`Safari`),ln&&ln.indexOf(`Edg/`),ln&&ln.indexOf(`Android`);var mn=typeof navigator==`object`?navigator:{};cn||document.queryCommandSupported&&document.queryCommandSupported(`copy`)||mn&&mn.clipboard&&mn.clipboard.writeText,cn||mn&&mn.clipboard&&mn.clipboard.readText,cn||Kt()||mn.keyboard,`ontouchstart`in pt||mn.maxTouchPoints,pt.PointerEvent&&(`ontouchstart`in pt||navigator.maxTouchPoints);var hn=class{constructor(){this._keyCodeToStr=[],this._strToKeyCode=Object.create(null)}define(e,t){this._keyCodeToStr[e]=t,this._strToKeyCode[t.toLowerCase()]=e}keyCodeToStr(e){return this._keyCodeToStr[e]}strToKeyCode(e){return this._strToKeyCode[e.toLowerCase()]||0}},gn=new hn,_n=new hn,vn=new hn,yn=Array(230),bn;(e=>{function t(e){return gn.keyCodeToStr(e)}e.toString=t;function n(e){return gn.strToKeyCode(e)}e.fromString=n;function r(e){return _n.keyCodeToStr(e)}e.toUserSettingsUS=r;function i(e){return vn.keyCodeToStr(e)}e.toUserSettingsGeneral=i;function a(e){return _n.strToKeyCode(e)||vn.strToKeyCode(e)}e.fromUserSettings=a;function o(e){if(e>=98&&e<=113)return null;switch(e){case 16:return`Up`;case 18:return`Down`;case 15:return`Left`;case 17:return`Right`}return gn.keyCodeToStr(e)}e.toElectronAccelerator=o})(bn||={});var xn=class e{constructor(e,t,n,r,i){this.ctrlKey=e,this.shiftKey=t,this.altKey=n,this.metaKey=r,this.keyCode=i}equals(t){return t instanceof e&&this.ctrlKey===t.ctrlKey&&this.shiftKey===t.shiftKey&&this.altKey===t.altKey&&this.metaKey===t.metaKey&&this.keyCode===t.keyCode}getHashCode(){return`K${this.ctrlKey?`1`:`0`}${this.shiftKey?`1`:`0`}${this.altKey?`1`:`0`}${this.metaKey?`1`:`0`}${this.keyCode}`}isModifierKey(){return this.keyCode===0||this.keyCode===5||this.keyCode===57||this.keyCode===6||this.keyCode===4}toKeybinding(){return new Sn([this])}isDuplicateModifierCase(){return this.ctrlKey&&this.keyCode===5||this.shiftKey&&this.keyCode===4||this.altKey&&this.keyCode===6||this.metaKey&&this.keyCode===57}},Sn=class{constructor(e){if(e.length===0)throw ze(`chords`);this.chords=e}getHashCode(){let e=``;for(let t=0,n=this.chords.length;t{function t(t){return t===e.None||t===e.Cancelled||t instanceof In?!0:!t||typeof t!=`object`?!1:typeof t.isCancellationRequested==`boolean`&&typeof t.onCancellationRequested==`function`}e.isCancellationToken=t,e.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:xt.None}),e.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:Pn})})(Fn||={});var In=class{constructor(){this._isCancelled=!1,this._emitter=null}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?Pn:(this._emitter||=new R,this._emitter.event)}dispose(){this._emitter&&=(this._emitter.dispose(),null)}},Ln=class{constructor(e,t){this._isDisposed=!1,this._token=-1,typeof e==`function`&&typeof t==`number`&&this.setIfNotSet(e,t)}dispose(){this.cancel(),this._isDisposed=!0}cancel(){this._token!==-1&&(clearTimeout(this._token),this._token=-1)}cancelAndSet(e,t){if(this._isDisposed)throw new Ve(`Calling 'cancelAndSet' on a disposed TimeoutTimer`);this.cancel(),this._token=setTimeout(()=>{this._token=-1,e()},t)}setIfNotSet(e,t){if(this._isDisposed)throw new Ve(`Calling 'setIfNotSet' on a disposed TimeoutTimer`);this._token===-1&&(this._token=setTimeout(()=>{this._token=-1,e()},t))}},Rn=class{constructor(){this.disposable=void 0,this.isDisposed=!1}cancel(){this.disposable?.dispose(),this.disposable=void 0}cancelAndSet(e,t,n=globalThis){if(this.isDisposed)throw new Ve(`Calling 'cancelAndSet' on a disposed IntervalTimer`);this.cancel();let r=n.setInterval(()=>{e()},t);this.disposable=F(()=>{n.clearInterval(r),this.disposable=void 0})}dispose(){this.cancel(),this.isDisposed=!0}};(function(){typeof globalThis.requestIdleCallback!=`function`||globalThis.cancelIdleCallback})();var zn;(e=>{async function t(e){let t,n=await Promise.all(e.map(e=>e.then(e=>e,e=>{t||=e})));if(typeof t<`u`)throw t;return n}e.settled=t;function n(e){return new Promise(async(t,n)=>{try{await e(t,n)}catch(e){n(e)}})}e.withAsyncBody=n})(zn||={});var Bn=class e{static fromArray(t){return new e(e=>{e.emitMany(t)})}static fromPromise(t){return new e(async e=>{e.emitMany(await t)})}static fromPromises(t){return new e(async e=>{await Promise.all(t.map(async t=>e.emitOne(await t)))})}static merge(t){return new e(async e=>{await Promise.all(t.map(async t=>{for await(let n of t)e.emitOne(n)}))})}constructor(e,t){this._state=0,this._results=[],this._error=null,this._onReturn=t,this._onStateChanged=new R,queueMicrotask(async()=>{let t={emitOne:e=>this.emitOne(e),emitMany:e=>this.emitMany(e),reject:e=>this.reject(e)};try{await Promise.resolve(e(t)),this.resolve()}catch(e){this.reject(e)}finally{t.emitOne=void 0,t.emitMany=void 0,t.reject=void 0}})}[Symbol.asyncIterator](){let e=0;return{next:async()=>{do{if(this._state===2)throw this._error;if(e(this._onReturn?.(),{done:!0,value:void 0})}}static map(t,n){return new e(async e=>{for await(let r of t)e.emitOne(n(r))})}map(t){return e.map(this,t)}static filter(t,n){return new e(async e=>{for await(let r of t)n(r)&&e.emitOne(r)})}filter(t){return e.filter(this,t)}static coalesce(t){return e.filter(t,e=>!!e)}coalesce(){return e.coalesce(this)}static async toPromise(e){let t=[];for await(let n of e)t.push(n);return t}toPromise(){return e.toPromise(this)}emitOne(e){this._state===0&&(this._results.push(e),this._onStateChanged.fire())}emitMany(e){this._state===0&&(this._results=this._results.concat(e),this._onStateChanged.fire())}resolve(){this._state===0&&(this._state=1,this._onStateChanged.fire())}reject(e){this._state===0&&(this._state=2,this._error=e,this._onStateChanged.fire())}};Bn.EMPTY=Bn.fromArray([]);function Vn(e){return 55296<=e&&e<=56319}function Hn(e){return 56320<=e&&e<=57343}function Un(e,t){return(e-55296<<10)+(t-56320)+65536}function Wn(e){return Gn(e,0)}function Gn(e,t){switch(typeof e){case`object`:return e===null?Kn(349,t):Array.isArray(e)?Yn(e,t):Xn(e,t);case`string`:return Jn(e,t);case`boolean`:return qn(e,t);case`number`:return Kn(e,t);case`undefined`:return Kn(937,t);default:return Kn(617,t)}}function Kn(e,t){return(t<<5)-t+e|0}function qn(e,t){return Kn(e?433:863,t)}function Jn(e,t){t=Kn(149417,t);for(let n=0,r=e.length;nGn(t,e),t)}function Xn(e,t){return t=Kn(181387,t),Object.keys(e).sort().reduce((t,n)=>(t=Jn(n,t),Gn(e[n],t)),t)}function Zn(e,t,n=32){let r=n-t,i=~((1<>>r)>>>0}function Qn(e,t=0,n=e.byteLength,r=0){for(let i=0;ie.toString(16).padStart(2,`0`)).join(``):$n((e>>>0).toString(16),t/4)}var tr=class e{constructor(){this._h0=1732584193,this._h1=4023233417,this._h2=2562383102,this._h3=271733878,this._h4=3285377520,this._buff=new Uint8Array(67),this._buffDV=new DataView(this._buff.buffer),this._buffLen=0,this._totalLen=0,this._leftoverHighSurrogate=0,this._finished=!1}update(e){let t=e.length;if(t===0)return;let n=this._buff,r=this._buffLen,i=this._leftoverHighSurrogate,a,o;for(i===0?(a=e.charCodeAt(0),o=0):(a=i,o=-1,i=0);;){let s=a;if(Vn(a))if(o+1>>6,e[t++]=128|(n&63)>>>0):n<65536?(e[t++]=224|(n&61440)>>>12,e[t++]=128|(n&4032)>>>6,e[t++]=128|(n&63)>>>0):(e[t++]=240|(n&1835008)>>>18,e[t++]=128|(n&258048)>>>12,e[t++]=128|(n&4032)>>>6,e[t++]=128|(n&63)>>>0),t>=64&&(this._step(),t-=64,this._totalLen+=64,e[0]=e[64],e[1]=e[65],e[2]=e[66]),t}digest(){return this._finished||(this._finished=!0,this._leftoverHighSurrogate&&(this._leftoverHighSurrogate=0,this._buffLen=this._push(this._buff,this._buffLen,65533)),this._totalLen+=this._buffLen,this._wrapUp()),er(this._h0)+er(this._h1)+er(this._h2)+er(this._h3)+er(this._h4)}_wrapUp(){this._buff[this._buffLen++]=128,Qn(this._buff,this._buffLen),this._buffLen>56&&(this._step(),Qn(this._buff));let e=8*this._totalLen;this._buffDV.setUint32(56,Math.floor(e/4294967296),!1),this._buffDV.setUint32(60,e%4294967296,!1),this._step()}_step(){let t=e._bigBlock32,n=this._buffDV;for(let e=0;e<64;e+=4)t.setUint32(e,n.getUint32(e,!1),!1);for(let e=64;e<320;e+=4)t.setUint32(e,Zn(t.getUint32(e-12,!1)^t.getUint32(e-32,!1)^t.getUint32(e-56,!1)^t.getUint32(e-64,!1),1),!1);let r=this._h0,i=this._h1,a=this._h2,o=this._h3,s=this._h4,c,l,u;for(let e=0;e<80;e++)e<20?(c=i&a|~i&o,l=1518500249):e<40?(c=i^a^o,l=1859775393):e<60?(c=i&a|i&o|a&o,l=2400959708):(c=i^a^o,l=3395469782),u=Zn(r,5)+c+s+l+t.getUint32(e*4,!1)&4294967295,s=o,o=a,a=Zn(i,30),i=r,r=u;this._h0=this._h0+r&4294967295,this._h1=this._h1+i&4294967295,this._h2=this._h2+a&4294967295,this._h3=this._h3+o&4294967295,this._h4=this._h4+s&4294967295}};tr._bigBlock32=new DataView(new ArrayBuffer(320));var{registerWindow:nr,getWindow:rr,getDocument:ir,getWindows:ar,getWindowsCount:or,getWindowId:sr,getWindowById:cr,hasWindow:lr,onDidRegisterWindow:ur,onWillUnregisterWindow:dr,onDidUnregisterWindow:fr}=function(){let e=new Map,t={window:pt,disposables:new dt};e.set(pt.vscodeWindowId,t);let n=new R,r=new R,i=new R;function a(n,r){return(typeof n==`number`?e.get(n):void 0)??(r?t:void 0)}return{onDidRegisterWindow:n.event,onWillUnregisterWindow:i.event,onDidUnregisterWindow:r.event,registerWindow(t){if(e.has(t.vscodeWindowId))return I.None;let a=new dt,o={window:t,disposables:a.add(new dt)};return e.set(t.vscodeWindowId,o),a.add(F(()=>{e.delete(t.vscodeWindowId),r.fire(t)})),a.add(z(t,Sr.BEFORE_UNLOAD,()=>{i.fire(t)})),n.fire(o),a},getWindows(){return e.values()},getWindowsCount(){return e.size},getWindowId(e){return e.vscodeWindowId},hasWindow(t){return e.has(t)},getWindowById:a,getWindow(e){let t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView.window;let n=e;return n?.view?n.view.window:pt},getDocument(e){return rr(e).document}}}(),pr=class{constructor(e,t,n,r){this._node=e,this._type=t,this._handler=n,this._options=r||!1,this._node.addEventListener(this._type,this._handler,this._options)}dispose(){this._handler&&=(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,null)}};function z(e,t,n,r){return new pr(e,t,n,r)}function mr(e,t){return function(n){return t(new Mn(e,n))}}function hr(e){return function(t){return e(new On(t))}}var gr=function(e,t,n,r){let i=n;return t===`click`||t===`mousedown`||t===`contextmenu`?i=mr(rr(e),n):(t===`keydown`||t===`keypress`||t===`keyup`)&&(i=hr(n)),z(e,t,i,r)},_r,vr=class extends Rn{constructor(e){super(),this.defaultTarget=e&&rr(e)}cancelAndSet(e,t,n){return super.cancelAndSet(e,t,n??this.defaultTarget)}},yr=class{constructor(e,t=0){this._runner=e,this.priority=t,this._canceled=!1}dispose(){this._canceled=!0}execute(){if(!this._canceled)try{this._runner()}catch(e){Fe(e)}}static sort(e,t){return t.priority-e.priority}};(function(){let e=new Map,t=new Map,n=new Map,r=new Map,i=i=>{n.set(i,!1);let a=e.get(i)??[];for(t.set(i,a),e.set(i,[]),r.set(i,!0);a.length>0;)a.sort(yr.sort),a.shift().execute();r.set(i,!1)};_r=(t,r,a=0)=>{let o=sr(t),s=new yr(r,a),c=e.get(o);return c||(c=[],e.set(o,c)),c.push(s),n.get(o)||(n.set(o,!0),t.requestAnimationFrame(()=>i(o))),s}})();var br=class e{constructor(e,t){this.width=e,this.height=t}with(t=this.width,n=this.height){return t!==this.width||n!==this.height?new e(t,n):this}static is(e){return typeof e==`object`&&typeof e.height==`number`&&typeof e.width==`number`}static lift(t){return t instanceof e?t:new e(t.width,t.height)}static equals(e,t){return e===t?!0:!e||!t?!1:e.width===t.width&&e.height===t.height}};br.None=new br(0,0);function xr(e){let t=e.getBoundingClientRect(),n=rr(e);return{left:t.left+n.scrollX,top:t.top+n.scrollY,width:t.width,height:t.height}}new class{constructor(){this.mutationObservers=new Map}observe(e,t,n){let r=this.mutationObservers.get(e);r||(r=new Map,this.mutationObservers.set(e,r));let i=Wn(n),a=r.get(i);if(a)a.users+=1;else{let o=new R,s=new MutationObserver(e=>o.fire(e));s.observe(e,n);let c=a={users:1,observer:s,onDidMutate:o.event};t.add(F(()=>{--c.users,c.users===0&&(o.dispose(),s.disconnect(),r?.delete(i),r?.size===0&&this.mutationObservers.delete(e))})),r.set(i,a)}return a.onDidMutate}};var Sr={CLICK:`click`,AUXCLICK:`auxclick`,DBLCLICK:`dblclick`,MOUSE_UP:`mouseup`,MOUSE_DOWN:`mousedown`,MOUSE_OVER:`mouseover`,MOUSE_MOVE:`mousemove`,MOUSE_OUT:`mouseout`,MOUSE_ENTER:`mouseenter`,MOUSE_LEAVE:`mouseleave`,MOUSE_WHEEL:`wheel`,POINTER_UP:`pointerup`,POINTER_DOWN:`pointerdown`,POINTER_MOVE:`pointermove`,POINTER_LEAVE:`pointerleave`,CONTEXT_MENU:`contextmenu`,WHEEL:`wheel`,KEY_DOWN:`keydown`,KEY_PRESS:`keypress`,KEY_UP:`keyup`,LOAD:`load`,BEFORE_UNLOAD:`beforeunload`,UNLOAD:`unload`,PAGE_SHOW:`pageshow`,PAGE_HIDE:`pagehide`,PASTE:`paste`,ABORT:`abort`,ERROR:`error`,RESIZE:`resize`,SCROLL:`scroll`,FULLSCREEN_CHANGE:`fullscreenchange`,WK_FULLSCREEN_CHANGE:`webkitfullscreenchange`,SELECT:`select`,CHANGE:`change`,SUBMIT:`submit`,RESET:`reset`,FOCUS:`focus`,FOCUS_IN:`focusin`,FOCUS_OUT:`focusout`,BLUR:`blur`,INPUT:`input`,STORAGE:`storage`,DRAG_START:`dragstart`,DRAG:`drag`,DRAG_ENTER:`dragenter`,DRAG_LEAVE:`dragleave`,DRAG_OVER:`dragover`,DROP:`drop`,DRAG_END:`dragend`,ANIMATION_START:Ht?`webkitAnimationStart`:`animationstart`,ANIMATION_END:Ht?`webkitAnimationEnd`:`animationend`,ANIMATION_ITERATION:Ht?`webkitAnimationIteration`:`animationiteration`},Cr=/([\w\-]+)?(#([\w\-]+))?((\.([\w\-]+))*)/;function wr(e,t,n,...r){let i=Cr.exec(t);if(!i)throw Error(`Bad use of emmet`);let a=i[1]||`div`,o;return o=e===`http://www.w3.org/1999/xhtml`?document.createElement(a):document.createElementNS(e,a),i[3]&&(o.id=i[3]),i[4]&&(o.className=i[4].replace(/\./g,` `).trim()),n&&Object.entries(n).forEach(([e,t])=>{typeof t>`u`||(/^on\w+$/.test(e)?o[e]=t:e===`selected`?t&&o.setAttribute(e,`true`):o.setAttribute(e,t))}),o.append(...r),o}function Tr(e,t,...n){return wr(`http://www.w3.org/1999/xhtml`,e,t,...n)}Tr.SVG=function(e,t,...n){return wr(`http://www.w3.org/2000/svg`,e,t,...n)};var Er=class{constructor(e){this.domNode=e,this._maxWidth=``,this._width=``,this._height=``,this._top=``,this._left=``,this._bottom=``,this._right=``,this._paddingTop=``,this._paddingLeft=``,this._paddingBottom=``,this._paddingRight=``,this._fontFamily=``,this._fontWeight=``,this._fontSize=``,this._fontStyle=``,this._fontFeatureSettings=``,this._fontVariationSettings=``,this._textDecoration=``,this._lineHeight=``,this._letterSpacing=``,this._className=``,this._display=``,this._position=``,this._visibility=``,this._color=``,this._backgroundColor=``,this._layerHint=!1,this._contain=`none`,this._boxShadow=``}setMaxWidth(e){let t=Dr(e);this._maxWidth!==t&&(this._maxWidth=t,this.domNode.style.maxWidth=this._maxWidth)}setWidth(e){let t=Dr(e);this._width!==t&&(this._width=t,this.domNode.style.width=this._width)}setHeight(e){let t=Dr(e);this._height!==t&&(this._height=t,this.domNode.style.height=this._height)}setTop(e){let t=Dr(e);this._top!==t&&(this._top=t,this.domNode.style.top=this._top)}setLeft(e){let t=Dr(e);this._left!==t&&(this._left=t,this.domNode.style.left=this._left)}setBottom(e){let t=Dr(e);this._bottom!==t&&(this._bottom=t,this.domNode.style.bottom=this._bottom)}setRight(e){let t=Dr(e);this._right!==t&&(this._right=t,this.domNode.style.right=this._right)}setPaddingTop(e){let t=Dr(e);this._paddingTop!==t&&(this._paddingTop=t,this.domNode.style.paddingTop=this._paddingTop)}setPaddingLeft(e){let t=Dr(e);this._paddingLeft!==t&&(this._paddingLeft=t,this.domNode.style.paddingLeft=this._paddingLeft)}setPaddingBottom(e){let t=Dr(e);this._paddingBottom!==t&&(this._paddingBottom=t,this.domNode.style.paddingBottom=this._paddingBottom)}setPaddingRight(e){let t=Dr(e);this._paddingRight!==t&&(this._paddingRight=t,this.domNode.style.paddingRight=this._paddingRight)}setFontFamily(e){this._fontFamily!==e&&(this._fontFamily=e,this.domNode.style.fontFamily=this._fontFamily)}setFontWeight(e){this._fontWeight!==e&&(this._fontWeight=e,this.domNode.style.fontWeight=this._fontWeight)}setFontSize(e){let t=Dr(e);this._fontSize!==t&&(this._fontSize=t,this.domNode.style.fontSize=this._fontSize)}setFontStyle(e){this._fontStyle!==e&&(this._fontStyle=e,this.domNode.style.fontStyle=this._fontStyle)}setFontFeatureSettings(e){this._fontFeatureSettings!==e&&(this._fontFeatureSettings=e,this.domNode.style.fontFeatureSettings=this._fontFeatureSettings)}setFontVariationSettings(e){this._fontVariationSettings!==e&&(this._fontVariationSettings=e,this.domNode.style.fontVariationSettings=this._fontVariationSettings)}setTextDecoration(e){this._textDecoration!==e&&(this._textDecoration=e,this.domNode.style.textDecoration=this._textDecoration)}setLineHeight(e){let t=Dr(e);this._lineHeight!==t&&(this._lineHeight=t,this.domNode.style.lineHeight=this._lineHeight)}setLetterSpacing(e){let t=Dr(e);this._letterSpacing!==t&&(this._letterSpacing=t,this.domNode.style.letterSpacing=this._letterSpacing)}setClassName(e){this._className!==e&&(this._className=e,this.domNode.className=this._className)}toggleClassName(e,t){this.domNode.classList.toggle(e,t),this._className=this.domNode.className}setDisplay(e){this._display!==e&&(this._display=e,this.domNode.style.display=this._display)}setPosition(e){this._position!==e&&(this._position=e,this.domNode.style.position=this._position)}setVisibility(e){this._visibility!==e&&(this._visibility=e,this.domNode.style.visibility=this._visibility)}setColor(e){this._color!==e&&(this._color=e,this.domNode.style.color=this._color)}setBackgroundColor(e){this._backgroundColor!==e&&(this._backgroundColor=e,this.domNode.style.backgroundColor=this._backgroundColor)}setLayerHinting(e){this._layerHint!==e&&(this._layerHint=e,this.domNode.style.transform=this._layerHint?`translate3d(0px, 0px, 0px)`:``)}setBoxShadow(e){this._boxShadow!==e&&(this._boxShadow=e,this.domNode.style.boxShadow=e)}setContain(e){this._contain!==e&&(this._contain=e,this.domNode.style.contain=this._contain)}setAttribute(e,t){this.domNode.setAttribute(e,t)}removeAttribute(e){this.domNode.removeAttribute(e)}appendChild(e){this.domNode.appendChild(e.domNode)}removeChild(e){this.domNode.removeChild(e.domNode)}};function Dr(e){return typeof e==`number`?`${e}px`:e}function Or(e){return new Er(e)}var kr=class{constructor(){this._hooks=new dt,this._pointerMoveCallback=null,this._onStopCallback=null}dispose(){this.stopMonitoring(!1),this._hooks.dispose()}stopMonitoring(e,t){if(!this.isMonitoring())return;this._hooks.clear(),this._pointerMoveCallback=null;let n=this._onStopCallback;this._onStopCallback=null,e&&n&&n(t)}isMonitoring(){return!!this._pointerMoveCallback}startMonitoring(e,t,n,r,i){this.isMonitoring()&&this.stopMonitoring(!1),this._pointerMoveCallback=r,this._onStopCallback=i;let a=e;try{e.setPointerCapture(t),this._hooks.add(F(()=>{try{e.releasePointerCapture(t)}catch{}}))}catch{a=rr(e)}this._hooks.add(z(a,Sr.POINTER_MOVE,e=>{if(e.buttons!==n){this.stopMonitoring(!0);return}e.preventDefault(),this._pointerMoveCallback(e)})),this._hooks.add(z(a,Sr.POINTER_UP,e=>this.stopMonitoring(!0)))}};function Ar(e,t,n){let r=null,i=null;if(typeof n.value==`function`?(r=`value`,i=n.value,i.length!==0&&console.warn(`Memoize should only be used in functions with zero parameters`)):typeof n.get==`function`&&(r=`get`,i=n.get),!i)throw Error(`not supported`);let a=`$memoize$${t}`;n[r]=function(...e){return this.hasOwnProperty(a)||Object.defineProperty(this,a,{configurable:!1,enumerable:!1,writable:!1,value:i.apply(this,e)}),this[a]}}var jr;(e=>(e.Tap=`-xterm-gesturetap`,e.Change=`-xterm-gesturechange`,e.Start=`-xterm-gesturestart`,e.End=`-xterm-gesturesend`,e.Contextmenu=`-xterm-gesturecontextmenu`))(jr||={});var Mr=class e extends I{constructor(){super(),this.dispatched=!1,this.targets=new ht,this.ignoreTargets=new ht,this.activeTouches={},this.handle=null,this._lastSetTapCountTime=0,this._register(xt.runAndSubscribe(ur,({window:e,disposables:t})=>{t.add(z(e.document,`touchstart`,e=>this.onTouchStart(e),{passive:!1})),t.add(z(e.document,`touchend`,t=>this.onTouchEnd(e,t))),t.add(z(e.document,`touchmove`,e=>this.onTouchMove(e),{passive:!1}))},{window:pt,disposables:this._store}))}static addTarget(t){return e.isTouchDevice()?(e.INSTANCE||=st(new e),F(e.INSTANCE.targets.push(t))):I.None}static ignoreTarget(t){return e.isTouchDevice()?(e.INSTANCE||=st(new e),F(e.INSTANCE.ignoreTargets.push(t))):I.None}static isTouchDevice(){return`ontouchstart`in pt||navigator.maxTouchPoints>0}dispose(){this.handle&&=(this.handle.dispose(),null),super.dispose()}onTouchStart(e){let t=Date.now();this.handle&&=(this.handle.dispose(),null);for(let n=0,r=e.targetTouches.length;n=e.HOLD_DELAY&&Math.abs(s.initialPageX-We(s.rollingPageX))<30&&Math.abs(s.initialPageY-We(s.rollingPageY))<30){let e=this.newGestureEvent(jr.Contextmenu,s.initialTarget);e.pageX=We(s.rollingPageX),e.pageY=We(s.rollingPageY),this.dispatchEvent(e)}else if(i===1){let e=We(s.rollingPageX),n=We(s.rollingPageY),i=We(s.rollingTimestamps)-s.rollingTimestamps[0],a=e-s.rollingPageX[0],o=n-s.rollingPageY[0],c=[...this.targets].filter(e=>s.initialTarget instanceof Node&&e.contains(s.initialTarget));this.inertia(t,c,r,Math.abs(a)/i,a>0?1:-1,e,Math.abs(o)/i,o>0?1:-1,n)}this.dispatchEvent(this.newGestureEvent(jr.End,s.initialTarget)),delete this.activeTouches[o.identifier]}this.dispatched&&=(n.preventDefault(),n.stopPropagation(),!1)}newGestureEvent(e,t){let n=document.createEvent(`CustomEvent`);return n.initEvent(e,!1,!0),n.initialTarget=t,n.tapCount=0,n}dispatchEvent(t){if(t.type===jr.Tap){let n=new Date().getTime(),r=0;r=n-this._lastSetTapCountTime>e.CLEAR_TAP_COUNT_TIME?1:2,this._lastSetTapCountTime=n,t.tapCount=r}else (t.type===jr.Change||t.type===jr.Contextmenu)&&(this._lastSetTapCountTime=0);if(t.initialTarget instanceof Node){for(let e of this.ignoreTargets)if(e.contains(t.initialTarget))return;let e=[];for(let n of this.targets)if(n.contains(t.initialTarget)){let r=0,i=t.initialTarget;for(;i&&i!==n;)r++,i=i.parentElement;e.push([r,n])}e.sort((e,t)=>e[0]-t[0]);for(let[n,r]of e)r.dispatchEvent(t),this.dispatched=!0}}inertia(t,n,r,i,a,o,s,c,l){this.handle=_r(t,()=>{let u=Date.now(),d=u-r,f=0,p=0,m=!0;i+=e.SCROLL_FRICTION*d,s+=e.SCROLL_FRICTION*d,i>0&&(m=!1,f=a*i*d),s>0&&(m=!1,p=c*s*d);let h=this.newGestureEvent(jr.Change);h.translationX=f,h.translationY=p,n.forEach(e=>e.dispatchEvent(h)),m||this.inertia(t,n,u,i,a,o+f,s,c,l+p)})}onTouchMove(e){let t=Date.now();for(let n=0,r=e.changedTouches.length;n3&&(i.rollingPageX.shift(),i.rollingPageY.shift(),i.rollingTimestamps.shift()),i.rollingPageX.push(r.pageX),i.rollingPageY.push(r.pageY),i.rollingTimestamps.push(t)}this.dispatched&&=(e.preventDefault(),e.stopPropagation(),!1)}};Mr.SCROLL_FRICTION=-.005,Mr.HOLD_DELAY=700,Mr.CLEAR_TAP_COUNT_TIME=400,x([Ar],Mr,`isTouchDevice`,1);var Nr=Mr,Pr=class extends I{onclick(e,t){this._register(z(e,Sr.CLICK,n=>t(new Mn(rr(e),n))))}onmousedown(e,t){this._register(z(e,Sr.MOUSE_DOWN,n=>t(new Mn(rr(e),n))))}onmouseover(e,t){this._register(z(e,Sr.MOUSE_OVER,n=>t(new Mn(rr(e),n))))}onmouseleave(e,t){this._register(z(e,Sr.MOUSE_LEAVE,n=>t(new Mn(rr(e),n))))}onkeydown(e,t){this._register(z(e,Sr.KEY_DOWN,e=>t(new On(e))))}onkeyup(e,t){this._register(z(e,Sr.KEY_UP,e=>t(new On(e))))}oninput(e,t){this._register(z(e,Sr.INPUT,t))}onblur(e,t){this._register(z(e,Sr.BLUR,t))}onfocus(e,t){this._register(z(e,Sr.FOCUS,t))}onchange(e,t){this._register(z(e,Sr.CHANGE,t))}ignoreGesture(e){return Nr.ignoreTarget(e)}},Fr=11,Ir=class extends Pr{constructor(e){super(),this._onActivate=e.onActivate,this.bgDomNode=document.createElement(`div`),this.bgDomNode.className=`arrow-background`,this.bgDomNode.style.position=`absolute`,this.bgDomNode.style.width=e.bgWidth+`px`,this.bgDomNode.style.height=e.bgHeight+`px`,typeof e.top<`u`&&(this.bgDomNode.style.top=`0px`),typeof e.left<`u`&&(this.bgDomNode.style.left=`0px`),typeof e.bottom<`u`&&(this.bgDomNode.style.bottom=`0px`),typeof e.right<`u`&&(this.bgDomNode.style.right=`0px`),this.domNode=document.createElement(`div`),this.domNode.className=e.className,this.domNode.style.position=`absolute`,this.domNode.style.width=Fr+`px`,this.domNode.style.height=Fr+`px`,typeof e.top<`u`&&(this.domNode.style.top=e.top+`px`),typeof e.left<`u`&&(this.domNode.style.left=e.left+`px`),typeof e.bottom<`u`&&(this.domNode.style.bottom=e.bottom+`px`),typeof e.right<`u`&&(this.domNode.style.right=e.right+`px`),this._pointerMoveMonitor=this._register(new kr),this._register(gr(this.bgDomNode,Sr.POINTER_DOWN,e=>this._arrowPointerDown(e))),this._register(gr(this.domNode,Sr.POINTER_DOWN,e=>this._arrowPointerDown(e))),this._pointerdownRepeatTimer=this._register(new vr),this._pointerdownScheduleRepeatTimer=this._register(new Ln)}_arrowPointerDown(e){!e.target||!(e.target instanceof Element)||(this._onActivate(),this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancelAndSet(()=>{this._pointerdownRepeatTimer.cancelAndSet(()=>this._onActivate(),1e3/24,rr(e))},200),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{},()=>{this._pointerdownRepeatTimer.cancel(),this._pointerdownScheduleRepeatTimer.cancel()}),e.preventDefault())}},Lr=class e{constructor(e,t,n,r,i,a,o){this._forceIntegerValues=e,this._scrollStateBrand=void 0,this._forceIntegerValues&&(t|=0,n|=0,r|=0,i|=0,a|=0,o|=0),this.rawScrollLeft=r,this.rawScrollTop=o,t<0&&(t=0),r+t>n&&(r=n-t),r<0&&(r=0),i<0&&(i=0),o+i>a&&(o=a-i),o<0&&(o=0),this.width=t,this.scrollWidth=n,this.scrollLeft=r,this.height=i,this.scrollHeight=a,this.scrollTop=o}equals(e){return this.rawScrollLeft===e.rawScrollLeft&&this.rawScrollTop===e.rawScrollTop&&this.width===e.width&&this.scrollWidth===e.scrollWidth&&this.scrollLeft===e.scrollLeft&&this.height===e.height&&this.scrollHeight===e.scrollHeight&&this.scrollTop===e.scrollTop}withScrollDimensions(t,n){return new e(this._forceIntegerValues,typeof t.width<`u`?t.width:this.width,typeof t.scrollWidth<`u`?t.scrollWidth:this.scrollWidth,n?this.rawScrollLeft:this.scrollLeft,typeof t.height<`u`?t.height:this.height,typeof t.scrollHeight<`u`?t.scrollHeight:this.scrollHeight,n?this.rawScrollTop:this.scrollTop)}withScrollPosition(t){return new e(this._forceIntegerValues,this.width,this.scrollWidth,typeof t.scrollLeft<`u`?t.scrollLeft:this.rawScrollLeft,this.height,this.scrollHeight,typeof t.scrollTop<`u`?t.scrollTop:this.rawScrollTop)}createScrollEvent(e,t){let n=this.width!==e.width,r=this.scrollWidth!==e.scrollWidth,i=this.scrollLeft!==e.scrollLeft,a=this.height!==e.height,o=this.scrollHeight!==e.scrollHeight,s=this.scrollTop!==e.scrollTop;return{inSmoothScrolling:t,oldWidth:e.width,oldScrollWidth:e.scrollWidth,oldScrollLeft:e.scrollLeft,width:this.width,scrollWidth:this.scrollWidth,scrollLeft:this.scrollLeft,oldHeight:e.height,oldScrollHeight:e.scrollHeight,oldScrollTop:e.scrollTop,height:this.height,scrollHeight:this.scrollHeight,scrollTop:this.scrollTop,widthChanged:n,scrollWidthChanged:r,scrollLeftChanged:i,heightChanged:a,scrollHeightChanged:o,scrollTopChanged:s}}},Rr=class extends I{constructor(e){super(),this._scrollableBrand=void 0,this._onScroll=this._register(new R),this.onScroll=this._onScroll.event,this._smoothScrollDuration=e.smoothScrollDuration,this._scheduleAtNextAnimationFrame=e.scheduleAtNextAnimationFrame,this._state=new Lr(e.forceIntegerValues,0,0,0,0,0,0),this._smoothScrolling=null}dispose(){this._smoothScrolling&&=(this._smoothScrolling.dispose(),null),super.dispose()}setSmoothScrollDuration(e){this._smoothScrollDuration=e}validateScrollPosition(e){return this._state.withScrollPosition(e)}getScrollDimensions(){return this._state}setScrollDimensions(e,t){let n=this._state.withScrollDimensions(e,t);this._setState(n,!!this._smoothScrolling),this._smoothScrolling?.acceptScrollDimensions(this._state)}getFutureScrollPosition(){return this._smoothScrolling?this._smoothScrolling.to:this._state}getCurrentScrollPosition(){return this._state}setScrollPositionNow(e){let t=this._state.withScrollPosition(e);this._smoothScrolling&&=(this._smoothScrolling.dispose(),null),this._setState(t,!1)}setScrollPositionSmooth(e,t){if(this._smoothScrollDuration===0)return this.setScrollPositionNow(e);if(this._smoothScrolling){e={scrollLeft:typeof e.scrollLeft>`u`?this._smoothScrolling.to.scrollLeft:e.scrollLeft,scrollTop:typeof e.scrollTop>`u`?this._smoothScrolling.to.scrollTop:e.scrollTop};let n=this._state.withScrollPosition(e);if(this._smoothScrolling.to.scrollLeft===n.scrollLeft&&this._smoothScrolling.to.scrollTop===n.scrollTop)return;let r;r=t?new Hr(this._smoothScrolling.from,n,this._smoothScrolling.startTime,this._smoothScrolling.duration):this._smoothScrolling.combine(this._state,n,this._smoothScrollDuration),this._smoothScrolling.dispose(),this._smoothScrolling=r}else{let t=this._state.withScrollPosition(e);this._smoothScrolling=Hr.start(this._state,t,this._smoothScrollDuration)}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}hasPendingScrollAnimation(){return!!this._smoothScrolling}_performSmoothScrolling(){if(!this._smoothScrolling)return;let e=this._smoothScrolling.tick(),t=this._state.withScrollPosition(e);if(this._setState(t,!0),this._smoothScrolling){if(e.isDone){this._smoothScrolling.dispose(),this._smoothScrolling=null;return}this._smoothScrolling.animationFrameDisposable=this._scheduleAtNextAnimationFrame(()=>{this._smoothScrolling&&(this._smoothScrolling.animationFrameDisposable=null,this._performSmoothScrolling())})}}_setState(e,t){let n=this._state;n.equals(e)||(this._state=e,this._onScroll.fire(this._state.createScrollEvent(n,t)))}},zr=class{constructor(e,t,n){this.scrollLeft=e,this.scrollTop=t,this.isDone=n}};function Br(e,t){let n=t-e;return function(t){return e+n*Wr(t)}}function Vr(e,t,n){return function(r){return r2.5*n){let r,i;return e{this._domNode?.setClassName(this._visibleClassName)},0))}_hide(e){this._revealTimer.cancel(),this._isVisible&&(this._isVisible=!1,this._domNode?.setClassName(this._invisibleClassName+(e?` fade`:``)))}},Kr=140,qr=class extends Pr{constructor(e){super(),this._lazyRender=e.lazyRender,this._host=e.host,this._scrollable=e.scrollable,this._scrollByPage=e.scrollByPage,this._scrollbarState=e.scrollbarState,this._visibilityController=this._register(new Gr(e.visibility,`visible scrollbar `+e.extraScrollbarClassName,`invisible scrollbar `+e.extraScrollbarClassName)),this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._pointerMoveMonitor=this._register(new kr),this._shouldRender=!0,this.domNode=Or(document.createElement(`div`)),this.domNode.setAttribute(`role`,`presentation`),this.domNode.setAttribute(`aria-hidden`,`true`),this._visibilityController.setDomNode(this.domNode),this.domNode.setPosition(`absolute`),this._register(z(this.domNode.domNode,Sr.POINTER_DOWN,e=>this._domNodePointerDown(e)))}_createArrow(e){let t=this._register(new Ir(e));this.domNode.domNode.appendChild(t.bgDomNode),this.domNode.domNode.appendChild(t.domNode)}_createSlider(e,t,n,r){this.slider=Or(document.createElement(`div`)),this.slider.setClassName(`slider`),this.slider.setPosition(`absolute`),this.slider.setTop(e),this.slider.setLeft(t),typeof n==`number`&&this.slider.setWidth(n),typeof r==`number`&&this.slider.setHeight(r),this.slider.setLayerHinting(!0),this.slider.setContain(`strict`),this.domNode.domNode.appendChild(this.slider.domNode),this._register(z(this.slider.domNode,Sr.POINTER_DOWN,e=>{e.button===0&&(e.preventDefault(),this._sliderPointerDown(e))})),this.onclick(this.slider.domNode,e=>{e.leftButton&&e.stopPropagation()})}_onElementSize(e){return this._scrollbarState.setVisibleSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollSize(e){return this._scrollbarState.setScrollSize(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}_onElementScrollPosition(e){return this._scrollbarState.setScrollPosition(e)&&(this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded()),this._shouldRender=!0,this._lazyRender||this.render()),this._shouldRender}beginReveal(){this._visibilityController.setShouldBeVisible(!0)}beginHide(){this._visibilityController.setShouldBeVisible(!1)}render(){this._shouldRender&&(this._shouldRender=!1,this._renderDomNode(this._scrollbarState.getRectangleLargeSize(),this._scrollbarState.getRectangleSmallSize()),this._updateSlider(this._scrollbarState.getSliderSize(),this._scrollbarState.getArrowSize()+this._scrollbarState.getSliderPosition()))}_domNodePointerDown(e){e.target===this.domNode.domNode&&this._onPointerDown(e)}delegatePointerDown(e){let t=this.domNode.domNode.getClientRects()[0].top,n=t+this._scrollbarState.getSliderPosition(),r=t+this._scrollbarState.getSliderPosition()+this._scrollbarState.getSliderSize(),i=this._sliderPointerPosition(e);n<=i&&i<=r?e.button===0&&(e.preventDefault(),this._sliderPointerDown(e)):this._onPointerDown(e)}_onPointerDown(e){let t,n;if(e.target===this.domNode.domNode&&typeof e.offsetX==`number`&&typeof e.offsetY==`number`)t=e.offsetX,n=e.offsetY;else{let r=xr(this.domNode.domNode);t=e.pageX-r.left,n=e.pageY-r.top}let r=this._pointerDownRelativePosition(t,n);this._setDesiredScrollPositionNow(this._scrollByPage?this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(r):this._scrollbarState.getDesiredScrollPositionFromOffset(r)),e.button===0&&(e.preventDefault(),this._sliderPointerDown(e))}_sliderPointerDown(e){if(!e.target||!(e.target instanceof Element))return;let t=this._sliderPointerPosition(e),n=this._sliderOrthogonalPointerPosition(e),r=this._scrollbarState.clone();this.slider.toggleClassName(`active`,!0),this._pointerMoveMonitor.startMonitoring(e.target,e.pointerId,e.buttons,e=>{let i=this._sliderOrthogonalPointerPosition(e),a=Math.abs(i-n);if(an&&a>Kr){this._setDesiredScrollPositionNow(r.getScrollPosition());return}let o=this._sliderPointerPosition(e)-t;this._setDesiredScrollPositionNow(r.getDesiredScrollPositionFromDelta(o))},()=>{this.slider.toggleClassName(`active`,!1),this._host.onDragEnd()}),this._host.onDragStart()}_setDesiredScrollPositionNow(e){let t={};this.writeScrollPosition(t,e),this._scrollable.setScrollPositionNow(t)}updateScrollbarSize(e){this._updateScrollbarSize(e),this._scrollbarState.setScrollbarSize(e),this._shouldRender=!0,this._lazyRender||this.render()}isNeeded(){return this._scrollbarState.isNeeded()}},Jr=class e{constructor(e,t,n,r,i,a){this._scrollbarSize=Math.round(t),this._oppositeScrollbarSize=Math.round(n),this._arrowSize=Math.round(e),this._visibleSize=r,this._scrollSize=i,this._scrollPosition=a,this._computedAvailableSize=0,this._computedIsNeeded=!1,this._computedSliderSize=0,this._computedSliderRatio=0,this._computedSliderPosition=0,this._refreshComputedValues()}clone(){return new e(this._arrowSize,this._scrollbarSize,this._oppositeScrollbarSize,this._visibleSize,this._scrollSize,this._scrollPosition)}setVisibleSize(e){let t=Math.round(e);return this._visibleSize===t?!1:(this._visibleSize=t,this._refreshComputedValues(),!0)}setScrollSize(e){let t=Math.round(e);return this._scrollSize===t?!1:(this._scrollSize=t,this._refreshComputedValues(),!0)}setScrollPosition(e){let t=Math.round(e);return this._scrollPosition===t?!1:(this._scrollPosition=t,this._refreshComputedValues(),!0)}setScrollbarSize(e){this._scrollbarSize=Math.round(e)}setOppositeScrollbarSize(e){this._oppositeScrollbarSize=Math.round(e)}static _computeValues(e,t,n,r,i){let a=Math.max(0,n-e),o=Math.max(0,a-2*t),s=r>0&&r>n;if(!s)return{computedAvailableSize:Math.round(a),computedIsNeeded:s,computedSliderSize:Math.round(o),computedSliderRatio:0,computedSliderPosition:0};let c=Math.round(Math.max(20,Math.floor(n*o/r))),l=(o-c)/(r-n),u=i*l;return{computedAvailableSize:Math.round(a),computedIsNeeded:s,computedSliderSize:Math.round(c),computedSliderRatio:l,computedSliderPosition:Math.round(u)}}_refreshComputedValues(){let t=e._computeValues(this._oppositeScrollbarSize,this._arrowSize,this._visibleSize,this._scrollSize,this._scrollPosition);this._computedAvailableSize=t.computedAvailableSize,this._computedIsNeeded=t.computedIsNeeded,this._computedSliderSize=t.computedSliderSize,this._computedSliderRatio=t.computedSliderRatio,this._computedSliderPosition=t.computedSliderPosition}getArrowSize(){return this._arrowSize}getScrollPosition(){return this._scrollPosition}getRectangleLargeSize(){return this._computedAvailableSize}getRectangleSmallSize(){return this._scrollbarSize}isNeeded(){return this._computedIsNeeded}getSliderSize(){return this._computedSliderSize}getSliderPosition(){return this._computedSliderPosition}getDesiredScrollPositionFromOffset(e){if(!this._computedIsNeeded)return 0;let t=e-this._arrowSize-this._computedSliderSize/2;return Math.round(t/this._computedSliderRatio)}getDesiredScrollPositionFromOffsetPaged(e){if(!this._computedIsNeeded)return 0;let t=e-this._arrowSize,n=this._scrollPosition;return t0&&Math.abs(e.deltaY)>0)return 1;let n=.5;if((!this._isAlmostInt(e.deltaX)||!this._isAlmostInt(e.deltaY))&&(n+=.25),t){let r=Math.abs(e.deltaX),i=Math.abs(e.deltaY),a=Math.abs(t.deltaX),o=Math.abs(t.deltaY),s=Math.max(Math.min(r,a),1),c=Math.max(Math.min(i,o),1),l=Math.max(r,a),u=Math.max(i,o);l%s===0&&u%c===0&&(n-=.5)}return Math.min(Math.max(n,0),1)}_isAlmostInt(e){return Math.abs(Math.round(e)-e)<.01}};ti.INSTANCE=new ti;var ni=ti,ri=class extends Pr{constructor(e,t,n){super(),this._onScroll=this._register(new R),this.onScroll=this._onScroll.event,this._onWillScroll=this._register(new R),this.onWillScroll=this._onWillScroll.event,this._options=ai(t),this._scrollable=n,this._register(this._scrollable.onScroll(e=>{this._onWillScroll.fire(e),this._onDidScroll(e),this._onScroll.fire(e)}));let r={onMouseWheel:e=>this._onMouseWheel(e),onDragStart:()=>this._onDragStart(),onDragEnd:()=>this._onDragEnd()};this._verticalScrollbar=this._register(new Xr(this._scrollable,this._options,r)),this._horizontalScrollbar=this._register(new Yr(this._scrollable,this._options,r)),this._domNode=document.createElement(`div`),this._domNode.className=`xterm-scrollable-element `+this._options.className,this._domNode.setAttribute(`role`,`presentation`),this._domNode.style.position=`relative`,this._domNode.appendChild(e),this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode),this._domNode.appendChild(this._verticalScrollbar.domNode.domNode),this._options.useShadows?(this._leftShadowDomNode=Or(document.createElement(`div`)),this._leftShadowDomNode.setClassName(`shadow`),this._domNode.appendChild(this._leftShadowDomNode.domNode),this._topShadowDomNode=Or(document.createElement(`div`)),this._topShadowDomNode.setClassName(`shadow`),this._domNode.appendChild(this._topShadowDomNode.domNode),this._topLeftShadowDomNode=Or(document.createElement(`div`)),this._topLeftShadowDomNode.setClassName(`shadow`),this._domNode.appendChild(this._topLeftShadowDomNode.domNode)):(this._leftShadowDomNode=null,this._topShadowDomNode=null,this._topLeftShadowDomNode=null),this._listenOnDomNode=this._options.listenOnDomNode||this._domNode,this._mouseWheelToDispose=[],this._setListeningToMouseWheel(this._options.handleMouseWheel),this.onmouseover(this._listenOnDomNode,e=>this._onMouseOver(e)),this.onmouseleave(this._listenOnDomNode,e=>this._onMouseLeave(e)),this._hideTimeout=this._register(new Ln),this._isDragging=!1,this._mouseIsOver=!1,this._shouldRender=!0,this._revealOnScroll=!0}get options(){return this._options}dispose(){this._mouseWheelToDispose=ct(this._mouseWheelToDispose),super.dispose()}getDomNode(){return this._domNode}getOverviewRulerLayoutInfo(){return{parent:this._domNode,insertBefore:this._verticalScrollbar.domNode.domNode}}delegateVerticalScrollbarPointerDown(e){this._verticalScrollbar.delegatePointerDown(e)}getScrollDimensions(){return this._scrollable.getScrollDimensions()}setScrollDimensions(e){this._scrollable.setScrollDimensions(e,!1)}updateClassName(e){this._options.className=e,on&&(this._options.className+=` mac`),this._domNode.className=`xterm-scrollable-element `+this._options.className}updateOptions(e){typeof e.handleMouseWheel<`u`&&(this._options.handleMouseWheel=e.handleMouseWheel,this._setListeningToMouseWheel(this._options.handleMouseWheel)),typeof e.mouseWheelScrollSensitivity<`u`&&(this._options.mouseWheelScrollSensitivity=e.mouseWheelScrollSensitivity),typeof e.fastScrollSensitivity<`u`&&(this._options.fastScrollSensitivity=e.fastScrollSensitivity),typeof e.scrollPredominantAxis<`u`&&(this._options.scrollPredominantAxis=e.scrollPredominantAxis),typeof e.horizontal<`u`&&(this._options.horizontal=e.horizontal),typeof e.vertical<`u`&&(this._options.vertical=e.vertical),typeof e.horizontalScrollbarSize<`u`&&(this._options.horizontalScrollbarSize=e.horizontalScrollbarSize),typeof e.verticalScrollbarSize<`u`&&(this._options.verticalScrollbarSize=e.verticalScrollbarSize),typeof e.scrollByPage<`u`&&(this._options.scrollByPage=e.scrollByPage),this._horizontalScrollbar.updateOptions(this._options),this._verticalScrollbar.updateOptions(this._options),this._options.lazyRender||this._render()}setRevealOnScroll(e){this._revealOnScroll=e}delegateScrollFromMouseWheelEvent(e){this._onMouseWheel(new Nn(e))}_setListeningToMouseWheel(e){this._mouseWheelToDispose.length>0!==e&&(this._mouseWheelToDispose=ct(this._mouseWheelToDispose),e)&&this._mouseWheelToDispose.push(z(this._listenOnDomNode,Sr.MOUSE_WHEEL,e=>{this._onMouseWheel(new Nn(e))},{passive:!1}))}_onMouseWheel(e){if(e.browserEvent?.defaultPrevented)return;let t=ni.INSTANCE;$r&&t.acceptStandardWheelEvent(e);let n=!1;if(e.deltaY||e.deltaX){let r=e.deltaY*this._options.mouseWheelScrollSensitivity,i=e.deltaX*this._options.mouseWheelScrollSensitivity;this._options.scrollPredominantAxis&&(this._options.scrollYToX&&i+r===0?i=r=0:Math.abs(r)>=Math.abs(i)?i=0:r=0),this._options.flipAxes&&([r,i]=[i,r]);let a=!on&&e.browserEvent&&e.browserEvent.shiftKey;(this._options.scrollYToX||a)&&!i&&(i=r,r=0),e.browserEvent&&e.browserEvent.altKey&&(i*=this._options.fastScrollSensitivity,r*=this._options.fastScrollSensitivity);let o=this._scrollable.getFutureScrollPosition(),s={};if(r){let e=Qr*r,t=o.scrollTop-(e<0?Math.floor(e):Math.ceil(e));this._verticalScrollbar.writeScrollPosition(s,t)}if(i){let e=Qr*i,t=o.scrollLeft-(e<0?Math.floor(e):Math.ceil(e));this._horizontalScrollbar.writeScrollPosition(s,t)}s=this._scrollable.validateScrollPosition(s),(o.scrollLeft!==s.scrollLeft||o.scrollTop!==s.scrollTop)&&($r&&this._options.mouseWheelSmoothScroll&&t.isPhysicalMouseWheel()?this._scrollable.setScrollPositionSmooth(s):this._scrollable.setScrollPositionNow(s),n=!0)}let r=n;!r&&this._options.alwaysConsumeMouseWheel&&(r=!0),!r&&this._options.consumeMouseWheelIfScrollbarIsNeeded&&(this._verticalScrollbar.isNeeded()||this._horizontalScrollbar.isNeeded())&&(r=!0),r&&(e.preventDefault(),e.stopPropagation())}_onDidScroll(e){this._shouldRender=this._horizontalScrollbar.onDidScroll(e)||this._shouldRender,this._shouldRender=this._verticalScrollbar.onDidScroll(e)||this._shouldRender,this._options.useShadows&&(this._shouldRender=!0),this._revealOnScroll&&this._reveal(),this._options.lazyRender||this._render()}renderNow(){if(!this._options.lazyRender)throw Error("Please use `lazyRender` together with `renderNow`!");this._render()}_render(){if(this._shouldRender&&(this._shouldRender=!1,this._horizontalScrollbar.render(),this._verticalScrollbar.render(),this._options.useShadows)){let e=this._scrollable.getCurrentScrollPosition(),t=e.scrollTop>0,n=e.scrollLeft>0,r=n?` left`:``,i=t?` top`:``,a=n||t?` top-left-corner`:``;this._leftShadowDomNode.setClassName(`shadow${r}`),this._topShadowDomNode.setClassName(`shadow${i}`),this._topLeftShadowDomNode.setClassName(`shadow${a}${i}${r}`)}}_onDragStart(){this._isDragging=!0,this._reveal()}_onDragEnd(){this._isDragging=!1,this._hide()}_onMouseLeave(e){this._mouseIsOver=!1,this._hide()}_onMouseOver(e){this._mouseIsOver=!0,this._reveal()}_reveal(){this._verticalScrollbar.beginReveal(),this._horizontalScrollbar.beginReveal(),this._scheduleHide()}_hide(){!this._mouseIsOver&&!this._isDragging&&(this._verticalScrollbar.beginHide(),this._horizontalScrollbar.beginHide())}_scheduleHide(){!this._mouseIsOver&&!this._isDragging&&this._hideTimeout.cancelAndSet(()=>this._hide(),Zr)}},ii=class extends ri{constructor(e,t,n){super(e,t,n)}setScrollPosition(e){e.reuseAnimation?this._scrollable.setScrollPositionSmooth(e,e.reuseAnimation):this._scrollable.setScrollPositionNow(e)}getScrollPosition(){return this._scrollable.getCurrentScrollPosition()}};function ai(e){let t={lazyRender:typeof e.lazyRender<`u`?e.lazyRender:!1,className:typeof e.className<`u`?e.className:``,useShadows:typeof e.useShadows<`u`?e.useShadows:!0,handleMouseWheel:typeof e.handleMouseWheel<`u`?e.handleMouseWheel:!0,flipAxes:typeof e.flipAxes<`u`?e.flipAxes:!1,consumeMouseWheelIfScrollbarIsNeeded:typeof e.consumeMouseWheelIfScrollbarIsNeeded<`u`?e.consumeMouseWheelIfScrollbarIsNeeded:!1,alwaysConsumeMouseWheel:typeof e.alwaysConsumeMouseWheel<`u`?e.alwaysConsumeMouseWheel:!1,scrollYToX:typeof e.scrollYToX<`u`?e.scrollYToX:!1,mouseWheelScrollSensitivity:typeof e.mouseWheelScrollSensitivity<`u`?e.mouseWheelScrollSensitivity:1,fastScrollSensitivity:typeof e.fastScrollSensitivity<`u`?e.fastScrollSensitivity:5,scrollPredominantAxis:typeof e.scrollPredominantAxis<`u`?e.scrollPredominantAxis:!0,mouseWheelSmoothScroll:typeof e.mouseWheelSmoothScroll<`u`?e.mouseWheelSmoothScroll:!0,arrowSize:typeof e.arrowSize<`u`?e.arrowSize:11,listenOnDomNode:typeof e.listenOnDomNode<`u`?e.listenOnDomNode:null,horizontal:typeof e.horizontal<`u`?e.horizontal:1,horizontalScrollbarSize:typeof e.horizontalScrollbarSize<`u`?e.horizontalScrollbarSize:10,horizontalSliderSize:typeof e.horizontalSliderSize<`u`?e.horizontalSliderSize:0,horizontalHasArrows:typeof e.horizontalHasArrows<`u`?e.horizontalHasArrows:!1,vertical:typeof e.vertical<`u`?e.vertical:1,verticalScrollbarSize:typeof e.verticalScrollbarSize<`u`?e.verticalScrollbarSize:10,verticalHasArrows:typeof e.verticalHasArrows<`u`?e.verticalHasArrows:!1,verticalSliderSize:typeof e.verticalSliderSize<`u`?e.verticalSliderSize:0,scrollByPage:typeof e.scrollByPage<`u`?e.scrollByPage:!1};return t.horizontalSliderSize=typeof e.horizontalSliderSize<`u`?e.horizontalSliderSize:t.horizontalScrollbarSize,t.verticalSliderSize=typeof e.verticalSliderSize<`u`?e.verticalSliderSize:t.verticalScrollbarSize,on&&(t.className+=` mac`),t}var oi=class extends I{constructor(e,t,n,r,i,a,o,s){super(),this._bufferService=n,this._optionsService=o,this._renderService=s,this._onRequestScrollLines=this._register(new R),this.onRequestScrollLines=this._onRequestScrollLines.event,this._isSyncing=!1,this._isHandlingScroll=!1,this._suppressOnScrollHandler=!1;let c=this._register(new Rr({forceIntegerValues:!1,smoothScrollDuration:this._optionsService.rawOptions.smoothScrollDuration,scheduleAtNextAnimationFrame:e=>_r(r.window,e)}));this._register(this._optionsService.onSpecificOptionChange(`smoothScrollDuration`,()=>{c.setSmoothScrollDuration(this._optionsService.rawOptions.smoothScrollDuration)})),this._scrollableElement=this._register(new ii(t,{vertical:1,horizontal:2,useShadows:!1,mouseWheelSmoothScroll:!0,...this._getChangeOptions()},c)),this._register(this._optionsService.onMultipleOptionChange([`scrollSensitivity`,`fastScrollSensitivity`,`overviewRuler`],()=>this._scrollableElement.updateOptions(this._getChangeOptions()))),this._register(i.onProtocolChange(e=>{this._scrollableElement.updateOptions({handleMouseWheel:!(e&16)})})),this._scrollableElement.setScrollDimensions({height:0,scrollHeight:0}),this._register(xt.runAndSubscribe(a.onChangeColors,()=>{this._scrollableElement.getDomNode().style.backgroundColor=a.colors.background.css})),e.appendChild(this._scrollableElement.getDomNode()),this._register(F(()=>this._scrollableElement.getDomNode().remove())),this._styleElement=r.mainDocument.createElement(`style`),t.appendChild(this._styleElement),this._register(F(()=>this._styleElement.remove())),this._register(xt.runAndSubscribe(a.onChangeColors,()=>{this._styleElement.textContent=[`.xterm .xterm-scrollable-element > .scrollbar > .slider {`,` background: ${a.colors.scrollbarSliderBackground.css};`,`}`,`.xterm .xterm-scrollable-element > .scrollbar > .slider:hover {`,` background: ${a.colors.scrollbarSliderHoverBackground.css};`,`}`,`.xterm .xterm-scrollable-element > .scrollbar > .slider.active {`,` background: ${a.colors.scrollbarSliderActiveBackground.css};`,`}`].join(` +`)})),this._register(this._bufferService.onResize(()=>this.queueSync())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._latestYDisp=void 0,this.queueSync()})),this._register(this._bufferService.onScroll(()=>this._sync())),this._register(this._scrollableElement.onScroll(e=>this._handleScroll(e)))}scrollLines(e){let t=this._scrollableElement.getScrollPosition();this._scrollableElement.setScrollPosition({reuseAnimation:!0,scrollTop:t.scrollTop+e*this._renderService.dimensions.css.cell.height})}scrollToLine(e,t){t&&(this._latestYDisp=e),this._scrollableElement.setScrollPosition({reuseAnimation:!t,scrollTop:e*this._renderService.dimensions.css.cell.height})}_getChangeOptions(){return{mouseWheelScrollSensitivity:this._optionsService.rawOptions.scrollSensitivity,fastScrollSensitivity:this._optionsService.rawOptions.fastScrollSensitivity,verticalScrollbarSize:this._optionsService.rawOptions.overviewRuler?.width||14}}queueSync(e){e!==void 0&&(this._latestYDisp=e),this._queuedAnimationFrame===void 0&&(this._queuedAnimationFrame=this._renderService.addRefreshCallback(()=>{this._queuedAnimationFrame=void 0,this._sync(this._latestYDisp)}))}_sync(e=this._bufferService.buffer.ydisp){!this._renderService||this._isSyncing||(this._isSyncing=!0,this._suppressOnScrollHandler=!0,this._scrollableElement.setScrollDimensions({height:this._renderService.dimensions.css.canvas.height,scrollHeight:this._renderService.dimensions.css.cell.height*this._bufferService.buffer.lines.length}),this._suppressOnScrollHandler=!1,e!==this._latestYDisp&&this._scrollableElement.setScrollPosition({scrollTop:e*this._renderService.dimensions.css.cell.height}),this._isSyncing=!1)}_handleScroll(e){if(!this._renderService||this._isHandlingScroll||this._suppressOnScrollHandler)return;this._isHandlingScroll=!0;let t=Math.round(e.scrollTop/this._renderService.dimensions.css.cell.height),n=t-this._bufferService.buffer.ydisp;n!==0&&(this._latestYDisp=t,this._onRequestScrollLines.fire(n)),this._isHandlingScroll=!1}};oi=x([S(2,P),S(3,De),S(4,he),S(5,Me),S(6,be),S(7,ke)],oi);var si=class extends I{constructor(e,t,n,r,i){super(),this._screenElement=e,this._bufferService=t,this._coreBrowserService=n,this._decorationService=r,this._renderService=i,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement(`div`),this._container.classList.add(`xterm-decoration-container`),this._screenElement.appendChild(this._container),this._register(this._renderService.onRenderedViewportChange(()=>this._doRefreshDecorations())),this._register(this._renderService.onDimensionsChange(()=>{this._dimensionsChanged=!0,this._queueRefresh()})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt})),this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh())),this._register(this._decorationService.onDecorationRemoved(e=>this._removeDecoration(e))),this._register(F(()=>{this._container.remove(),this._decorationElements.clear()}))}_queueRefresh(){this._animationFrame===void 0&&(this._animationFrame=this._renderService.addRefreshCallback(()=>{this._doRefreshDecorations(),this._animationFrame=void 0}))}_doRefreshDecorations(){for(let e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){let t=this._coreBrowserService.mainDocument.createElement(`div`);t.classList.add(`xterm-decoration`),t.classList.toggle(`xterm-decoration-top-layer`,e?.options?.layer===`top`),t.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,t.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,t.style.top=`${(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height}px`,t.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;let n=e.options.x??0;return n&&n>this._bufferService.cols&&(t.style.display=`none`),this._refreshXPosition(e,t),t}_refreshStyle(e){let t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display=`none`,e.onRenderEmitter.fire(e.element));else{let n=this._decorationElements.get(e);n||(n=this._createElement(e),e.element=n,this._decorationElements.set(e,n),this._container.appendChild(n),e.onDispose(()=>{this._decorationElements.delete(e),n.remove()})),n.style.display=this._altBufferIsActive?`none`:`block`,this._altBufferIsActive||(n.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,n.style.height=`${(e.options.height||1)*this._renderService.dimensions.css.cell.height}px`,n.style.top=`${t*this._renderService.dimensions.css.cell.height}px`,n.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`),e.onRenderEmitter.fire(n)}}_refreshXPosition(e,t=e.element){if(!t)return;let n=e.options.x??0;(e.options.anchor||`left`)===`right`?t.style.right=n?`${n*this._renderService.dimensions.css.cell.width}px`:``:t.style.left=n?`${n*this._renderService.dimensions.css.cell.width}px`:``}_removeDecoration(e){this._decorationElements.get(e)?.remove(),this._decorationElements.delete(e),e.dispose()}};si=x([S(1,P),S(2,De),S(3,Ce),S(4,ke)],si);var ci=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(let t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position)){this._addLineToZone(t,e.marker.line);return}}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,n){return t>=e.startBufferLine-this._linePadding[n||`full`]&&t<=e.endBufferLine+this._linePadding[n||`full`]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}},li={full:0,left:0,center:0,right:0},ui={full:0,left:0,center:0,right:0},di={full:0,left:0,center:0,right:0},fi=class extends I{constructor(e,t,n,r,i,a,o,s){super(),this._viewportElement=e,this._screenElement=t,this._bufferService=n,this._decorationService=r,this._renderService=i,this._optionsService=a,this._themeService=o,this._coreBrowserService=s,this._colorZoneStore=new ci,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=this._coreBrowserService.mainDocument.createElement(`canvas`),this._canvas.classList.add(`xterm-decoration-overview-ruler`),this._refreshCanvasDimensions(),this._viewportElement.parentElement?.insertBefore(this._canvas,this._viewportElement),this._register(F(()=>this._canvas?.remove()));let c=this._canvas.getContext(`2d`);if(c)this._ctx=c;else throw Error(`Ctx cannot be null`);this._register(this._decorationService.onDecorationRegistered(()=>this._queueRefresh(void 0,!0))),this._register(this._decorationService.onDecorationRemoved(()=>this._queueRefresh(void 0,!0))),this._register(this._renderService.onRenderedViewportChange(()=>this._queueRefresh())),this._register(this._bufferService.buffers.onBufferActivate(()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?`none`:`block`})),this._register(this._bufferService.onScroll(()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})),this._register(this._renderService.onRender(()=>{(!this._containerHeight||this._containerHeight!==this._screenElement.clientHeight)&&(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)})),this._register(this._coreBrowserService.onDprChange(()=>this._queueRefresh(!0))),this._register(this._optionsService.onSpecificOptionChange(`overviewRuler`,()=>this._queueRefresh(!0))),this._register(this._themeService.onChangeColors(()=>this._queueRefresh())),this._queueRefresh(!0)}get _width(){return this._optionsService.options.overviewRuler?.width||0}_refreshDrawConstants(){let e=Math.floor((this._canvas.width-1)/3),t=Math.ceil((this._canvas.width-1)/3);ui.full=this._canvas.width,ui.left=e,ui.center=t,ui.right=e,this._refreshDrawHeightConstants(),di.full=1,di.left=1,di.center=1+ui.left,di.right=1+ui.left+ui.center}_refreshDrawHeightConstants(){li.full=Math.round(2*this._coreBrowserService.dpr);let e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowserService.dpr);li.left=t,li.center=t,li.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*li.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*li.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*li.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*li.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowserService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowserService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(let e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1,this._renderRulerOutline();let e=this._colorZoneStore.zones;for(let t of e)t.position!==`full`&&this._renderColorZone(t);for(let t of e)t.position===`full`&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderRulerOutline(){this._ctx.fillStyle=this._themeService.colors.overviewRulerBorder.css,this._ctx.fillRect(0,0,1,this._canvas.height),this._optionsService.rawOptions.overviewRuler.showTopBorder&&this._ctx.fillRect(1,0,this._canvas.width-1,1),this._optionsService.rawOptions.overviewRuler.showBottomBorder&&this._ctx.fillRect(1,this._canvas.height-1,this._canvas.width-1,this._canvas.height)}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(di[e.position||`full`],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-li[e.position||`full`]/2),ui[e.position||`full`],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+li[e.position||`full`]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,this._animationFrame===void 0&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._refreshDecorations(),this._animationFrame=void 0}))}};fi=x([S(2,P),S(3,Ce),S(4,ke),S(5,be),S(6,Me),S(7,De)],fi);var B;(e=>(e.NUL=`\0`,e.SOH=``,e.STX=``,e.ETX=``,e.EOT=``,e.ENQ=``,e.ACK=``,e.BEL=`\x07`,e.BS=`\b`,e.HT=` `,e.LF=` +`,e.VT=`\v`,e.FF=`\f`,e.CR=`\r`,e.SO=``,e.SI=``,e.DLE=``,e.DC1=``,e.DC2=``,e.DC3=``,e.DC4=``,e.NAK=``,e.SYN=``,e.ETB=``,e.CAN=``,e.EM=``,e.SUB=``,e.ESC=`\x1B`,e.FS=``,e.GS=``,e.RS=``,e.US=``,e.SP=` `,e.DEL=``))(B||={});var pi;(e=>(e.PAD=`€`,e.HOP=``,e.BPH=`‚`,e.NBH=`ƒ`,e.IND=`„`,e.NEL=`…`,e.SSA=`†`,e.ESA=`‡`,e.HTS=`ˆ`,e.HTJ=`‰`,e.VTS=`Š`,e.PLD=`‹`,e.PLU=`Œ`,e.RI=``,e.SS2=`Ž`,e.SS3=``,e.DCS=``,e.PU1=`‘`,e.PU2=`’`,e.STS=`“`,e.CCH=`”`,e.MW=`•`,e.SPA=`–`,e.EPA=`—`,e.SOS=`˜`,e.SGCI=`™`,e.SCI=`š`,e.CSI=`›`,e.ST=`œ`,e.OSC=``,e.PM=`ž`,e.APC=`Ÿ`))(pi||={});var mi;(e=>e.ST=`${B.ESC}\\`)(mi||={});var hi=class{constructor(e,t,n,r,i,a){this._textarea=e,this._compositionView=t,this._bufferService=n,this._optionsService=r,this._coreService=i,this._renderService=a,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=``}get isComposing(){return this._isComposing}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=``,this._dataAlreadySent=``,this._compositionView.classList.add(`active`)}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout(()=>{this._compositionPosition.end=this._textarea.value.length},0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(e.keyCode===20||e.keyCode===229||e.keyCode===16||e.keyCode===17||e.keyCode===18)return!1;this._finalizeComposition(!1)}return e.keyCode===229?(this._handleAnyTextareaChanges(),!1):!0}_finalizeComposition(e){if(this._compositionView.classList.remove(`active`),this._isComposing=!1,e){let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout(()=>{if(this._isSendingComposition){this._isSendingComposition=!1;let t;e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}},0)}else{this._isSendingComposition=!1;let e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){let e=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let t=this._textarea.value,n=t.replace(e,``);this._dataAlreadySent=n,t.length>e.length?this._coreService.triggerDataEvent(n,!0):t.lengththis.updateCompositionElements(!0),0)}}};hi=x([S(2,P),S(3,be),S(4,ge),S(5,ke)],hi);var gi=0,_i=0,vi=0,V=0,yi={css:`#00000000`,rgba:0},H;(e=>{function t(e,t,n,r){return r===void 0?`#${Si(e)}${Si(t)}${Si(n)}`:`#${Si(e)}${Si(t)}${Si(n)}${Si(r)}`}e.toCss=t;function n(e,t,n,r=255){return(e<<24|t<<16|n<<8|r)>>>0}e.toRgba=n;function r(t,n,r,i){return{css:e.toCss(t,n,r,i),rgba:e.toRgba(t,n,r,i)}}e.toColor=r})(H||={});var U;(e=>{function t(e,t){if(V=(t.rgba&255)/255,V===1)return{css:t.css,rgba:t.rgba};let n=t.rgba>>24&255,r=t.rgba>>16&255,i=t.rgba>>8&255,a=e.rgba>>24&255,o=e.rgba>>16&255,s=e.rgba>>8&255;return gi=a+Math.round((n-a)*V),_i=o+Math.round((r-o)*V),vi=s+Math.round((i-s)*V),{css:H.toCss(gi,_i,vi),rgba:H.toRgba(gi,_i,vi)}}e.blend=t;function n(e){return(e.rgba&255)==255}e.isOpaque=n;function r(e,t,n){let r=xi.ensureContrastRatio(e.rgba,t.rgba,n);if(r)return H.toColor(r>>24&255,r>>16&255,r>>8&255)}e.ensureContrastRatio=r;function i(e){let t=(e.rgba|255)>>>0;return[gi,_i,vi]=xi.toChannels(t),{css:H.toCss(gi,_i,vi),rgba:t}}e.opaque=i;function a(e,t){return V=Math.round(t*255),[gi,_i,vi]=xi.toChannels(e.rgba),{css:H.toCss(gi,_i,vi,V),rgba:H.toRgba(gi,_i,vi,V)}}e.opacity=a;function o(e,t){return V=e.rgba&255,a(e,V*t/255)}e.multiplyOpacity=o;function s(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}e.toColorRGB=s})(U||={});var W;(e=>{let t,n;try{let e=document.createElement(`canvas`);e.width=1,e.height=1;let r=e.getContext(`2d`,{willReadFrequently:!0});r&&(t=r,t.globalCompositeOperation=`copy`,n=t.createLinearGradient(0,0,1,1))}catch{}function r(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return gi=parseInt(e.slice(1,2).repeat(2),16),_i=parseInt(e.slice(2,3).repeat(2),16),vi=parseInt(e.slice(3,4).repeat(2),16),H.toColor(gi,_i,vi);case 5:return gi=parseInt(e.slice(1,2).repeat(2),16),_i=parseInt(e.slice(2,3).repeat(2),16),vi=parseInt(e.slice(3,4).repeat(2),16),V=parseInt(e.slice(4,5).repeat(2),16),H.toColor(gi,_i,vi,V);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}let r=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(r)return gi=parseInt(r[1]),_i=parseInt(r[2]),vi=parseInt(r[3]),V=Math.round((r[5]===void 0?1:parseFloat(r[5]))*255),H.toColor(gi,_i,vi,V);if(!t||!n||(t.fillStyle=n,t.fillStyle=e,typeof t.fillStyle!=`string`)||(t.fillRect(0,0,1,1),[gi,_i,vi,V]=t.getImageData(0,0,1,1).data,V!==255))throw Error(`css.toColor: Unsupported css format`);return{rgba:H.toRgba(gi,_i,vi,V),css:e}}e.toColor=r})(W||={});var bi;(e=>{function t(e){return n(e>>16&255,e>>8&255,e&255)}e.relativeLuminance=t;function n(e,t,n){let r=e/255,i=t/255,a=n/255,o=r<=.03928?r/12.92:((r+.055)/1.055)**2.4,s=i<=.03928?i/12.92:((i+.055)/1.055)**2.4,c=a<=.03928?a/12.92:((a+.055)/1.055)**2.4;return o*.2126+s*.7152+c*.0722}e.relativeLuminance2=n})(bi||={});var xi;(e=>{function t(e,t){if(V=(t&255)/255,V===1)return t;let n=t>>24&255,r=t>>16&255,i=t>>8&255,a=e>>24&255,o=e>>16&255,s=e>>8&255;return gi=a+Math.round((n-a)*V),_i=o+Math.round((r-o)*V),vi=s+Math.round((i-s)*V),H.toRgba(gi,_i,vi)}e.blend=t;function n(e,t,n){let a=bi.relativeLuminance(e>>8),o=bi.relativeLuminance(t>>8);if(Ci(a,o)>8));if(sCi(a,bi.relativeLuminance(r>>8))?o:r}return o}let s=i(e,t,n),c=Ci(a,bi.relativeLuminance(s>>8));if(cCi(a,bi.relativeLuminance(i>>8))?s:i}return s}}e.ensureContrastRatio=n;function r(e,t,n){let r=e>>24&255,i=e>>16&255,a=e>>8&255,o=t>>24&255,s=t>>16&255,c=t>>8&255,l=Ci(bi.relativeLuminance2(o,s,c),bi.relativeLuminance2(r,i,a));for(;l0||s>0||c>0);)o-=Math.max(0,Math.ceil(o*.1)),s-=Math.max(0,Math.ceil(s*.1)),c-=Math.max(0,Math.ceil(c*.1)),l=Ci(bi.relativeLuminance2(o,s,c),bi.relativeLuminance2(r,i,a));return(o<<24|s<<16|c<<8|255)>>>0}e.reduceLuminance=r;function i(e,t,n){let r=e>>24&255,i=e>>16&255,a=e>>8&255,o=t>>24&255,s=t>>16&255,c=t>>8&255,l=Ci(bi.relativeLuminance2(o,s,c),bi.relativeLuminance2(r,i,a));for(;l>>0}e.increaseLuminance=i;function a(e){return[e>>24&255,e>>16&255,e>>8&255,e&255]}e.toChannels=a})(xi||={});function Si(e){let t=e.toString(16);return t.length<2?`0`+t:t}function Ci(e,t){return e1){let e=this._getJoinedRanges(r,o,a,t,i);for(let t=0;t1){let e=this._getJoinedRanges(r,o,a,t,i);for(let t=0;t=w,re=D,k=this._workCell;if(f.length>0&&D===f[0][0]&&O){let r=f.shift(),i=this._isCellInSelection(r[0],t);for(v=r[0]+1;v=r[1],O?(ne=!0,k=new wi(this._workCell,e.translateToString(!0,r[0],r[1]),r[1]-r[0]),re=r[1]-1,m=k.getWidth()):w=r[1]}let A=this._isCellInSelection(D,t),j=n&&D===a,ie=E&&D>=l&&D<=u,ae=!1;this._decorationService.forEachDecorationAtCell(D,t,void 0,e=>{ae=!0});let oe=k.getChars()||se;if(oe===` `&&(k.isUnderline()||k.isOverline())&&(oe=`\xA0`),C=m*s-c.get(oe,k.isBold(),k.isItalic()),!h)h=this._document.createElement(`span`);else if(g&&(A&&te||!A&&!te&&k.bg===y)&&(A&&te&&p.selectionForeground||k.fg===b)&&k.extended.ext===x&&ie===S&&C===ee&&!j&&!ne&&!ae&&O){k.isInvisible()?_+=se:_+=oe,g++;continue}else g&&(h.textContent=_),h=this._document.createElement(`span`),g=0,_=``;if(y=k.bg,b=k.fg,x=k.extended.ext,S=ie,ee=C,te=A,ne&&a>=D&&a<=re&&(a=D),!this._coreService.isCursorHidden&&j&&this._coreService.isCursorInitialized){if(T.push(`xterm-cursor`),this._coreBrowserService.isFocused)o&&T.push(`xterm-cursor-blink`),T.push(r===`bar`?`xterm-cursor-bar`:r===`underline`?`xterm-cursor-underline`:`xterm-cursor-block`);else if(i)switch(i){case`outline`:T.push(`xterm-cursor-outline`);break;case`block`:T.push(`xterm-cursor-block`);break;case`bar`:T.push(`xterm-cursor-bar`);break;case`underline`:T.push(`xterm-cursor-underline`);break;default:break}}if(k.isBold()&&T.push(`xterm-bold`),k.isItalic()&&T.push(`xterm-italic`),k.isDim()&&T.push(`xterm-dim`),_=k.isInvisible()?se:k.getChars()||se,k.isUnderline()&&(T.push(`xterm-underline-${k.extended.underlineStyle}`),_===` `&&(_=`\xA0`),!k.isUnderlineColorDefault()))if(k.isUnderlineColorRGB())h.style.textDecorationColor=`rgb(${ce.toColorRGB(k.getUnderlineColor()).join(`,`)})`;else{let e=k.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&k.isBold()&&e<8&&(e+=8),h.style.textDecorationColor=p.ansi[e].css}k.isOverline()&&(T.push(`xterm-overline`),_===` `&&(_=`\xA0`)),k.isStrikethrough()&&T.push(`xterm-strikethrough`),ie&&(h.style.textDecoration=`underline`);let le=k.getFgColor(),M=k.getFgColorMode(),ue=k.getBgColor(),de=k.getBgColorMode(),fe=!!k.isInverse();if(fe){let e=le;le=ue,ue=e;let t=M;M=de,de=t}let pe,N,me=!1;this._decorationService.forEachDecorationAtCell(D,t,void 0,e=>{e.options.layer!==`top`&&me||(e.backgroundColorRGB&&(de=50331648,ue=e.backgroundColorRGB.rgba>>8&16777215,pe=e.backgroundColorRGB),e.foregroundColorRGB&&(M=50331648,le=e.foregroundColorRGB.rgba>>8&16777215,N=e.foregroundColorRGB),me=e.options.layer===`top`)}),!me&&A&&(pe=this._coreBrowserService.isFocused?p.selectionBackgroundOpaque:p.selectionInactiveBackgroundOpaque,ue=pe.rgba>>8&16777215,de=50331648,me=!0,p.selectionForeground&&(M=50331648,le=p.selectionForeground.rgba>>8&16777215,N=p.selectionForeground)),me&&T.push(`xterm-decoration-top`);let P;switch(de){case 16777216:case 33554432:P=p.ansi[ue],T.push(`xterm-bg-${ue}`);break;case 50331648:P=H.toColor(ue>>16,ue>>8&255,ue&255),this._addStyle(h,`background-color:#${Mi((ue>>>0).toString(16),`0`,6)}`);break;default:fe?(P=p.foreground,T.push(`xterm-bg-257`)):P=p.background}switch(pe||k.isDim()&&(pe=U.multiplyOpacity(P,.5)),M){case 16777216:case 33554432:k.isBold()&&le<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&(le+=8),this._applyMinimumContrast(h,P,p.ansi[le],k,pe,void 0)||T.push(`xterm-fg-${le}`);break;case 50331648:let e=H.toColor(le>>16&255,le>>8&255,le&255);this._applyMinimumContrast(h,P,e,k,pe,N)||this._addStyle(h,`color:#${Mi(le.toString(16),`0`,6)}`);break;default:this._applyMinimumContrast(h,P,p.foreground,k,pe,N)||fe&&T.push(`xterm-fg-257`)}T.length&&=(h.className=T.join(` `),0),!j&&!ne&&!ae&&O?g++:h.textContent=_,C!==this.defaultSpacing&&(h.style.letterSpacing=`${C}px`),d.push(h),D=re}return h&&g&&(h.textContent=_),d}_applyMinimumContrast(e,t,n,r,i,a){if(this._optionsService.rawOptions.minimumContrastRatio===1||Oi(r.getCode()))return!1;let o=this._getContrastCache(r),s;if(!i&&!a&&(s=o.getColor(t.rgba,n.rgba)),s===void 0){let e=this._optionsService.rawOptions.minimumContrastRatio/(r.isDim()?2:1);s=U.ensureContrastRatio(i||t,a||n,e),o.setColor((i||t).rgba,(a||n).rgba,s??null)}return s?(this._addStyle(e,`color:${s.css}`),!0):!1}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute(`style`,`${e.getAttribute(`style`)||``}${t};`)}_isCellInSelection(e,t){let n=this._selectionStart,r=this._selectionEnd;return!n||!r?!1:this._columnSelectMode?n[0]<=r[0]?e>=n[0]&&t>=n[1]&&e=n[1]&&e>=r[0]&&t<=r[1]:t>n[1]&&t=n[0]&&e=n[0]}};ji=x([S(1,je),S(2,be),S(3,De),S(4,ge),S(5,Ce),S(6,Me)],ji);function Mi(e,t,n){for(;e.length0&&(this._flat[r]=t),t}let i=e;t&&(i+=`B`),n&&(i+=`I`);let a=this._holey.get(i);if(a===void 0){let r=0;t&&(r|=1),n&&(r|=2),a=this._measure(e,r),a>0&&this._holey.set(i,a)}return a}_measure(e,t){let n=this._measureElements[t];return n.textContent=e.repeat(32),n.offsetWidth/32}},Pi=class{constructor(){this.clear()}clear(){this.hasSelection=!1,this.columnSelectMode=!1,this.viewportStartRow=0,this.viewportEndRow=0,this.viewportCappedStartRow=0,this.viewportCappedEndRow=0,this.startCol=0,this.endCol=0,this.selectionStart=void 0,this.selectionEnd=void 0}update(e,t,n,r=!1){if(this.selectionStart=t,this.selectionEnd=n,!t||!n||t[0]===n[0]&&t[1]===n[1]){this.clear();return}let i=e.buffers.active.ydisp,a=t[1]-i,o=n[1]-i,s=Math.max(a,0),c=Math.min(o,e.rows-1);if(s>=e.rows||c<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=r,this.viewportStartRow=a,this.viewportEndRow=o,this.viewportCappedStartRow=s,this.viewportCappedEndRow=c,this.startCol=t[0],this.endCol=n[0]}isCellSelected(e,t,n){return this.hasSelection?(n-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&n>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&n<=this.viewportCappedEndRow:n>this.viewportStartRow&&n=this.startCol&&t=this.startCol):!1}};function Fi(){return new Pi}var Ii=`xterm-dom-renderer-owner-`,Li=`xterm-rows`,Ri=`xterm-fg-`,zi=`xterm-bg-`,Bi=`xterm-focus`,Vi=`xterm-selection`,Hi=1,Ui=class extends I{constructor(e,t,n,r,i,a,o,s,c,l,u,d,f,p){super(),this._terminal=e,this._document=t,this._element=n,this._screenElement=r,this._viewportElement=i,this._helperContainer=a,this._linkifier2=o,this._charSizeService=c,this._optionsService=l,this._bufferService=u,this._coreService=d,this._coreBrowserService=f,this._themeService=p,this._terminalClass=Hi++,this._rowElements=[],this._selectionRenderModel=Fi(),this.onRequestRedraw=this._register(new R).event,this._rowContainer=this._document.createElement(`div`),this._rowContainer.classList.add(Li),this._rowContainer.style.lineHeight=`normal`,this._rowContainer.setAttribute(`aria-hidden`,`true`),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=this._document.createElement(`div`),this._selectionContainer.classList.add(Vi),this._selectionContainer.setAttribute(`aria-hidden`,`true`),this.dimensions=ki(),this._updateDimensions(),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._themeService.onChangeColors(e=>this._injectCss(e))),this._injectCss(this._themeService.colors),this._rowFactory=s.createInstance(ji,document),this._element.classList.add(Ii+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this._register(this._linkifier2.onShowLinkUnderline(e=>this._handleLinkHover(e))),this._register(this._linkifier2.onHideLinkUnderline(e=>this._handleLinkLeave(e))),this._register(F(()=>{this._element.classList.remove(Ii+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()})),this._widthCache=new Ni(this._document,this._helperContainer),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){let e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(let e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow=`hidden`;this._dimensionsStyleElement||(this._dimensionsStyleElement=this._document.createElement(`style`),this._screenElement.appendChild(this._dimensionsStyleElement));let t=`${this._terminalSelector} .${Li} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=this._document.createElement(`style`),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${Li} { pointer-events: none; color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${Li} .xterm-dim { color: ${U.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`;let n=`blink_underline_${this._terminalClass}`,r=`blink_bar_${this._terminalClass}`,i=`blink_block_${this._terminalClass}`;t+=`@keyframes ${n} { 50% { border-bottom-style: hidden; }}`,t+=`@keyframes ${r} { 50% { box-shadow: none; }}`,t+=`@keyframes ${i} { 0% { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css}; } 50% { background-color: inherit; color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${Li}.${Bi} .xterm-cursor.xterm-cursor-blink.xterm-cursor-underline { animation: ${n} 1s step-end infinite;}${this._terminalSelector} .${Li}.${Bi} .xterm-cursor.xterm-cursor-blink.xterm-cursor-bar { animation: ${r} 1s step-end infinite;}${this._terminalSelector} .${Li}.${Bi} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: ${i} 1s step-end infinite;}${this._terminalSelector} .${Li} .xterm-cursor.xterm-cursor-block { background-color: ${e.cursor.css}; color: ${e.cursorAccent.css};}${this._terminalSelector} .${Li} .xterm-cursor.xterm-cursor-block:not(.xterm-cursor-blink) { background-color: ${e.cursor.css} !important; color: ${e.cursorAccent.css} !important;}${this._terminalSelector} .${Li} .xterm-cursor.xterm-cursor-outline { outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}${this._terminalSelector} .${Li} .xterm-cursor.xterm-cursor-bar { box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}${this._terminalSelector} .${Li} .xterm-cursor.xterm-cursor-underline { border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${Vi} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${Vi} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${Vi} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(let[n,r]of e.ansi.entries())t+=`${this._terminalSelector} .${Ri}${n} { color: ${r.css}; }${this._terminalSelector} .${Ri}${n}.xterm-dim { color: ${U.multiplyOpacity(r,.5).css}; }${this._terminalSelector} .${zi}${n} { background-color: ${r.css}; }`;t+=`${this._terminalSelector} .${Ri}257 { color: ${U.opaque(e.background).css}; }${this._terminalSelector} .${Ri}257.xterm-dim { color: ${U.multiplyOpacity(U.opaque(e.background),.5).css}; }${this._terminalSelector} .${zi}257 { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){let e=this.dimensions.css.cell.width-this._widthCache.get(`W`,!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){let e=this._document.createElement(`div`);this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions(),this.handleSelectionChanged(this._selectionRenderModel.selectionStart,this._selectionRenderModel.selectionEnd,this._selectionRenderModel.columnSelectMode)}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(Bi),this.renderRows(0,this._bufferService.rows-1)}handleFocus(){this._rowContainer.classList.add(Bi),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,n){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,n),this.renderRows(0,this._bufferService.rows-1),!e||!t||(this._selectionRenderModel.update(this._terminal,e,t,n),!this._selectionRenderModel.hasSelection))return;let r=this._selectionRenderModel.viewportStartRow,i=this._selectionRenderModel.viewportEndRow,a=this._selectionRenderModel.viewportCappedStartRow,o=this._selectionRenderModel.viewportCappedEndRow,s=this._document.createDocumentFragment();if(n){let n=e[0]>t[0];s.appendChild(this._createSelectionElement(a,n?t[0]:e[0],n?e[0]:t[0],o-a+1))}else{let n=r===a?e[0]:0,c=a===i?t[0]:this._bufferService.cols;s.appendChild(this._createSelectionElement(a,n,c));let l=o-a-1;if(s.appendChild(this._createSelectionElement(a+1,0,this._bufferService.cols,l)),a!==o){let e=i===o?t[0]:this._bufferService.cols;s.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(s)}_createSelectionElement(e,t,n,r=1){let i=this._document.createElement(`div`),a=t*this.dimensions.css.cell.width,o=this.dimensions.css.cell.width*(n-t);return a+o>this.dimensions.css.canvas.width&&(o=this.dimensions.css.canvas.width-a),i.style.height=`${r*this.dimensions.css.cell.height}px`,i.style.top=`${e*this.dimensions.css.cell.height}px`,i.style.left=`${a}px`,i.style.width=`${o}px`,i}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(let e of this._rowElements)e.replaceChildren()}renderRows(e,t){let n=this._bufferService.buffer,r=n.ybase+n.y,i=Math.min(n.x,this._bufferService.cols-1),a=this._coreService.decPrivateModes.cursorBlink??this._optionsService.rawOptions.cursorBlink,o=this._coreService.decPrivateModes.cursorStyle??this._optionsService.rawOptions.cursorStyle,s=this._optionsService.rawOptions.cursorInactiveStyle;for(let c=e;c<=t;c++){let e=c+n.ydisp,t=this._rowElements[c],l=n.lines.get(e);if(!t||!l)break;t.replaceChildren(...this._rowFactory.createRow(l,e,e===r,o,s,i,a,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${Ii}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,n,r,i,a){n<0&&(e=0),r<0&&(t=0);let o=this._bufferService.rows-1;n=Math.max(Math.min(n,o),0),r=Math.max(Math.min(r,o),0),i=Math.min(i,this._bufferService.cols);let s=this._bufferService.buffer,c=s.ybase+s.y,l=Math.min(s.x,i-1),u=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,f=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=n;o<=r;++o){let p=o+s.ydisp,m=this._rowElements[o],h=s.lines.get(p);if(!m||!h)break;m.replaceChildren(...this._rowFactory.createRow(h,p,p===c,d,f,l,u,this.dimensions.css.cell.width,this._widthCache,a?o===n?e:0:-1,a?(o===r?t:i)-1:-1))}}};Ui=x([S(7,ve),S(8,Ee),S(9,be),S(10,P),S(11,ge),S(12,De),S(13,Me)],Ui);var Wi=class extends I{constructor(e,t,n){super(),this._optionsService=n,this.width=0,this.height=0,this._onCharSizeChange=this._register(new R),this.onCharSizeChange=this._onCharSizeChange.event;try{this._measureStrategy=this._register(new qi(this._optionsService))}catch{this._measureStrategy=this._register(new Ki(e,t,this._optionsService))}this._register(this._optionsService.onMultipleOptionChange([`fontFamily`,`fontSize`],()=>this.measure()))}get hasValidSize(){return this.width>0&&this.height>0}measure(){let e=this._measureStrategy.measure();(e.width!==this.width||e.height!==this.height)&&(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};Wi=x([S(2,be)],Wi);var Gi=class extends I{constructor(){super(...arguments),this._result={width:0,height:0}}_validateAndSet(e,t){e!==void 0&&e>0&&t!==void 0&&t>0&&(this._result.width=e,this._result.height=t)}},Ki=class extends Gi{constructor(e,t,n){super(),this._document=e,this._parentElement=t,this._optionsService=n,this._measureElement=this._document.createElement(`span`),this._measureElement.classList.add(`xterm-char-measure-element`),this._measureElement.textContent=`W`.repeat(32),this._measureElement.setAttribute(`aria-hidden`,`true`),this._measureElement.style.whiteSpace=`pre`,this._measureElement.style.fontKerning=`none`,this._parentElement.appendChild(this._measureElement)}measure(){return this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`,this._validateAndSet(Number(this._measureElement.offsetWidth)/32,Number(this._measureElement.offsetHeight)),this._result}},qi=class extends Gi{constructor(e){super(),this._optionsService=e,this._canvas=new OffscreenCanvas(100,100),this._ctx=this._canvas.getContext(`2d`);let t=this._ctx.measureText(`W`);if(!(`width`in t&&`fontBoundingBoxAscent`in t&&`fontBoundingBoxDescent`in t))throw Error(`Required font metrics not supported`)}measure(){this._ctx.font=`${this._optionsService.rawOptions.fontSize}px ${this._optionsService.rawOptions.fontFamily}`;let e=this._ctx.measureText(`W`);return this._validateAndSet(e.width,e.fontBoundingBoxAscent+e.fontBoundingBoxDescent),this._result}},Ji=class extends I{constructor(e,t,n){super(),this._textarea=e,this._window=t,this.mainDocument=n,this._isFocused=!1,this._cachedIsFocused=void 0,this._screenDprMonitor=this._register(new Yi(this._window)),this._onDprChange=this._register(new R),this.onDprChange=this._onDprChange.event,this._onWindowChange=this._register(new R),this.onWindowChange=this._onWindowChange.event,this._register(this.onWindowChange(e=>this._screenDprMonitor.setWindow(e))),this._register(xt.forward(this._screenDprMonitor.onDprChange,this._onDprChange)),this._register(z(this._textarea,`focus`,()=>this._isFocused=!0)),this._register(z(this._textarea,`blur`,()=>this._isFocused=!1))}get window(){return this._window}set window(e){this._window!==e&&(this._window=e,this._onWindowChange.fire(this._window))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return this._cachedIsFocused===void 0&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask(()=>this._cachedIsFocused=void 0)),this._cachedIsFocused}},Yi=class extends I{constructor(e){super(),this._parentWindow=e,this._windowResizeListener=this._register(new ft),this._onDprChange=this._register(new R),this.onDprChange=this._onDprChange.event,this._outerListener=()=>this._setDprAndFireIfDiffers(),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._updateDpr(),this._setWindowResizeListener(),this._register(F(()=>this.clearListener()))}setWindow(e){this._parentWindow=e,this._setWindowResizeListener(),this._setDprAndFireIfDiffers()}_setWindowResizeListener(){this._windowResizeListener.value=z(this._parentWindow,`resize`,()=>this._setDprAndFireIfDiffers())}_setDprAndFireIfDiffers(){this._parentWindow.devicePixelRatio!==this._currentDevicePixelRatio&&this._onDprChange.fire(this._parentWindow.devicePixelRatio),this._updateDpr()}_updateDpr(){this._outerListener&&(this._resolutionMediaMatchList?.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){!this._resolutionMediaMatchList||!this._outerListener||(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._outerListener=void 0)}},Xi=class extends I{constructor(){super(),this.linkProviders=[],this._register(F(()=>this.linkProviders.length=0))}registerLinkProvider(e){return this.linkProviders.push(e),{dispose:()=>{let t=this.linkProviders.indexOf(e);t!==-1&&this.linkProviders.splice(t,1)}}}};function Zi(e,t,n){let r=n.getBoundingClientRect(),i=e.getComputedStyle(n),a=parseInt(i.getPropertyValue(`padding-left`)),o=parseInt(i.getPropertyValue(`padding-top`));return[t.clientX-r.left-a,t.clientY-r.top-o]}function Qi(e,t,n,r,i,a,o,s,c){if(!a)return;let l=Zi(e,t,n);if(l)return l[0]=Math.ceil((l[0]+(c?o/2:0))/o),l[1]=Math.ceil(l[1]/s),l[0]=Math.min(Math.max(l[0],1),r+(c?1:0)),l[1]=Math.min(Math.max(l[1],1),i),l}var $i=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,n,r,i){return Qi(window,e,t,n,r,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,i)}getMouseReportCoords(e,t){let n=Zi(window,e,t);if(this._charSizeService.hasValidSize)return n[0]=Math.min(Math.max(n[0],0),this._renderService.dimensions.css.canvas.width-1),n[1]=Math.min(Math.max(n[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(n[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(n[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(n[0]),y:Math.floor(n[1])}}};$i=x([S(0,ke),S(1,Ee)],$i);var ea=class{constructor(e,t){this._renderCallback=e,this._coreBrowserService=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&=(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()),this._animationFrame}refresh(e,t,n){this._rowCount=n,e=e===void 0?0:e,t=t===void 0?this._rowCount-1:t,this._rowStart=this._rowStart===void 0?e:Math.min(this._rowStart,e),this._rowEnd=this._rowEnd===void 0?t:Math.max(this._rowEnd,t),!this._animationFrame&&(this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>this._innerRefresh()))}_innerRefresh(){if(this._animationFrame=void 0,this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0){this._runRefreshCallbacks();return}let e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(let e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}},ta={};b(ta,{getSafariVersion:()=>ca,isChromeOS:()=>ma,isFirefox:()=>aa,isIpad:()=>ua,isIphone:()=>da,isLegacyEdge:()=>oa,isLinux:()=>pa,isMac:()=>la,isNode:()=>na,isSafari:()=>sa,isWindows:()=>fa});var na=typeof process<`u`&&`title`in process,ra=na?`node`:navigator.userAgent,ia=na?`node`:navigator.platform,aa=ra.includes(`Firefox`),oa=ra.includes(`Edge`),sa=/^((?!chrome|android).)*safari/i.test(ra);function ca(){if(!sa)return 0;let e=ra.match(/Version\/(\d+)/);return e===null||e.length<2?0:parseInt(e[1])}var la=[`Macintosh`,`MacIntel`,`MacPPC`,`Mac68K`].includes(ia),ua=ia===`iPad`,da=ia===`iPhone`,fa=[`Windows`,`Win16`,`Win32`,`WinCE`].includes(ia),pa=ia.indexOf(`Linux`)>=0,ma=/\bCrOS\b/.test(ra),ha=class{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ii){r-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-t))}ms`),this._start();return}r=i}this.clear()}},ga=class extends ha{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){let t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}},_a=class extends ha{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}},va=!na&&`requestIdleCallback`in window?_a:ga,ya=class{constructor(){this._queue=new va}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}},ba=class extends I{constructor(e,t,n,r,i,a,o,s,c){super(),this._rowCount=e,this._optionsService=n,this._charSizeService=r,this._coreService=i,this._coreBrowserService=s,this._renderer=this._register(new ft),this._pausedResizeTask=new ya,this._observerDisposable=this._register(new ft),this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this._register(new R),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this._register(new R),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this._register(new R),this.onRender=this._onRender.event,this._onRefreshRequest=this._register(new R),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new ea((e,t)=>this._renderRows(e,t),this._coreBrowserService),this._register(this._renderDebouncer),this._syncOutputHandler=new xa(this._coreBrowserService,this._coreService,()=>this._fullRefresh()),this._register(F(()=>this._syncOutputHandler.dispose())),this._register(this._coreBrowserService.onDprChange(()=>this.handleDevicePixelRatioChange())),this._register(o.onResize(()=>this._fullRefresh())),this._register(o.buffers.onBufferActivate(()=>this._renderer.value?.clear())),this._register(this._optionsService.onOptionChange(()=>this._handleOptionsChanged())),this._register(this._charSizeService.onCharSizeChange(()=>this.handleCharSizeChanged())),this._register(a.onDecorationRegistered(()=>this._fullRefresh())),this._register(a.onDecorationRemoved(()=>this._fullRefresh())),this._register(this._optionsService.onMultipleOptionChange([`customGlyphs`,`drawBoldTextInBrightColors`,`letterSpacing`,`lineHeight`,`fontFamily`,`fontSize`,`fontWeight`,`fontWeightBold`,`minimumContrastRatio`,`rescaleOverlappingGlyphs`],()=>{this.clear(),this.handleResize(o.cols,o.rows),this._fullRefresh()})),this._register(this._optionsService.onMultipleOptionChange([`cursorBlink`,`cursorStyle`],()=>this.refreshRows(o.buffer.y,o.buffer.y,!0))),this._register(c.onChangeColors(()=>this._fullRefresh())),this._registerIntersectionObserver(this._coreBrowserService.window,t),this._register(this._coreBrowserService.onWindowChange(e=>this._registerIntersectionObserver(e,t)))}get dimensions(){return this._renderer.value.dimensions}_registerIntersectionObserver(e,t){if(`IntersectionObserver`in e){let n=new e.IntersectionObserver(e=>this._handleIntersectionChange(e[e.length-1]),{threshold:0});n.observe(t),this._observerDisposable.value=F(()=>n.disconnect())}}_handleIntersectionChange(e){this._isPaused=e.isIntersecting===void 0?e.intersectionRatio===0:!e.isIntersecting,!this._isPaused&&!this._charSizeService.hasValidSize&&this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,n=!1){if(this._isPaused){this._needsFullRefresh=!0;return}if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}let r=this._syncOutputHandler.flush();r&&(e=Math.min(e,r.start),t=Math.max(t,r.end)),n||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount)}_renderRows(e,t){if(this._renderer.value){if(this._coreService.decPrivateModes.synchronizedOutput){this._syncOutputHandler.bufferRows(e,t);return}e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&=(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0}}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value&&(this._renderer.value.onRequestRedraw(e=>this.refreshRows(e.start,e.end,!0)),this._needsSelectionRefresh=!0,this._fullRefresh())}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){this._renderer.value&&(this._renderer.value.clearTextureAtlas?.(),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set(()=>this._renderer.value?.handleResize(e,t)):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){this._renderer.value?.handleCharSizeChanged()}handleBlur(){this._renderer.value?.handleBlur()}handleFocus(){this._renderer.value?.handleFocus()}handleSelectionChanged(e,t,n){this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=n,this._renderer.value?.handleSelectionChanged(e,t,n)}handleCursorMove(){this._renderer.value?.handleCursorMove()}clear(){this._renderer.value?.clear()}};ba=x([S(2,be),S(3,Ee),S(4,ge),S(5,Ce),S(6,P),S(7,De),S(8,Me)],ba);var xa=class{constructor(e,t,n){this._coreBrowserService=e,this._coreService=t,this._onTimeout=n,this._start=0,this._end=0,this._isBuffering=!1}bufferRows(e,t){this._isBuffering?(this._start=Math.min(this._start,e),this._end=Math.max(this._end,t)):(this._start=e,this._end=t,this._isBuffering=!0),this._timeout===void 0&&(this._timeout=this._coreBrowserService.window.setTimeout(()=>{this._timeout=void 0,this._coreService.decPrivateModes.synchronizedOutput=!1,this._onTimeout()},1e3))}flush(){if(this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0),!this._isBuffering)return;let e={start:this._start,end:this._end};return this._isBuffering=!1,e}dispose(){this._timeout!==void 0&&(this._coreBrowserService.window.clearTimeout(this._timeout),this._timeout=void 0)}};function Sa(e,t,n,r){let i=n.buffer.x,a=n.buffer.y;if(!n.buffer.hasScrollback)return Ta(i,a,e,t,n,r)+Ea(a,t,n,r)+Da(i,a,e,t,n,r);let o;if(a===t)return o=i>e?`D`:`C`,Pa(Math.abs(i-e),Na(o,r));o=a>t?`D`:`C`;let s=Math.abs(a-t);return Pa(wa(a>t?e:i,n)+(s-1)*n.cols+1+Ca(a>t?i:e,n),Na(o,r))}function Ca(e,t){return e-1}function wa(e,t){return t.cols-e}function Ta(e,t,n,r,i,a){return Ea(t,r,i,a).length===0?``:Pa(Ma(e,t,e,t-ka(t,i),!1,i).length,Na(`D`,a))}function Ea(e,t,n,r){let i=e-ka(e,n),a=t-ka(t,n);return Pa(Math.abs(i-a)-Oa(e,t,n),Na(ja(e,t),r))}function Da(e,t,n,r,i,a){let o;o=Ea(t,r,i,a).length>0?r-ka(r,i):t;let s=r,c=Aa(e,t,n,r,i,a);return Pa(Ma(e,o,n,s,c===`C`,i).length,Na(c,a))}function Oa(e,t,n){let r=0,i=e-ka(e,n),a=t-ka(t,n);for(let o=0;o=0&&e0?r-ka(r,i):t,e=n&&ot?`A`:`B`}function Ma(e,t,n,r,i,a){let o=e,s=t,c=``;for(;(o!==n||s!==r)&&s>=0&&sa.cols-1?(c+=a.buffer.translateBufferLineToString(s,!1,e,o),o=0,e=0,s++):!i&&o<0&&(c+=a.buffer.translateBufferLineToString(s,!1,0,e+1),o=a.cols-1,e=o,s--);return c+a.buffer.translateBufferLineToString(s,!1,e,o)}function Na(e,t){let n=t?`O`:`[`;return B.ESC+n+e}function Pa(e,t){e=Math.floor(e);let n=``;for(let r=0;rthis._bufferService.cols?e%this._bufferService.cols===0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){let e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){let e=this.selectionStart,t=this.selectionEnd;return!e||!t?!1:e[1]>t[1]||e[1]===t[1]&&e[0]>t[0]}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}};function Ia(e,t){if(e.start.y>e.end.y)throw Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}var La=50,Ra=15,za=50,Ba=500,Va=RegExp(`\xA0`,`g`),Ha=class extends I{constructor(e,t,n,r,i,a,o,s,c){super(),this._element=e,this._screenElement=t,this._linkifier=n,this._bufferService=r,this._coreService=i,this._mouseService=a,this._optionsService=o,this._renderService=s,this._coreBrowserService=c,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new M,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this._register(new R),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this._register(new R),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this._register(new R),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this._register(new R),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput(()=>{this.hasSelection&&this.clearSelection()}),this._trimListener=this._bufferService.buffer.lines.onTrim(e=>this._handleTrim(e)),this._register(this._bufferService.buffers.onBufferActivate(e=>this._handleBufferActivate(e))),this.enable(),this._model=new Fa(this._bufferService),this._activeSelectionMode=0,this._register(F(()=>{this._removeMouseDownListeners()})),this._register(this._bufferService.onResize(e=>{e.rowsChanged&&this.clearSelection()}))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!e||!t?!1:e[0]!==t[0]||e[1]!==t[1]}get selectionText(){let e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return``;let n=this._bufferService.buffer,r=[];if(this._activeSelectionMode===3){if(e[0]===t[0])return``;let i=e[0]e.replace(Va,` `)).join(fa?`\r +`:` +`)}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||=this._coreBrowserService.window.requestAnimationFrame(()=>this._refresh()),pa&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:this._activeSelectionMode===3})}_isClickInSelection(e){let t=this._getMouseBufferCoords(e),n=this._model.finalSelectionStart,r=this._model.finalSelectionEnd;return!n||!r||!t?!1:this._areCoordsInSelection(t,n,r)}isCellInSelection(e,t){let n=this._model.finalSelectionStart,r=this._model.finalSelectionEnd;return!n||!r?!1:this._areCoordsInSelection([e,t],n,r)}_areCoordsInSelection(e,t,n){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){let n=this._linkifier.currentLink?.link?.range;if(n)return this._model.selectionStart=[n.start.x-1,n.start.y-1],this._model.selectionStartLength=Ia(n,this._bufferService.cols),this._model.selectionEnd=void 0,!0;let r=this._getMouseBufferCoords(e);return r?(this._selectWordAt(r,t),this._model.selectionEnd=void 0,!0):!1}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){let t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=Zi(this._coreBrowserService.window,e,this._screenElement)[1],n=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=n?0:(t>n&&(t-=n),t=Math.min(Math.max(t,-La),La),t/=La,t/Math.abs(t)+Math.round(t*(Ra-1)))}shouldForceSelection(e){return la?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,!(e.button===2&&this.hasSelection)&&e.button===0){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):e.detail===1?this._handleSingleClick(e):e.detail===2?this._handleDoubleClick(e):e.detail===3&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener(`mousemove`,this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener(`mouseup`,this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval(()=>this._dragScroll(),za)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener(`mousemove`,this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener(`mouseup`,this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;let t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&t.hasWidth(this._model.selectionStart[0])===0&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){let t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(la&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;let t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd){this.refresh(!0);return}this._activeSelectionMode===2?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));let n=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(this._activeSelectionMode!==3&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){let t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&tthis._handleTrim(e))}_convertViewportColToCharacterIndex(e,t){let n=t;for(let r=0;t>=r;r++){let i=e.loadCell(r,this._workCell).getChars().length;this._workCell.getWidth()===0?n--:i>1&&t!==r&&(n+=i-1)}return n}setSelection(e,t,n){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=n,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,n=!0,r=!0){if(e[0]>=this._bufferService.cols)return;let i=this._bufferService.buffer,a=i.lines.get(e[1]);if(!a)return;let o=i.translateBufferLineToString(e[1],!1),s=this._convertViewportColToCharacterIndex(a,e[0]),c=s,l=e[0]-s,u=0,d=0,f=0,p=0;if(o.charAt(s)===` `){for(;s>0&&o.charAt(s-1)===` `;)s--;for(;c1&&(p+=r-1,c+=r-1);t>0&&s>0&&!this._isCharWordSeparator(a.loadCell(t-1,this._workCell));){a.loadCell(t-1,this._workCell);let e=this._workCell.getChars().length;this._workCell.getWidth()===0?(u++,t--):e>1&&(f+=e-1,s-=e-1),s--,t--}for(;n1&&(p+=e-1,c+=e-1),c++,n++}}c++;let m=s+l-u+f,h=Math.min(this._bufferService.cols,c-s+u+d-f-p);if(!(!t&&o.slice(s,c).trim()===``)){if(n&&m===0&&a.getCodePoint(0)!==32){let t=i.lines.get(e[1]-1);if(t&&a.isWrapped&&t.getCodePoint(this._bufferService.cols-1)!==32){let t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){let e=this._bufferService.cols-t.start;m-=e,h+=e}}}if(r&&m+h===this._bufferService.cols&&a.getCodePoint(this._bufferService.cols-1)!==32){let t=i.lines.get(e[1]+1);if(t?.isWrapped&&t.getCodePoint(0)!==32){let t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(h+=t.length)}}return{start:m,length:h}}}_selectWordAt(e,t){let n=this._getWordAt(e,t);if(n){for(;n.start<0;)n.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[n.start,e[1]],this._model.selectionStartLength=n.length}}_selectToWordAt(e){let t=this._getWordAt(e,!0);if(t){let n=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,n--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,n++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,n]}}_isCharWordSeparator(e){return e.getWidth()===0?!1:this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){let t=this._bufferService.buffer.getWrappedRangeForLine(e),n={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=Ia(n,this._bufferService.cols)}};Ha=x([S(3,P),S(4,ge),S(5,Oe),S(6,be),S(7,ke),S(8,De)],Ha);var Ua=class{constructor(){this._data={}}set(e,t,n){this._data[e]||(this._data[e]={}),this._data[e][t]=n}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}},Wa=class{constructor(){this._color=new Ua,this._css=new Ua}setCss(e,t,n){this._css.set(e,t,n)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,n){this._color.set(e,t,n)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}},Ga=Object.freeze((()=>{let e=[W.toColor(`#2e3436`),W.toColor(`#cc0000`),W.toColor(`#4e9a06`),W.toColor(`#c4a000`),W.toColor(`#3465a4`),W.toColor(`#75507b`),W.toColor(`#06989a`),W.toColor(`#d3d7cf`),W.toColor(`#555753`),W.toColor(`#ef2929`),W.toColor(`#8ae234`),W.toColor(`#fce94f`),W.toColor(`#729fcf`),W.toColor(`#ad7fa8`),W.toColor(`#34e2e2`),W.toColor(`#eeeeec`)],t=[0,95,135,175,215,255];for(let n=0;n<216;n++){let r=t[n/36%6|0],i=t[n/6%6|0],a=t[n%6];e.push({css:H.toCss(r,i,a),rgba:H.toRgba(r,i,a)})}for(let t=0;t<24;t++){let n=8+t*10;e.push({css:H.toCss(n,n,n),rgba:H.toRgba(n,n,n)})}return e})()),Ka=W.toColor(`#ffffff`),qa=W.toColor(`#000000`),Ja=W.toColor(`#ffffff`),Ya=qa,Xa={css:`rgba(255, 255, 255, 0.3)`,rgba:4294967117},Za=Ka,Qa=class extends I{constructor(e){super(),this._optionsService=e,this._contrastCache=new Wa,this._halfContrastCache=new Wa,this._onChangeColors=this._register(new R),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:Ka,background:qa,cursor:Ja,cursorAccent:Ya,selectionForeground:void 0,selectionBackgroundTransparent:Xa,selectionBackgroundOpaque:U.blend(qa,Xa),selectionInactiveBackgroundTransparent:Xa,selectionInactiveBackgroundOpaque:U.blend(qa,Xa),scrollbarSliderBackground:U.opacity(Ka,.2),scrollbarSliderHoverBackground:U.opacity(Ka,.4),scrollbarSliderActiveBackground:U.opacity(Ka,.5),overviewRulerBorder:Ka,ansi:Ga.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this._register(this._optionsService.onSpecificOptionChange(`minimumContrastRatio`,()=>this._contrastCache.clear())),this._register(this._optionsService.onSpecificOptionChange(`theme`,()=>this._setTheme(this._optionsService.rawOptions.theme)))}get colors(){return this._colors}_setTheme(e={}){let t=this._colors;if(t.foreground=G(e.foreground,Ka),t.background=G(e.background,qa),t.cursor=U.blend(t.background,G(e.cursor,Ja)),t.cursorAccent=U.blend(t.background,G(e.cursorAccent,Ya)),t.selectionBackgroundTransparent=G(e.selectionBackground,Xa),t.selectionBackgroundOpaque=U.blend(t.background,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundTransparent=G(e.selectionInactiveBackground,t.selectionBackgroundTransparent),t.selectionInactiveBackgroundOpaque=U.blend(t.background,t.selectionInactiveBackgroundTransparent),t.selectionForeground=e.selectionForeground?G(e.selectionForeground,yi):void 0,t.selectionForeground===yi&&(t.selectionForeground=void 0),U.isOpaque(t.selectionBackgroundTransparent)&&(t.selectionBackgroundTransparent=U.opacity(t.selectionBackgroundTransparent,.3)),U.isOpaque(t.selectionInactiveBackgroundTransparent)&&(t.selectionInactiveBackgroundTransparent=U.opacity(t.selectionInactiveBackgroundTransparent,.3)),t.scrollbarSliderBackground=G(e.scrollbarSliderBackground,U.opacity(t.foreground,.2)),t.scrollbarSliderHoverBackground=G(e.scrollbarSliderHoverBackground,U.opacity(t.foreground,.4)),t.scrollbarSliderActiveBackground=G(e.scrollbarSliderActiveBackground,U.opacity(t.foreground,.5)),t.overviewRulerBorder=G(e.overviewRulerBorder,Za),t.ansi=Ga.slice(),t.ansi[0]=G(e.black,Ga[0]),t.ansi[1]=G(e.red,Ga[1]),t.ansi[2]=G(e.green,Ga[2]),t.ansi[3]=G(e.yellow,Ga[3]),t.ansi[4]=G(e.blue,Ga[4]),t.ansi[5]=G(e.magenta,Ga[5]),t.ansi[6]=G(e.cyan,Ga[6]),t.ansi[7]=G(e.white,Ga[7]),t.ansi[8]=G(e.brightBlack,Ga[8]),t.ansi[9]=G(e.brightRed,Ga[9]),t.ansi[10]=G(e.brightGreen,Ga[10]),t.ansi[11]=G(e.brightYellow,Ga[11]),t.ansi[12]=G(e.brightBlue,Ga[12]),t.ansi[13]=G(e.brightMagenta,Ga[13]),t.ansi[14]=G(e.brightCyan,Ga[14]),t.ansi[15]=G(e.brightWhite,Ga[15]),e.extendedAnsi){let n=Math.min(t.ansi.length-16,e.extendedAnsi.length);for(let r=0;re.index-t.index),r=[];for(let t of n){let n=this._services.get(t.id);if(!n)throw Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id._id}.`);r.push(n)}let i=n.length>0?n[0].index:t.length;if(t.length!==i)throw Error(`[createInstance] First service dependency of ${e.name} at position ${i+1} conflicts with ${t.length} static arguments`);return new e(...t,...r)}},to={trace:0,debug:1,info:2,warn:3,error:4,off:5},no=`xterm.js: `,ro=class extends I{constructor(e){super(),this._optionsService=e,this._logLevel=5,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange(`logLevel`,()=>this._updateLogLevel())),io=this}get logLevel(){return this._logLevel}_updateLogLevel(){this._logLevel=to[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tthis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+n.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){let e=this._length+n.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=n.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,n){if(!(t<=0)){if(e<0||e>=this._length)throw Error(`start argument out of range`);if(e+n<0)throw Error(`Cannot shift elements in list beyond index 0`);if(n>0){for(let r=t-1;r>=0;r--)this.set(e+r+n,this.get(e+r));let r=e+t+n-this._length;if(r>0)for(this._length+=r;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let r=0;r>22,t&2097152?this._combined[e].charCodeAt(this._combined[e].length-1):n]}set(e,t){this._data[e*K+1]=t[0],t[1].length>1?(this._combined[e]=t[1],this._data[e*K+0]=e|2097152|t[2]<<22):this._data[e*K+0]=t[1].charCodeAt(0)|t[2]<<22}getWidth(e){return this._data[e*K+0]>>22}hasWidth(e){return this._data[e*K+0]&12582912}getFg(e){return this._data[e*K+1]}getBg(e){return this._data[e*K+2]}hasContent(e){return this._data[e*K+0]&4194303}getCodePoint(e){let t=this._data[e*K+0];return t&2097152?this._combined[e].charCodeAt(this._combined[e].length-1):t&2097151}isCombined(e){return this._data[e*K+0]&2097152}getString(e){let t=this._data[e*K+0];return t&2097152?this._combined[e]:t&2097151?A(t&2097151):``}isProtected(e){return this._data[e*K+2]&536870912}loadCell(e,t){return oo=e*K,t.content=this._data[oo+0],t.fg=this._data[oo+1],t.bg=this._data[oo+2],t.content&2097152&&(t.combinedData=this._combined[e]),t.bg&268435456&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){t.content&2097152&&(this._combined[e]=t.combinedData),t.bg&268435456&&(this._extendedAttrs[e]=t.extended),this._data[e*K+0]=t.content,this._data[e*K+1]=t.fg,this._data[e*K+2]=t.bg}setCellFromCodepoint(e,t,n,r){r.bg&268435456&&(this._extendedAttrs[e]=r.extended),this._data[e*K+0]=t|n<<22,this._data[e*K+1]=r.fg,this._data[e*K+2]=r.bg}addCodepointToCell(e,t,n){let r=this._data[e*K+0];r&2097152?this._combined[e]+=A(t):r&2097151?(this._combined[e]=A(r&2097151)+A(t),r&=-2097152,r|=2097152):r=t|1<<22,n&&(r&=-12582913,r|=n<<22),this._data[e*K+0]=r}insertCells(e,t,n){if(e%=this.length,e&&this.getWidth(e-1)===2&&this.setCellFromCodepoint(e-1,0,1,n),t=0;--n)this.setCell(e+t+n,this.loadCell(e+n,r));for(let r=0;rthis.length){if(this._data.buffer.byteLength>=n*4)this._data=new Uint32Array(this._data.buffer,0,n);else{let e=new Uint32Array(n);e.set(this._data),this._data=e}for(let n=this.length;n=e&&delete this._combined[r]}let r=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[n]}}return this.length=e,n*4*so=0;--e)if(this._data[e*K+0]&4194303)return e+(this._data[e*K+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(this._data[e*K+0]&4194303||this._data[e*K+2]&50331648)return e+(this._data[e*K+0]>>22);return 0}copyCellsFrom(e,t,n,r,i){let a=e._data;if(i)for(let i=r-1;i>=0;i--){for(let e=0;e=t&&(this._combined[i-t+n]=e._combined[i])}}translateToString(e,t,n,r){t??=0,n??=this.length,e&&(n=Math.min(n,this.getTrimmedLength())),r&&(r.length=0);let i=``;for(;t>22||1}return r&&r.push(t),i}};function lo(e,t,n,r,i,a){let o=[];for(let s=0;s=s&&r0&&(e>d||u[e].getTrimmedLength()===0);e--)h++;h>0&&(o.push(s+u.length-h),o.push(h)),s+=u.length-1}return o}function uo(e,t){let n=[],r=0,i=t[r],a=0;for(let o=0;omo(e,r,t)).reduce((e,t)=>e+t),a=0,o=0,s=0;for(;sc&&(a-=c,o++);let l=e[o].getWidth(a-1)===2;l&&a--;let u=l?n-1:n;r.push(u),s+=u}return r}function mo(e,t,n){if(t===e.length-1)return e[t].getTrimmedLength();let r=!e[t].hasContent(n-1)&&e[t].getWidth(n-1)===1,i=e[t+1].getWidth(0)===2;return r&&i?n-1:n}var ho=class e{constructor(t){this.line=t,this.isDisposed=!1,this._disposables=[],this._id=e._nextId++,this._onDispose=this.register(new R),this.onDispose=this._onDispose.event}get id(){return this._id}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),ct(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}};ho._nextId=1;var go=ho,_o={},vo=_o.B;_o[0]={"`":`◆`,a:`▒`,b:`␉`,c:`␌`,d:`␍`,e:`␊`,f:`°`,g:`±`,h:`␤`,i:`␋`,j:`┘`,k:`┐`,l:`┌`,m:`└`,n:`┼`,o:`⎺`,p:`⎻`,q:`─`,r:`⎼`,s:`⎽`,t:`├`,u:`┤`,v:`┴`,w:`┬`,x:`│`,y:`≤`,z:`≥`,"{":`π`,"|":`≠`,"}":`£`,"~":`·`},_o.A={"#":`£`},_o.B=void 0,_o[4]={"#":`£`,"@":`¾`,"[":`ij`,"\\":`½`,"]":`|`,"{":`¨`,"|":`f`,"}":`¼`,"~":`´`},_o.C=_o[5]={"[":`Ä`,"\\":`Ö`,"]":`Å`,"^":`Ü`,"`":`é`,"{":`ä`,"|":`ö`,"}":`å`,"~":`ü`},_o.R={"#":`£`,"@":`à`,"[":`°`,"\\":`ç`,"]":`§`,"{":`é`,"|":`ù`,"}":`è`,"~":`¨`},_o.Q={"@":`à`,"[":`â`,"\\":`ç`,"]":`ê`,"^":`î`,"`":`ô`,"{":`é`,"|":`ù`,"}":`è`,"~":`û`},_o.K={"@":`§`,"[":`Ä`,"\\":`Ö`,"]":`Ü`,"{":`ä`,"|":`ö`,"}":`ü`,"~":`ß`},_o.Y={"#":`£`,"@":`§`,"[":`°`,"\\":`ç`,"]":`é`,"`":`ù`,"{":`à`,"|":`ò`,"}":`è`,"~":`ì`},_o.E=_o[6]={"@":`Ä`,"[":`Æ`,"\\":`Ø`,"]":`Å`,"^":`Ü`,"`":`ä`,"{":`æ`,"|":`ø`,"}":`å`,"~":`ü`},_o.Z={"#":`£`,"@":`§`,"[":`¡`,"\\":`Ñ`,"]":`¿`,"{":`°`,"|":`ñ`,"}":`ç`},_o.H=_o[7]={"@":`É`,"[":`Ä`,"\\":`Ö`,"]":`Å`,"^":`Ü`,"`":`é`,"{":`ä`,"|":`ö`,"}":`å`,"~":`ü`},_o[`=`]={"#":`ù`,"@":`à`,"[":`é`,"\\":`ç`,"]":`ê`,"^":`î`,_:`è`,"`":`ô`,"{":`ä`,"|":`ö`,"}":`ü`,"~":`û`};var yo=4294967295,bo=class{constructor(e,t,n){this._hasScrollback=e,this._optionsService=t,this._bufferService=n,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=q.clone(),this.savedCharset=vo,this.markers=[],this._nullCell=M.fromCharData([0,oe,1,0]),this._whitespaceCell=M.fromCharData([0,se,1,32]),this._isClearing=!1,this._memoryCleanupQueue=new va,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new ao(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new le),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new le),this._whitespaceCell}getBlankLine(e,t){return new co(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){let e=this.ybase+this.y-this.ydisp;return e>=0&&eyo?yo:t}fillViewportRows(e){if(this.lines.length===0){e===void 0&&(e=q);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new ao(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){let n=this.getNullCell(q),r=0,i=this._getCorrectBufferLength(t);if(i>this.lines.maxLength&&(this.lines.maxLength=i),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+a+1?(this.ybase--,a++,this.ydisp>0&&this.ydisp--):this.lines.push(new co(e,n)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(i0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=i}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),a&&(this.y+=a),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue(()=>this._batchedMemoryCleanup()))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){let e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&e.backend===`conpty`&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){let n=this._optionsService.rawOptions.reflowCursorLine,r=lo(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(q),n);if(r.length>0){let n=uo(this.lines,r);fo(this.lines,n.layout),this._reflowLargerAdjustViewport(e,t,n.countRemoved)}}_reflowLargerAdjustViewport(e,t,n){let r=this.getNullCell(q),i=n;for(;i-- >0;)this.ybase===0?(this.y>0&&this.y--,this.lines.length=0;o--){let s=this.lines.get(o);if(!s||!s.isWrapped&&s.getTrimmedLength()<=e)continue;let c=[s];for(;s.isWrapped&&o>0;)s=this.lines.get(--o),c.unshift(s);if(!n){let e=this.ybase+this.y;if(e>=o&&e0&&(i.push({start:o+c.length+a,newLines:p}),a+=p.length),c.push(...p);let m=u.length-1,h=u[m];h===0&&(m--,h=u[m]);let g=c.length-d-1,_=l;for(;g>=0;){let e=Math.min(_,h);if(c[m]===void 0)break;c[m].copyCellsFrom(c[g],_-e,h-e,e,!0),h-=e,h===0&&(m--,h=u[m]),_-=e,_===0&&(g--,_=mo(c,Math.max(g,0),this._cols))}for(let t=0;t0;)this.ybase===0?this.y0){let e=[],t=[];for(let e=0;e=0;l--)if(s&&s.start>r+c){for(let e=s.newLines.length-1;e>=0;e--)this.lines.set(l--,s.newLines[e]);l++,e.push({index:r+1,amount:s.newLines.length}),c+=s.newLines.length,s=i[++o]}else this.lines.set(l,t[r--]);let l=0;for(let t=e.length-1;t>=0;t--)e[t].index+=l,this.lines.onInsertEmitter.fire(e[t]),l+=e[t].amount;let u=Math.max(0,n+a-this.lines.maxLength);u>0&&this.lines.onTrimEmitter.fire(u)}}translateBufferLineToString(e,t,n=0,r){let i=this.lines.get(e);return i?i.translateToString(t,n,r):``}getWrappedRangeForLine(e){let t=e,n=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;n+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(e??=this.x;!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()})),t.register(this.lines.onInsert(e=>{t.line>=e.index&&(t.line+=e.amount)})),t.register(this.lines.onDelete(e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)})),t.register(t.onDispose(()=>this._removeMarker(t))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}},xo=class extends I{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this._register(new R),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this._register(this._optionsService.onSpecificOptionChange(`scrollback`,()=>this.resize(this._bufferService.cols,this._bufferService.rows))),this._register(this._optionsService.onSpecificOptionChange(`tabStopWidth`,()=>this.setupTabStops()))}reset(){this._normal=new bo(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new bo(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}},So=2,Co=1,wo=class extends I{constructor(e){super(),this.isUserScrolling=!1,this._onResize=this._register(new R),this.onResize=this._onResize.event,this._onScroll=this._register(new R),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,So),this.rows=Math.max(e.rawOptions.rows||0,Co),this.buffers=this._register(new xo(e,this)),this._register(this.buffers.onBufferActivate(e=>{this._onScroll.fire(e.activeBuffer.ydisp)}))}get buffer(){return this.buffers.active}resize(e,t){let n=this.cols!==e,r=this.rows!==t;this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t,colsChanged:n,rowsChanged:r})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){let n=this.buffer,r;r=this._cachedBlankLine,(!r||r.length!==this.cols||r.getFg(0)!==e.fg||r.getBg(0)!==e.bg)&&(r=n.getBlankLine(e,t),this._cachedBlankLine=r),r.isWrapped=t;let i=n.ybase+n.scrollTop,a=n.ybase+n.scrollBottom;if(n.scrollTop===0){let e=n.lines.isFull;a===n.lines.length-1?e?n.lines.recycle().copyFrom(r):n.lines.push(r.clone()):n.lines.splice(a+1,0,r.clone()),e?this.isUserScrolling&&(n.ydisp=Math.max(n.ydisp-1,0)):(n.ybase++,this.isUserScrolling||n.ydisp++)}else{let e=a-i+1;n.lines.shiftElements(i+1,e-1,-1),n.lines.set(a,r.clone())}this.isUserScrolling||(n.ydisp=n.ybase),this._onScroll.fire(n.ydisp)}scrollLines(e,t){let n=this.buffer;if(e<0){if(n.ydisp===0)return;this.isUserScrolling=!0}else e+n.ydisp>=n.ybase&&(this.isUserScrolling=!1);let r=n.ydisp;n.ydisp=Math.max(Math.min(n.ydisp+e,n.ybase),0),r!==n.ydisp&&(t||this._onScroll.fire(n.ydisp))}};wo=x([S(0,be)],wo);var To={cols:80,rows:24,cursorBlink:!1,cursorStyle:`block`,cursorWidth:1,cursorInactiveStyle:`outline`,customGlyphs:!0,drawBoldTextInBrightColors:!0,documentOverride:null,fastScrollModifier:`alt`,fastScrollSensitivity:5,fontFamily:`monospace`,fontSize:15,fontWeight:`normal`,fontWeightBold:`bold`,ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:`info`,logger:null,scrollback:1e3,scrollOnEraseInDisplay:!1,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},reflowCursorLine:!1,rescaleOverlappingGlyphs:!1,rightClickSelectsWord:la,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:` ()[]{}',"\``,altClickMovesCursor:!0,convertEol:!1,termName:`xterm`,cancelEvents:!1,overviewRuler:{}},Eo=[`normal`,`bold`,`100`,`200`,`300`,`400`,`500`,`600`,`700`,`800`,`900`],Do=class extends I{constructor(e){super(),this._onOptionChange=this._register(new R),this.onOptionChange=this._onOptionChange.event;let t={...To};for(let n in e)if(n in t)try{let r=e[n];t[n]=this._sanitizeAndValidateOption(n,r)}catch(e){console.error(e)}this.rawOptions=t,this.options={...t},this._setupOptions(),this._register(F(()=>{this.rawOptions.linkHandler=null,this.rawOptions.documentOverride=null}))}onSpecificOptionChange(e,t){return this.onOptionChange(n=>{n===e&&t(this.rawOptions[e])})}onMultipleOptionChange(e,t){return this.onOptionChange(n=>{e.indexOf(n)!==-1&&t()})}_setupOptions(){let e=e=>{if(!(e in To))throw Error(`No option with key "${e}"`);return this.rawOptions[e]},t=(e,t)=>{if(!(e in To))throw Error(`No option with key "${e}"`);t=this._sanitizeAndValidateOption(e,t),this.rawOptions[e]!==t&&(this.rawOptions[e]=t,this._onOptionChange.fire(e))};for(let n in this.rawOptions){let r={get:e.bind(this,n),set:t.bind(this,n)};Object.defineProperty(this.options,n,r)}}_sanitizeAndValidateOption(e,t){switch(e){case`cursorStyle`:if(t||=To[e],!Oo(t))throw Error(`"${t}" is not a valid value for ${e}`);break;case`wordSeparator`:t||=To[e];break;case`fontWeight`:case`fontWeightBold`:if(typeof t==`number`&&1<=t&&t<=1e3)break;t=Eo.includes(t)?t:To[e];break;case`cursorWidth`:t=Math.floor(t);case`lineHeight`:case`tabStopWidth`:if(t<1)throw Error(`${e} cannot be less than 1, value: ${t}`);break;case`minimumContrastRatio`:t=Math.max(1,Math.min(21,Math.round(t*10)/10));break;case`scrollback`:if(t=Math.min(t,4294967295),t<0)throw Error(`${e} cannot be less than 0, value: ${t}`);break;case`fastScrollSensitivity`:case`scrollSensitivity`:if(t<=0)throw Error(`${e} cannot be less than or equal to 0, value: ${t}`);break;case`rows`:case`cols`:if(!t&&t!==0)throw Error(`${e} must be numeric, value: ${t}`);break;case`windowsPty`:t??={};break}return t}};function Oo(e){return e===`block`||e===`underline`||e===`bar`}function ko(e,t=5){if(typeof e!=`object`)return e;let n=Array.isArray(e)?[]:{};for(let r in e)n[r]=t<=1?e[r]:e[r]&&ko(e[r],t-1);return n}var Ao=Object.freeze({insertMode:!1}),jo=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,cursorBlink:void 0,cursorStyle:void 0,origin:!1,reverseWraparound:!1,sendFocus:!1,synchronizedOutput:!1,wraparound:!0}),Mo=class extends I{constructor(e,t,n){super(),this._bufferService=e,this._logService=t,this._optionsService=n,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this._register(new R),this.onData=this._onData.event,this._onUserInput=this._register(new R),this.onUserInput=this._onUserInput.event,this._onBinary=this._register(new R),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this._register(new R),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=ko(Ao),this.decPrivateModes=ko(jo)}reset(){this.modes=ko(Ao),this.decPrivateModes=ko(jo)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;let n=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&n.ybase!==n.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`),this._logService.trace(`sending data (codes)`,()=>e.split(``).map(e=>e.charCodeAt(0))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`),this._logService.trace(`sending binary (codes)`,()=>e.split(``).map(e=>e.charCodeAt(0))),this._onBinary.fire(e))}};Mo=x([S(0,P),S(1,ye),S(2,be)],Mo);var No={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>e.button===4||e.action!==1?!1:(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>e.action!==32},DRAG:{events:23,restrict:e=>!(e.action===32&&e.button===3)},ANY:{events:31,restrict:e=>!0}};function Po(e,t){let n=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return e.button===4?(n|=64,n|=e.action):(n|=e.button&3,e.button&4&&(n|=64),e.button&8&&(n|=128),e.action===32?n|=32:e.action===0&&!t&&(n|=3)),n}var Fo=String.fromCharCode,Io={DEFAULT:e=>{let t=[Po(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?``:`\x1B[M${Fo(t[0])}${Fo(t[1])}${Fo(t[2])}`},SGR:e=>{let t=e.action===0&&e.button!==4?`m`:`M`;return`\x1B[<${Po(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{let t=e.action===0&&e.button!==4?`m`:`M`;return`\x1B[<${Po(e,!0)};${e.x};${e.y}${t}`}},Lo=class extends I{constructor(e,t,n){super(),this._bufferService=e,this._coreService=t,this._optionsService=n,this._protocols={},this._encodings={},this._activeProtocol=``,this._activeEncoding=``,this._lastEvent=null,this._wheelPartialScroll=0,this._onProtocolChange=this._register(new R),this.onProtocolChange=this._onProtocolChange.event;for(let e of Object.keys(No))this.addProtocol(e,No[e]);for(let e of Object.keys(Io))this.addEncoding(e,Io[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return this._protocols[this._activeProtocol].events!==0}set activeProtocol(e){if(!this._protocols[e])throw Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol=`NONE`,this.activeEncoding=`DEFAULT`,this._lastEvent=null,this._wheelPartialScroll=0}consumeWheelEvent(e,t,n){if(e.deltaY===0||e.shiftKey||t===void 0||n===void 0)return 0;let r=t/n,i=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(i/=r+0,Math.abs(e.deltaY)<50&&(i*=.3),this._wheelPartialScroll+=i,i=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(i*=this._bufferService.rows),i}_applyScrollModifier(e,t){return t.altKey||t.ctrlKey||t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows||e.button===4&&e.action===32||e.button===3&&e.action!==32||e.button!==4&&(e.action===2||e.action===3)||(e.col++,e.row++,e.action===32&&this._lastEvent&&this._equalEvents(this._lastEvent,e,this._activeEncoding===`SGR_PIXELS`))||!this._protocols[this._activeProtocol].restrict(e))return!1;let t=this._encodings[this._activeEncoding](e);return t&&(this._activeEncoding===`DEFAULT`?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(e&1),up:!!(e&2),drag:!!(e&4),move:!!(e&8),wheel:!!(e&16)}}_equalEvents(e,t,n){if(n){if(e.x!==t.x||e.y!==t.y)return!1}else if(e.col!==t.col||e.row!==t.row)return!1;return!(e.button!==t.button||e.action!==t.action||e.ctrl!==t.ctrl||e.alt!==t.alt||e.shift!==t.shift)}};Lo=x([S(0,P),S(1,ge),S(2,be)],Lo);var Ro=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],zo=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]],Bo;function Vo(e,t){let n=0,r=t.length-1,i;if(et[r][1])return!1;for(;r>=n;)if(i=n+r>>1,e>t[i][1])n=i+1;else if(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let n=this.wcwidth(e),r=n===0&&t!==0;if(r){let e=Uo.extractWidth(t);e===0?r=!1:e>n&&(n=e)}return Uo.createPropertyValue(0,n,r)}},Uo=class e{constructor(){this._providers=Object.create(null),this._active=``,this._onChange=new R,this.onChange=this._onChange.event;let e=new Ho;this.register(e),this._active=e.version,this._activeProvider=e}static extractShouldJoin(e){return(e&1)!=0}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,n=!1){return(e&16777215)<<3|(t&3)<<1|(n?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(t){let n=0,r=0,i=t.length;for(let a=0;a=i)return n+this.wcwidth(o);let e=t.charCodeAt(a);56320<=e&&e<=57343?o=(o-55296)*1024+e-56320+65536:n+=this.wcwidth(e)}let s=this.charProperties(o,r),c=e.extractWidth(s);e.extractShouldJoin(s)&&(c-=e.extractWidth(r)),n+=c,r=s}return n}charProperties(e,t){return this._activeProvider.charProperties(e,t)}},Wo=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}};function Go(e){let t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1)?.get(e.cols-1),n=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);n&&t&&(n.isWrapped=t[3]!==0&&t[3]!==32)}var Ko=2147483647,qo=256,Jo=class e{constructor(e=32,t=32){if(this.maxLength=e,this.maxSubParamsLength=t,t>qo)throw Error(`maxSubParamsLength must not be greater than 256`);this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}static fromArray(t){let n=new e;if(!t.length)return n;for(let e=Array.isArray(t[0])?1:0;e>8,r=this._subParamsIdx[t]&255;r-n>0&&e.push(Array.prototype.slice.call(this._subParams,n,r))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength){this._rejectDigits=!0;return}if(e<-1)throw Error(`values lesser than -1 are not allowed`);this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>Ko?Ko:e}addSubParam(e){if(this._digitIsSub=!0,this.length){if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength){this._rejectSubDigits=!0;return}if(e<-1)throw Error(`values lesser than -1 are not allowed`);this._subParams[this._subParamsLength++]=e>Ko?Ko:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(this._subParamsIdx[e]&255)-(this._subParamsIdx[e]>>8)>0}getSubParams(e){let t=this._subParamsIdx[e]>>8,n=this._subParamsIdx[e]&255;return n-t>0?this._subParams.subarray(t,n):null}getSubParamsAll(){let e={};for(let t=0;t>8,r=this._subParamsIdx[t]&255;r-n>0&&(e[t]=this._subParams.slice(n,r))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;let n=this._digitIsSub?this._subParams:this.params,r=n[t-1];n[t-1]=~r?Math.min(r*10+e,Ko):e}},Yo=[],Xo=class{constructor(){this._state=0,this._active=Yo,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){this._handlers[e]===void 0&&(this._handlers[e]=[]);let n=this._handlers[e];return n.push(t),{dispose:()=>{let e=n.indexOf(t);e!==-1&&n.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=Yo}reset(){if(this._state===2)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=Yo,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||Yo,!this._active.length)this._handlerFb(this._id,`START`);else for(let e=this._active.length-1;e>=0;e--)this._active[e].start()}_put(e,t,n){if(!this._active.length)this._handlerFb(this._id,`PUT`,j(e,t,n));else for(let r=this._active.length-1;r>=0;r--)this._active[r].put(e,t,n)}start(){this.reset(),this._state=1}put(e,t,n){if(this._state!==3){if(this._state===1)for(;t0&&this._put(e,t,n)}}end(e,t=!0){if(this._state!==0){if(this._state!==3)if(this._state===1&&this._start(),!this._active.length)this._handlerFb(this._id,`END`,e);else{let n=!1,r=this._active.length-1,i=!1;if(this._stack.paused&&(r=this._stack.loopPosition-1,n=t,i=this._stack.fallThrough,this._stack.paused=!1),!i&&n===!1){for(;r>=0&&(n=this._active[r].end(e),n!==!0);r--)if(n instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!1,n;r--}for(;r>=0;r--)if(n=this._active[r].end(!1),n instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!0,n}this._active=Yo,this._id=-1,this._state=0}}},Zo=class{constructor(e){this._handler=e,this._data=``,this._hitLimit=!1}start(){this._data=``,this._hitLimit=!1}put(e,t,n){this._hitLimit||(this._data+=j(e,t,n),this._data.length>1e7&&(this._data=``,this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then(e=>(this._data=``,this._hitLimit=!1,e));return this._data=``,this._hitLimit=!1,t}},Qo=[],$o=class{constructor(){this._handlers=Object.create(null),this._active=Qo,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=Qo}registerHandler(e,t){this._handlers[e]===void 0&&(this._handlers[e]=[]);let n=this._handlers[e];return n.push(t),{dispose:()=>{let e=n.indexOf(t);e!==-1&&n.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=Qo,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||Qo,!this._active.length)this._handlerFb(this._ident,`HOOK`,t);else for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t)}put(e,t,n){if(!this._active.length)this._handlerFb(this._ident,`PUT`,j(e,t,n));else for(let r=this._active.length-1;r>=0;r--)this._active[r].put(e,t,n)}unhook(e,t=!0){if(!this._active.length)this._handlerFb(this._ident,`UNHOOK`,e);else{let n=!1,r=this._active.length-1,i=!1;if(this._stack.paused&&(r=this._stack.loopPosition-1,n=t,i=this._stack.fallThrough,this._stack.paused=!1),!i&&n===!1){for(;r>=0&&(n=this._active[r].unhook(e),n!==!0);r--)if(n instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!1,n;r--}for(;r>=0;r--)if(n=this._active[r].unhook(!1),n instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=r,this._stack.fallThrough=!0,n}this._active=Qo,this._ident=0}},es=new Jo;es.addParam(0);var ts=class{constructor(e){this._handler=e,this._data=``,this._params=es,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():es,this._data=``,this._hitLimit=!1}put(e,t,n){this._hitLimit||(this._data+=j(e,t,n),this._data.length>1e7&&(this._data=``,this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then(e=>(this._params=es,this._data=``,this._hitLimit=!1,e));return this._params=es,this._data=``,this._hitLimit=!1,t}},ns=class{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,n,r){this.table[t<<8|e]=n<<4|r}addMany(e,t,n,r){for(let i=0;it),n=(e,n)=>t.slice(e,n),r=n(32,127),i=n(0,24);i.push(25),i.push.apply(i,n(28,32));let a=n(0,14),o;for(o in e.setDefault(1,0),e.addMany(r,0,2,0),a)e.addMany([24,26,153,154],o,3,0),e.addMany(n(128,144),o,3,0),e.addMany(n(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(i,0,3,0),e.addMany(i,1,3,1),e.add(127,1,0,1),e.addMany(i,8,0,8),e.addMany(i,3,3,3),e.add(127,3,0,3),e.addMany(i,4,3,4),e.add(127,4,0,4),e.addMany(i,6,3,6),e.addMany(i,5,3,5),e.add(127,5,0,5),e.addMany(i,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(r,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(n(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(r,7,0,7),e.addMany(i,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(n(64,127),3,7,0),e.addMany(n(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(n(48,60),4,8,4),e.addMany(n(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(n(32,64),6,0,6),e.add(127,6,0,6),e.addMany(n(64,127),6,0,0),e.addMany(n(32,48),3,9,5),e.addMany(n(32,48),5,9,5),e.addMany(n(48,64),5,0,6),e.addMany(n(64,127),5,7,0),e.addMany(n(32,48),4,9,5),e.addMany(n(32,48),1,9,2),e.addMany(n(32,48),2,9,2),e.addMany(n(48,127),2,10,0),e.addMany(n(48,80),1,10,0),e.addMany(n(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(n(96,127),1,10,0),e.add(80,1,11,9),e.addMany(i,9,0,9),e.add(127,9,0,9),e.addMany(n(28,32),9,0,9),e.addMany(n(32,48),9,9,12),e.addMany(n(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(i,11,0,11),e.addMany(n(32,128),11,0,11),e.addMany(n(28,32),11,0,11),e.addMany(i,10,0,10),e.add(127,10,0,10),e.addMany(n(28,32),10,0,10),e.addMany(n(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(n(32,48),10,9,12),e.addMany(i,12,0,12),e.add(127,12,0,12),e.addMany(n(28,32),12,0,12),e.addMany(n(32,48),12,9,12),e.addMany(n(48,64),12,0,11),e.addMany(n(64,127),12,12,13),e.addMany(n(64,127),10,12,13),e.addMany(n(64,127),9,12,13),e.addMany(i,13,13,13),e.addMany(r,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(rs,0,2,0),e.add(rs,8,5,8),e.add(rs,6,0,6),e.add(rs,11,0,11),e.add(rs,13,13,13),e}(),as=class extends I{constructor(e=is){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new Jo,this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._printHandlerFb=(e,t,n)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this._register(F(()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)})),this._oscParser=this._register(new Xo),this._dcsParser=this._register(new $o),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:`\\`},()=>!0)}_identifier(e,t=[64,126]){let n=0;if(e.prefix){if(e.prefix.length>1)throw Error(`only one byte as prefix supported`);if(n=e.prefix.charCodeAt(0),n&&60>n||n>63)throw Error(`prefix must be in range 0x3c .. 0x3f`)}if(e.intermediates){if(e.intermediates.length>2)throw Error(`only two bytes as intermediates are supported`);for(let t=0;tr||r>47)throw Error(`intermediate must be in range 0x20 .. 0x2f`);n<<=8,n|=r}}if(e.final.length!==1)throw Error(`final must be a single byte`);let r=e.final.charCodeAt(0);if(t[0]>r||r>t[1])throw Error(`final must be in range ${t[0]} .. ${t[1]}`);return n<<=8,n|=r,n}identToString(e){let t=[];for(;e;)t.push(String.fromCharCode(e&255)),e>>=8;return t.reverse().join(``)}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){let n=this._identifier(e,[48,126]);this._escHandlers[n]===void 0&&(this._escHandlers[n]=[]);let r=this._escHandlers[n];return r.push(t),{dispose:()=>{let e=r.indexOf(t);e!==-1&&r.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){let n=this._identifier(e);this._csiHandlers[n]===void 0&&(this._csiHandlers[n]=[]);let r=this._csiHandlers[n];return r.push(t),{dispose:()=>{let e=r.indexOf(t);e!==-1&&r.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingJoinState=0,this._parseStack.state!==0&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,n,r,i){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=n,this._parseStack.transition=r,this._parseStack.chunkPos=i}parse(e,t,n){let r=0,i=0,a=0,o;if(this._parseStack.state)if(this._parseStack.state===2)this._parseStack.state=0,a=this._parseStack.chunkPos+1;else{if(n===void 0||this._parseStack.state===1)throw this._parseStack.state=1,Error(`improper continuation due to previous async handler, giving up parsing`);let t=this._parseStack.handlers,i=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(n===!1&&i>-1){for(;i>=0&&(o=t[i](this._params),o!==!0);i--)if(o instanceof Promise)return this._parseStack.handlerPos=i,o}this._parseStack.handlers=[];break;case 4:if(n===!1&&i>-1){for(;i>=0&&(o=t[i](),o!==!0);i--)if(o instanceof Promise)return this._parseStack.handlerPos=i,o}this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],o=this._dcsParser.unhook(r!==24&&r!==26,n),o)return o;r===27&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],o=this._oscParser.end(r!==24&&r!==26,n),o)return o;r===27&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break}this._parseStack.state=0,a=this._parseStack.chunkPos+1,this.precedingJoinState=0,this.currentState=this._parseStack.transition&15}for(let n=a;n>4){case 2:for(let i=n+1;;++i){if(i>=t||(r=e[i])<32||r>126&&r=t||(r=e[i])<32||r>126&&r=t||(r=e[i])<32||r>126&&r=t||(r=e[i])<32||r>126&&r=0&&(o=a[s](this._params),o!==!0);s--)if(o instanceof Promise)return this._preserveStack(3,a,s,i,n),o;s<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingJoinState=0;break;case 8:do switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}while(++n47&&r<60);n--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:let c=this._escHandlers[this._collect<<8|r],l=c?c.length-1:-1;for(;l>=0&&(o=c[l](),o!==!0);l--)if(o instanceof Promise)return this._preserveStack(4,c,l,i,n),o;l<0&&this._escHandlerFb(this._collect<<8|r),this.precedingJoinState=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let i=n+1;;++i)if(i>=t||(r=e[i])===24||r===26||r===27||r>127&&r=t||(r=e[i])<32||r>127&&r>4:i>>8}return n}}function ls(e,t){let n=e.toString(16),r=n.length<2?`0`+n:n;switch(t){case 4:return n[0];case 8:return r;case 12:return(r+r).slice(0,3);default:return r+r}}function us(e,t=16){let[n,r,i]=e;return`rgb:${ls(n,t)}/${ls(r,t)}/${ls(i,t)}`}var ds={"(":0,")":1,"*":2,"+":3,"-":1,".":2},fs=131072,ps=10;function ms(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var hs=5e3,gs=0,_s=class extends I{constructor(e,t,n,r,i,a,o,s,c=new as){super(),this._bufferService=e,this._charsetService=t,this._coreService=n,this._logService=r,this._optionsService=i,this._oscLinkService=a,this._coreMouseService=o,this._unicodeService=s,this._parser=c,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new ie,this._utf8Decoder=new ae,this._windowTitle=``,this._iconName=``,this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=q.clone(),this._eraseAttrDataInternal=q.clone(),this._onRequestBell=this._register(new R),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this._register(new R),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this._register(new R),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this._register(new R),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this._register(new R),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this._register(new R),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this._register(new R),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this._register(new R),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this._register(new R),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this._register(new R),this.onLineFeed=this._onLineFeed.event,this._onScroll=this._register(new R),this.onScroll=this._onScroll.event,this._onTitleChange=this._register(new R),this.onTitleChange=this._onTitleChange.event,this._onColor=this._register(new R),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this._register(this._parser),this._dirtyRowTracker=new vs(this._bufferService),this._activeBuffer=this._bufferService.buffer,this._register(this._bufferService.buffers.onBufferActivate(e=>this._activeBuffer=e.activeBuffer)),this._parser.setCsiHandlerFallback((e,t)=>{this._logService.debug(`Unknown CSI code: `,{identifier:this._parser.identToString(e),params:t.toArray()})}),this._parser.setEscHandlerFallback(e=>{this._logService.debug(`Unknown ESC code: `,{identifier:this._parser.identToString(e)})}),this._parser.setExecuteHandlerFallback(e=>{this._logService.debug(`Unknown EXECUTE code: `,{code:e})}),this._parser.setOscHandlerFallback((e,t,n)=>{this._logService.debug(`Unknown OSC code: `,{identifier:e,action:t,data:n})}),this._parser.setDcsHandlerFallback((e,t,n)=>{t===`HOOK`&&(n=n.toArray()),this._logService.debug(`Unknown DCS code: `,{identifier:this._parser.identToString(e),action:t,payload:n})}),this._parser.setPrintHandler((e,t,n)=>this.print(e,t,n)),this._parser.registerCsiHandler({final:`@`},e=>this.insertChars(e)),this._parser.registerCsiHandler({intermediates:` `,final:`@`},e=>this.scrollLeft(e)),this._parser.registerCsiHandler({final:`A`},e=>this.cursorUp(e)),this._parser.registerCsiHandler({intermediates:` `,final:`A`},e=>this.scrollRight(e)),this._parser.registerCsiHandler({final:`B`},e=>this.cursorDown(e)),this._parser.registerCsiHandler({final:`C`},e=>this.cursorForward(e)),this._parser.registerCsiHandler({final:`D`},e=>this.cursorBackward(e)),this._parser.registerCsiHandler({final:`E`},e=>this.cursorNextLine(e)),this._parser.registerCsiHandler({final:`F`},e=>this.cursorPrecedingLine(e)),this._parser.registerCsiHandler({final:`G`},e=>this.cursorCharAbsolute(e)),this._parser.registerCsiHandler({final:`H`},e=>this.cursorPosition(e)),this._parser.registerCsiHandler({final:`I`},e=>this.cursorForwardTab(e)),this._parser.registerCsiHandler({final:`J`},e=>this.eraseInDisplay(e,!1)),this._parser.registerCsiHandler({prefix:`?`,final:`J`},e=>this.eraseInDisplay(e,!0)),this._parser.registerCsiHandler({final:`K`},e=>this.eraseInLine(e,!1)),this._parser.registerCsiHandler({prefix:`?`,final:`K`},e=>this.eraseInLine(e,!0)),this._parser.registerCsiHandler({final:`L`},e=>this.insertLines(e)),this._parser.registerCsiHandler({final:`M`},e=>this.deleteLines(e)),this._parser.registerCsiHandler({final:`P`},e=>this.deleteChars(e)),this._parser.registerCsiHandler({final:`S`},e=>this.scrollUp(e)),this._parser.registerCsiHandler({final:`T`},e=>this.scrollDown(e)),this._parser.registerCsiHandler({final:`X`},e=>this.eraseChars(e)),this._parser.registerCsiHandler({final:`Z`},e=>this.cursorBackwardTab(e)),this._parser.registerCsiHandler({final:"`"},e=>this.charPosAbsolute(e)),this._parser.registerCsiHandler({final:`a`},e=>this.hPositionRelative(e)),this._parser.registerCsiHandler({final:`b`},e=>this.repeatPrecedingCharacter(e)),this._parser.registerCsiHandler({final:`c`},e=>this.sendDeviceAttributesPrimary(e)),this._parser.registerCsiHandler({prefix:`>`,final:`c`},e=>this.sendDeviceAttributesSecondary(e)),this._parser.registerCsiHandler({final:`d`},e=>this.linePosAbsolute(e)),this._parser.registerCsiHandler({final:`e`},e=>this.vPositionRelative(e)),this._parser.registerCsiHandler({final:`f`},e=>this.hVPosition(e)),this._parser.registerCsiHandler({final:`g`},e=>this.tabClear(e)),this._parser.registerCsiHandler({final:`h`},e=>this.setMode(e)),this._parser.registerCsiHandler({prefix:`?`,final:`h`},e=>this.setModePrivate(e)),this._parser.registerCsiHandler({final:`l`},e=>this.resetMode(e)),this._parser.registerCsiHandler({prefix:`?`,final:`l`},e=>this.resetModePrivate(e)),this._parser.registerCsiHandler({final:`m`},e=>this.charAttributes(e)),this._parser.registerCsiHandler({final:`n`},e=>this.deviceStatus(e)),this._parser.registerCsiHandler({prefix:`?`,final:`n`},e=>this.deviceStatusPrivate(e)),this._parser.registerCsiHandler({intermediates:`!`,final:`p`},e=>this.softReset(e)),this._parser.registerCsiHandler({intermediates:` `,final:`q`},e=>this.setCursorStyle(e)),this._parser.registerCsiHandler({final:`r`},e=>this.setScrollRegion(e)),this._parser.registerCsiHandler({final:`s`},e=>this.saveCursor(e)),this._parser.registerCsiHandler({final:`t`},e=>this.windowOptions(e)),this._parser.registerCsiHandler({final:`u`},e=>this.restoreCursor(e)),this._parser.registerCsiHandler({intermediates:`'`,final:`}`},e=>this.insertColumns(e)),this._parser.registerCsiHandler({intermediates:`'`,final:`~`},e=>this.deleteColumns(e)),this._parser.registerCsiHandler({intermediates:`"`,final:`q`},e=>this.selectProtected(e)),this._parser.registerCsiHandler({intermediates:`$`,final:`p`},e=>this.requestMode(e,!0)),this._parser.registerCsiHandler({prefix:`?`,intermediates:`$`,final:`p`},e=>this.requestMode(e,!1)),this._parser.setExecuteHandler(B.BEL,()=>this.bell()),this._parser.setExecuteHandler(B.LF,()=>this.lineFeed()),this._parser.setExecuteHandler(B.VT,()=>this.lineFeed()),this._parser.setExecuteHandler(B.FF,()=>this.lineFeed()),this._parser.setExecuteHandler(B.CR,()=>this.carriageReturn()),this._parser.setExecuteHandler(B.BS,()=>this.backspace()),this._parser.setExecuteHandler(B.HT,()=>this.tab()),this._parser.setExecuteHandler(B.SO,()=>this.shiftOut()),this._parser.setExecuteHandler(B.SI,()=>this.shiftIn()),this._parser.setExecuteHandler(pi.IND,()=>this.index()),this._parser.setExecuteHandler(pi.NEL,()=>this.nextLine()),this._parser.setExecuteHandler(pi.HTS,()=>this.tabSet()),this._parser.registerOscHandler(0,new Zo(e=>(this.setTitle(e),this.setIconName(e),!0))),this._parser.registerOscHandler(1,new Zo(e=>this.setIconName(e))),this._parser.registerOscHandler(2,new Zo(e=>this.setTitle(e))),this._parser.registerOscHandler(4,new Zo(e=>this.setOrReportIndexedColor(e))),this._parser.registerOscHandler(8,new Zo(e=>this.setHyperlink(e))),this._parser.registerOscHandler(10,new Zo(e=>this.setOrReportFgColor(e))),this._parser.registerOscHandler(11,new Zo(e=>this.setOrReportBgColor(e))),this._parser.registerOscHandler(12,new Zo(e=>this.setOrReportCursorColor(e))),this._parser.registerOscHandler(104,new Zo(e=>this.restoreIndexedColor(e))),this._parser.registerOscHandler(110,new Zo(e=>this.restoreFgColor(e))),this._parser.registerOscHandler(111,new Zo(e=>this.restoreBgColor(e))),this._parser.registerOscHandler(112,new Zo(e=>this.restoreCursorColor(e))),this._parser.registerEscHandler({final:`7`},()=>this.saveCursor()),this._parser.registerEscHandler({final:`8`},()=>this.restoreCursor()),this._parser.registerEscHandler({final:`D`},()=>this.index()),this._parser.registerEscHandler({final:`E`},()=>this.nextLine()),this._parser.registerEscHandler({final:`H`},()=>this.tabSet()),this._parser.registerEscHandler({final:`M`},()=>this.reverseIndex()),this._parser.registerEscHandler({final:`=`},()=>this.keypadApplicationMode()),this._parser.registerEscHandler({final:`>`},()=>this.keypadNumericMode()),this._parser.registerEscHandler({final:`c`},()=>this.fullReset()),this._parser.registerEscHandler({final:`n`},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:`o`},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:`|`},()=>this.setgLevel(3)),this._parser.registerEscHandler({final:`}`},()=>this.setgLevel(2)),this._parser.registerEscHandler({final:`~`},()=>this.setgLevel(1)),this._parser.registerEscHandler({intermediates:`%`,final:`@`},()=>this.selectDefaultCharset()),this._parser.registerEscHandler({intermediates:`%`,final:`G`},()=>this.selectDefaultCharset());for(let e in _o)this._parser.registerEscHandler({intermediates:`(`,final:e},()=>this.selectCharset(`(`+e)),this._parser.registerEscHandler({intermediates:`)`,final:e},()=>this.selectCharset(`)`+e)),this._parser.registerEscHandler({intermediates:`*`,final:e},()=>this.selectCharset(`*`+e)),this._parser.registerEscHandler({intermediates:`+`,final:e},()=>this.selectCharset(`+`+e)),this._parser.registerEscHandler({intermediates:`-`,final:e},()=>this.selectCharset(`-`+e)),this._parser.registerEscHandler({intermediates:`.`,final:e},()=>this.selectCharset(`.`+e)),this._parser.registerEscHandler({intermediates:`/`,final:e},()=>this.selectCharset(`/`+e));this._parser.registerEscHandler({intermediates:`#`,final:`8`},()=>this.screenAlignmentPattern()),this._parser.setErrorHandler(e=>(this._logService.error(`Parsing error: `,e),e)),this._parser.registerDcsHandler({intermediates:`$`,final:`q`},new ts((e,t)=>this.requestStatusString(e,t)))}getAttrData(){return this._curAttrData}_preserveStack(e,t,n,r){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=n,this._parseStack.position=r}_logSlowResolvingAsync(e){this._logService.logLevel<=3&&Promise.race([e,new Promise((e,t)=>setTimeout(()=>t(`#SLOW_TIMEOUT`),hs))]).catch(e=>{if(e!==`#SLOW_TIMEOUT`)throw e;console.warn(`async parser handler taking longer than ${hs} ms`)})}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let n,r=this._activeBuffer.x,i=this._activeBuffer.y,a=0,o=this._parseStack.paused;if(o){if(n=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(n),n;r=this._parseStack.cursorStartX,i=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>fs&&(a=this._parseStack.position+fs)}if(this._logService.logLevel<=1&&this._logService.debug(`parsing data ${typeof e==`string`?` "${e}"`:` "${Array.prototype.map.call(e,e=>String.fromCharCode(e)).join(``)}"`}`),this._logService.logLevel===0&&this._logService.trace(`parsing data (codes)`,typeof e==`string`?e.split(``).map(e=>e.charCodeAt(0)):e),this._parseBuffer.lengthfs)for(let t=a;t0&&d.getWidth(this._activeBuffer.x-1)===2&&d.setCellFromCodepoint(this._activeBuffer.x-1,0,1,u);let f=this._parser.precedingJoinState;for(let p=t;ps){if(c){let e=d,t=this._activeBuffer.x-m;for(this._activeBuffer.x=m,this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData(),!0)):(this._activeBuffer.y>=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),d=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y),m>0&&d instanceof co&&d.copyCellsFrom(e,t,0,m,!1);t=0;)d.setCellFromCodepoint(this._activeBuffer.x++,0,0,u);continue}if(l&&(d.insertCells(this._activeBuffer.x,i-m,this._activeBuffer.getNullCell(u)),d.getWidth(s-1)===2&&d.setCellFromCodepoint(s-1,0,1,u)),d.setCellFromCodepoint(this._activeBuffer.x++,r,i,u),i>0)for(;--i;)d.setCellFromCodepoint(this._activeBuffer.x++,0,0,u)}this._parser.precedingJoinState=f,this._activeBuffer.x0&&d.getWidth(this._activeBuffer.x)===0&&!d.hasContent(this._activeBuffer.x)&&d.setCellFromCodepoint(this._activeBuffer.x,0,1,u),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return e.final===`t`&&!e.prefix&&!e.intermediates?this._parser.registerCsiHandler(e,e=>ms(e.params[0],this._optionsService.rawOptions.windowOptions)?t(e):!0):this._parser.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new ts(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new Zo(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(this._activeBuffer.x===0&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)?.isWrapped){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;let e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){let t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){let t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){let t=e.params[0];return t===0?delete this._activeBuffer.tabs[this._activeBuffer.x]:t===3&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){let t=e.params[0];return t===1&&(this._curAttrData.bg|=536870912),(t===2||t===0)&&(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,n,r=!1,i=!1){let a=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);a.replaceCells(t,n,this._activeBuffer.getNullCell(this._eraseAttrData()),i),r&&(a.isWrapped=!1)}_resetBufferLine(e,t=!1){let n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n&&(n.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),n.isWrapped=!1)}eraseInDisplay(e,t=!1){this._restrictCursor(this._bufferService.cols);let n;switch(e.params[0]){case 0:for(n=this._activeBuffer.y,this._dirtyRowTracker.markDirty(n),this._eraseInBufferLine(n++,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);n=this._bufferService.cols&&(this._activeBuffer.lines.get(n+1).isWrapped=!1);n--;)this._resetBufferLine(n,t);this._dirtyRowTracker.markDirty(0);break;case 2:if(this._optionsService.rawOptions.scrollOnEraseInDisplay){for(n=this._bufferService.rows,this._dirtyRowTracker.markRangeDirty(0,n-1);n--&&!this._activeBuffer.lines.get(this._activeBuffer.ybase+n)?.getTrimmedLength(););for(;n>=0;n--)this._bufferService.scroll(this._eraseAttrData())}else{for(n=this._bufferService.rows,this._dirtyRowTracker.markDirty(n-1);n--;)this._resetBufferLine(n,t);this._dirtyRowTracker.markDirty(0)}break;case 3:let e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0));break}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,this._activeBuffer.x===0,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t);break}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y65535?2:1}let c=s;for(let e=1;e0||(this._is(`xterm`)||this._is(`rxvt-unicode`)||this._is(`screen`)?this._coreService.triggerDataEvent(B.ESC+`[?1;2c`):this._is(`linux`)&&this._coreService.triggerDataEvent(B.ESC+`[?6c`)),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is(`xterm`)?this._coreService.triggerDataEvent(B.ESC+`[>0;276;0c`):this._is(`rxvt-unicode`)?this._coreService.triggerDataEvent(B.ESC+`[>85;95;0c`):this._is(`linux`)?this._coreService.triggerDataEvent(e.params[0]+`c`):this._is(`screen`)&&this._coreService.triggerDataEvent(B.ESC+`[>83;40003;0c`)),!0}_is(e){return(this._optionsService.rawOptions.termName+``).indexOf(e)===0}setMode(e){for(let t=0;t(e[e.NOT_RECOGNIZED=0]=`NOT_RECOGNIZED`,e[e.SET=1]=`SET`,e[e.RESET=2]=`RESET`,e[e.PERMANENTLY_SET=3]=`PERMANENTLY_SET`,e[e.PERMANENTLY_RESET=4]=`PERMANENTLY_RESET`))(n||={});let r=this._coreService.decPrivateModes,{activeProtocol:i,activeEncoding:a}=this._coreMouseService,o=this._coreService,{buffers:s,cols:c}=this._bufferService,{active:l,alt:u}=s,d=this._optionsService.rawOptions,f=(e,n)=>(o.triggerDataEvent(`${B.ESC}[${t?``:`?`}${e};${n}$y`),!0),p=e=>e?1:2,m=e.params[0];return t?m===2?f(m,4):m===4?f(m,p(o.modes.insertMode)):m===12?f(m,3):m===20?f(m,p(d.convertEol)):f(m,0):m===1?f(m,p(r.applicationCursorKeys)):m===3?f(m,d.windowOptions.setWinLines?c===80?2:c===132?1:0:0):m===6?f(m,p(r.origin)):m===7?f(m,p(r.wraparound)):m===8?f(m,3):m===9?f(m,p(i===`X10`)):m===12?f(m,p(d.cursorBlink)):m===25?f(m,p(!o.isCursorHidden)):m===45?f(m,p(r.reverseWraparound)):m===66?f(m,p(r.applicationKeypad)):m===67?f(m,4):m===1e3?f(m,p(i===`VT200`)):m===1002?f(m,p(i===`DRAG`)):m===1003?f(m,p(i===`ANY`)):m===1004?f(m,p(r.sendFocus)):m===1005?f(m,4):m===1006?f(m,p(a===`SGR`)):m===1015?f(m,4):m===1016?f(m,p(a===`SGR_PIXELS`)):m===1048?f(m,1):m===47||m===1047||m===1049?f(m,p(l===u)):m===2004?f(m,p(r.bracketedPasteMode)):m===2026?f(m,p(r.synchronizedOutput)):f(m,0)}_updateAttrColor(e,t,n,r,i){return t===2?(e|=50331648,e&=-16777216,e|=ce.fromColorRGB([n,r,i])):t===5&&(e&=-50331904,e|=33554432|n&255),e}_extractColor(e,t,n){let r=[0,0,-1,0,0,0],i=0,a=0;do{if(r[a+i]=e.params[t+a],e.hasSubParams(t+a)){let n=e.getSubParams(t+a),o=0;do r[1]===5&&(i=1),r[a+o+1+i]=n[o];while(++o=2||r[1]===2&&a+i>=5)break;r[1]&&(i=1)}while(++a+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,e===0&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=q.fg,e.bg=q.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(e.length===1&&e.params[0]===0)return this._processSGR0(this._curAttrData),!0;let t=e.length,n,r=this._curAttrData;for(let i=0;i=30&&n<=37?(r.fg&=-50331904,r.fg|=16777216|n-30):n>=40&&n<=47?(r.bg&=-50331904,r.bg|=16777216|n-40):n>=90&&n<=97?(r.fg&=-50331904,r.fg|=n-90|16777224):n>=100&&n<=107?(r.bg&=-50331904,r.bg|=n-100|16777224):n===0?this._processSGR0(r):n===1?r.fg|=134217728:n===3?r.bg|=67108864:n===4?(r.fg|=268435456,this._processUnderline(e.hasSubParams(i)?e.getSubParams(i)[0]:1,r)):n===5?r.fg|=536870912:n===7?r.fg|=67108864:n===8?r.fg|=1073741824:n===9?r.fg|=2147483648:n===2?r.bg|=134217728:n===21?this._processUnderline(2,r):n===22?(r.fg&=-134217729,r.bg&=-134217729):n===23?r.bg&=-67108865:n===24?(r.fg&=-268435457,this._processUnderline(0,r)):n===25?r.fg&=-536870913:n===27?r.fg&=-67108865:n===28?r.fg&=-1073741825:n===29?r.fg&=2147483647:n===39?(r.fg&=-67108864,r.fg|=q.fg&16777215):n===49?(r.bg&=-67108864,r.bg|=q.bg&16777215):n===38||n===48||n===58?i+=this._extractColor(e,i,r):n===53?r.bg|=1073741824:n===55?r.bg&=-1073741825:n===59?(r.extended=r.extended.clone(),r.extended.underlineColor=-1,r.updateExtended()):n===100?(r.fg&=-67108864,r.fg|=q.fg&16777215,r.bg&=-67108864,r.bg|=q.bg&16777215):this._logService.debug(`Unknown SGR attribute: %d.`,n);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${B.ESC}[0n`);break;case 6:let e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${B.ESC}[${e};${t}R`);break}return!0}deviceStatusPrivate(e){switch(e.params[0]){case 6:let e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${B.ESC}[?${e};${t}R`);break;case 15:break;case 25:break;case 26:break;case 53:break}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=q.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){let t=e.length===0?1:e.params[0];if(t===0)this._coreService.decPrivateModes.cursorStyle=void 0,this._coreService.decPrivateModes.cursorBlink=void 0;else{switch(t){case 1:case 2:this._coreService.decPrivateModes.cursorStyle=`block`;break;case 3:case 4:this._coreService.decPrivateModes.cursorStyle=`underline`;break;case 5:case 6:this._coreService.decPrivateModes.cursorStyle=`bar`;break}let e=t%2==1;this._coreService.decPrivateModes.cursorBlink=e}return!0}setScrollRegion(e){let t=e.params[0]||1,n;return(e.length<2||(n=e.params[1])>this._bufferService.rows||n===0)&&(n=this._bufferService.rows),n>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=n-1,this._setCursor(0,0)),!0}windowOptions(e){if(!ms(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;let t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:t!==2&&this._onRequestWindowsOptionsReport.fire(0);break;case 16:this._onRequestWindowsOptionsReport.fire(1);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${B.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:(t===0||t===2)&&(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>ps&&this._windowTitleStack.shift()),(t===0||t===1)&&(this._iconNameStack.push(this._iconName),this._iconNameStack.length>ps&&this._iconNameStack.shift());break;case 23:(t===0||t===2)&&this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),(t===0||t===1)&&this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop());break}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){let t=[],n=e.split(`;`);for(;n.length>1;){let e=n.shift(),r=n.shift();if(/^\d+$/.exec(e)){let n=parseInt(e);if(ys(n))if(r===`?`)t.push({type:0,index:n});else{let e=cs(r);e&&t.push({type:1,index:n,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){let t=e.indexOf(`;`);if(t===-1)return!0;let n=e.slice(0,t).trim(),r=e.slice(t+1);return r?this._createHyperlink(n,r):n.trim()?!1:this._finishHyperlink()}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();let n=e.split(`:`),r,i=n.findIndex(e=>e.startsWith(`id=`));return i!==-1&&(r=n[i].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:r,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){let n=e.split(`;`);for(let e=0;e=this._specialColors.length);++e,++t)if(n[e]===`?`)this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{let r=cs(n[e]);r&&this._onColor.fire([{type:1,index:this._specialColors[t],color:r}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;let t=[],n=e.split(`;`);for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){let e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=q.clone(),this._eraseAttrDataInternal=q.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=this._curAttrData.bg&67108863,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){let e=new M;e.content=4194373,e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${B.ESC}${e}${B.ESC}\\`),!0),r=this._bufferService.buffer,i=this._optionsService.rawOptions;return n(e===`"q`?`P1$r${this._curAttrData.isProtected()?1:0}"q`:e===`"p`?`P1$r61;1"p`:e===`r`?`P1$r${r.scrollTop+1};${r.scrollBottom+1}r`:e===`m`?`P1$r0m`:e===` q`?`P1$r${{block:2,underline:4,bar:6}[i.cursorStyle]-(i.cursorBlink?1:0)} q`:`P0$r`)}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}},vs=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(gs=e,e=t,t=gs),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};vs=x([S(0,P)],vs);function ys(e){return 0<=e&&e<256}var bs=5e7,xs=12,Ss=50,Cs=class extends I{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this._register(new R),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(t!==void 0&&this._syncCalls>t){this._syncCalls=0;return}if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;this._isSyncWriting=!0;let n;for(;n=this._writeBuffer.shift();){this._action(n);let e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>bs)throw Error(`write data discarded, use flow control to avoid losing data`);if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput){this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),this._innerWrite();return}setTimeout(()=>this._innerWrite())}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){let n=e||performance.now();for(;this._writeBuffer.length>this._bufferOffset;){let e=this._writeBuffer[this._bufferOffset],r=this._action(e,t);if(r){r.catch(e=>(queueMicrotask(()=>{throw e}),Promise.resolve(!1))).then(e=>performance.now()-n>=xs?setTimeout(()=>this._innerWrite(0,e)):this._innerWrite(n,e));return}let i=this._callbacks[this._bufferOffset];if(i&&i(),this._bufferOffset++,this._pendingData-=e.length,performance.now()-n>=xs)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>Ss&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout(()=>this._innerWrite())):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}},ws=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){let t=this._bufferService.buffer;if(e.id===void 0){let n=t.addMarker(t.ybase+t.y),r={data:e,id:this._nextId++,lines:[n]};return n.onDispose(()=>this._removeMarkerFromLink(r,n)),this._dataByLinkId.set(r.id,r),r.id}let n=e,r=this._getEntryIdKey(n),i=this._entriesWithId.get(r);if(i)return this.addLineToLink(i.id,t.ybase+t.y),i.id;let a=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(n),data:n,lines:[a]};return a.onDispose(()=>this._removeMarkerFromLink(o,a)),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){let n=this._dataByLinkId.get(e);if(n&&n.lines.every(e=>e.line!==t)){let e=this._bufferService.buffer.addMarker(t);n.lines.push(e),e.onDispose(()=>this._removeMarkerFromLink(n,e))}}getLinkData(e){return this._dataByLinkId.get(e)?.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){let n=e.lines.indexOf(t);n!==-1&&(e.lines.splice(n,1),e.lines.length===0&&(e.data.id!==void 0&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};ws=x([S(0,P)],ws);var Ts=!1,Es=class extends I{constructor(e){super(),this._windowsWrappingHeuristics=this._register(new ft),this._onBinary=this._register(new R),this.onBinary=this._onBinary.event,this._onData=this._register(new R),this.onData=this._onData.event,this._onLineFeed=this._register(new R),this.onLineFeed=this._onLineFeed.event,this._onResize=this._register(new R),this.onResize=this._onResize.event,this._onWriteParsed=this._register(new R),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this._register(new R),this._instantiationService=new eo,this.optionsService=this._register(new Do(e)),this._instantiationService.setService(be,this.optionsService),this._bufferService=this._register(this._instantiationService.createInstance(wo)),this._instantiationService.setService(P,this._bufferService),this._logService=this._register(this._instantiationService.createInstance(ro)),this._instantiationService.setService(ye,this._logService),this.coreService=this._register(this._instantiationService.createInstance(Mo)),this._instantiationService.setService(ge,this.coreService),this.coreMouseService=this._register(this._instantiationService.createInstance(Lo)),this._instantiationService.setService(he,this.coreMouseService),this.unicodeService=this._register(this._instantiationService.createInstance(Uo)),this._instantiationService.setService(Se,this.unicodeService),this._charsetService=this._instantiationService.createInstance(Wo),this._instantiationService.setService(_e,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(ws),this._instantiationService.setService(xe,this._oscLinkService),this._inputHandler=this._register(new _s(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this._register(xt.forward(this._inputHandler.onLineFeed,this._onLineFeed)),this._register(this._inputHandler),this._register(xt.forward(this._bufferService.onResize,this._onResize)),this._register(xt.forward(this.coreService.onData,this._onData)),this._register(xt.forward(this.coreService.onBinary,this._onBinary)),this._register(this.coreService.onRequestScrollToBottom(()=>this.scrollToBottom(!0))),this._register(this.coreService.onUserInput(()=>this._writeBuffer.handleUserInput())),this._register(this.optionsService.onMultipleOptionChange([`windowsMode`,`windowsPty`],()=>this._handleWindowsPtyOptionChange())),this._register(this._bufferService.onScroll(()=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)})),this._writeBuffer=this._register(new Cs((e,t)=>this._inputHandler.parse(e,t))),this._register(xt.forward(this._writeBuffer.onWriteParsed,this._onWriteParsed))}get onScroll(){return this._onScrollApi||(this._onScrollApi=this._register(new R),this._onScroll.event(e=>{this._onScrollApi?.fire(e.position)})),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(let t in e)this.optionsService.options[t]=e[t]}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=3&&!Ts&&(this._logService.warn(`writeSync is unreliable and will be removed soon.`),Ts=!0),this._writeBuffer.writeSync(e,t)}input(e,t=!0){this.coreService.triggerDataEvent(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,So),t=Math.max(t,Co),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t){this._bufferService.scrollLines(e,t)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1,t=this.optionsService.rawOptions.windowsPty;t&&t.buildNumber!==void 0&&t.buildNumber!==void 0?e=t.backend===`conpty`&&t.buildNumber<21376:this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){let e=[];e.push(this.onLineFeed(Go.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:`H`},()=>(Go(this._bufferService),!1))),this._windowsWrappingHeuristics.value=F(()=>{for(let t of e)t.dispose()})}}},Ds={48:[`0`,`)`],49:[`1`,`!`],50:[`2`,`@`],51:[`3`,`#`],52:[`4`,`$`],53:[`5`,`%`],54:[`6`,`^`],55:[`7`,`&`],56:[`8`,`*`],57:[`9`,`(`],186:[`;`,`:`],187:[`=`,`+`],188:[`,`,`<`],189:[`-`,`_`],190:[`.`,`>`],191:[`/`,`?`],192:["`",`~`],219:[`[`,`{`],220:[`\\`,`|`],221:[`]`,`}`],222:[`'`,`"`]};function Os(e,t,n,r){let i={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:e.key===`UIKeyInputUpArrow`?t?i.key=B.ESC+`OA`:i.key=B.ESC+`[A`:e.key===`UIKeyInputLeftArrow`?t?i.key=B.ESC+`OD`:i.key=B.ESC+`[D`:e.key===`UIKeyInputRightArrow`?t?i.key=B.ESC+`OC`:i.key=B.ESC+`[C`:e.key===`UIKeyInputDownArrow`&&(t?i.key=B.ESC+`OB`:i.key=B.ESC+`[B`);break;case 8:i.key=e.ctrlKey?`\b`:B.DEL,e.altKey&&(i.key=B.ESC+i.key);break;case 9:if(e.shiftKey){i.key=B.ESC+`[Z`;break}i.key=B.HT,i.cancel=!0;break;case 13:i.key=e.altKey?B.ESC+B.CR:B.CR,i.cancel=!0;break;case 27:i.key=B.ESC,e.altKey&&(i.key=B.ESC+B.ESC),i.cancel=!0;break;case 37:if(e.metaKey)break;a?i.key=B.ESC+`[1;`+(a+1)+`D`:t?i.key=B.ESC+`OD`:i.key=B.ESC+`[D`;break;case 39:if(e.metaKey)break;a?i.key=B.ESC+`[1;`+(a+1)+`C`:t?i.key=B.ESC+`OC`:i.key=B.ESC+`[C`;break;case 38:if(e.metaKey)break;a?i.key=B.ESC+`[1;`+(a+1)+`A`:t?i.key=B.ESC+`OA`:i.key=B.ESC+`[A`;break;case 40:if(e.metaKey)break;a?i.key=B.ESC+`[1;`+(a+1)+`B`:t?i.key=B.ESC+`OB`:i.key=B.ESC+`[B`;break;case 45:!e.shiftKey&&!e.ctrlKey&&(i.key=B.ESC+`[2~`);break;case 46:a?i.key=B.ESC+`[3;`+(a+1)+`~`:i.key=B.ESC+`[3~`;break;case 36:a?i.key=B.ESC+`[1;`+(a+1)+`H`:t?i.key=B.ESC+`OH`:i.key=B.ESC+`[H`;break;case 35:a?i.key=B.ESC+`[1;`+(a+1)+`F`:t?i.key=B.ESC+`OF`:i.key=B.ESC+`[F`;break;case 33:e.shiftKey?i.type=2:e.ctrlKey?i.key=B.ESC+`[5;`+(a+1)+`~`:i.key=B.ESC+`[5~`;break;case 34:e.shiftKey?i.type=3:e.ctrlKey?i.key=B.ESC+`[6;`+(a+1)+`~`:i.key=B.ESC+`[6~`;break;case 112:a?i.key=B.ESC+`[1;`+(a+1)+`P`:i.key=B.ESC+`OP`;break;case 113:a?i.key=B.ESC+`[1;`+(a+1)+`Q`:i.key=B.ESC+`OQ`;break;case 114:a?i.key=B.ESC+`[1;`+(a+1)+`R`:i.key=B.ESC+`OR`;break;case 115:a?i.key=B.ESC+`[1;`+(a+1)+`S`:i.key=B.ESC+`OS`;break;case 116:a?i.key=B.ESC+`[15;`+(a+1)+`~`:i.key=B.ESC+`[15~`;break;case 117:a?i.key=B.ESC+`[17;`+(a+1)+`~`:i.key=B.ESC+`[17~`;break;case 118:a?i.key=B.ESC+`[18;`+(a+1)+`~`:i.key=B.ESC+`[18~`;break;case 119:a?i.key=B.ESC+`[19;`+(a+1)+`~`:i.key=B.ESC+`[19~`;break;case 120:a?i.key=B.ESC+`[20;`+(a+1)+`~`:i.key=B.ESC+`[20~`;break;case 121:a?i.key=B.ESC+`[21;`+(a+1)+`~`:i.key=B.ESC+`[21~`;break;case 122:a?i.key=B.ESC+`[23;`+(a+1)+`~`:i.key=B.ESC+`[23~`;break;case 123:a?i.key=B.ESC+`[24;`+(a+1)+`~`:i.key=B.ESC+`[24~`;break;default:if(e.ctrlKey&&!e.shiftKey&&!e.altKey&&!e.metaKey)e.keyCode>=65&&e.keyCode<=90?i.key=String.fromCharCode(e.keyCode-64):e.keyCode===32?i.key=B.NUL:e.keyCode>=51&&e.keyCode<=55?i.key=String.fromCharCode(e.keyCode-51+27):e.keyCode===56?i.key=B.DEL:e.keyCode===219?i.key=B.ESC:e.keyCode===220?i.key=B.FS:e.keyCode===221&&(i.key=B.GS);else if((!n||r)&&e.altKey&&!e.metaKey){let t=Ds[e.keyCode]?.[e.shiftKey?1:0];if(t)i.key=B.ESC+t;else if(e.keyCode>=65&&e.keyCode<=90){let t=e.ctrlKey?e.keyCode-64:e.keyCode+32,n=String.fromCharCode(t);e.shiftKey&&(n=n.toUpperCase()),i.key=B.ESC+n}else if(e.keyCode===32)i.key=B.ESC+(e.ctrlKey?B.NUL:` `);else if(e.key===`Dead`&&e.code.startsWith(`Key`)){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),i.key=B.ESC+t,i.cancel=!0}}else n&&!e.altKey&&!e.ctrlKey&&!e.shiftKey&&e.metaKey?e.keyCode===65&&(i.type=1):e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&e.key.length===1?i.key=e.key:e.key&&e.ctrlKey&&(e.key===`_`&&(i.key=B.US),e.key===`@`&&(i.key=B.NUL));break}return i}var J=0,ks=class{constructor(e){this._getKey=e,this._array=[],this._insertedValues=[],this._flushInsertedTask=new va,this._isFlushingInserted=!1,this._deletedIndices=[],this._flushDeletedTask=new va,this._isFlushingDeleted=!1}clear(){this._array.length=0,this._insertedValues.length=0,this._flushInsertedTask.clear(),this._isFlushingInserted=!1,this._deletedIndices.length=0,this._flushDeletedTask.clear(),this._isFlushingDeleted=!1}insert(e){this._flushCleanupDeleted(),this._insertedValues.length===0&&this._flushInsertedTask.enqueue(()=>this._flushInserted()),this._insertedValues.push(e)}_flushInserted(){let e=this._insertedValues.sort((e,t)=>this._getKey(e)-this._getKey(t)),t=0,n=0,r=Array(this._array.length+this._insertedValues.length);for(let i=0;i=this._array.length||this._getKey(e[t])<=this._getKey(this._array[n])?(r[i]=e[t],t++):r[i]=this._array[n++];this._array=r,this._insertedValues.length=0}_flushCleanupInserted(){!this._isFlushingInserted&&this._insertedValues.length>0&&this._flushInsertedTask.flush()}delete(e){if(this._flushCleanupInserted(),this._array.length===0)return!1;let t=this._getKey(e);if(t===void 0||(J=this._search(t),J===-1)||this._getKey(this._array[J])!==t)return!1;do if(this._array[J]===e)return this._deletedIndices.length===0&&this._flushDeletedTask.enqueue(()=>this._flushDeleted()),this._deletedIndices.push(J),!0;while(++Je-t),t=0,n=Array(this._array.length-e.length),r=0;for(let i=0;i0&&this._flushDeletedTask.flush()}*getKeyIterator(e){if(this._flushCleanupInserted(),this._flushCleanupDeleted(),this._array.length!==0&&(J=this._search(e),!(J<0||J>=this._array.length)&&this._getKey(this._array[J])===e))do yield this._array[J];while(++J=this._array.length)&&this._getKey(this._array[J])===e))do t(this._array[J]);while(++J=t;){let r=t+n>>1,i=this._getKey(this._array[r]);if(i>e)n=r-1;else if(i0&&this._getKey(this._array[r-1])===e;)r--;return r}}return t}},As=0,js=0,Ms=class extends I{constructor(){super(),this._decorations=new ks(e=>e?.marker.line),this._onDecorationRegistered=this._register(new R),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this._register(new R),this.onDecorationRemoved=this._onDecorationRemoved.event,this._register(F(()=>this.reset()))}get decorations(){return this._decorations.values()}registerDecoration(e){if(e.marker.isDisposed)return;let t=new Ns(e);if(t){let e=t.marker.onDispose(()=>t.dispose()),n=t.onDispose(()=>{n.dispose(),t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())});this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(let e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,n){let r=0,i=0;for(let a of this._decorations.getKeyIterator(t))r=a.options.x??0,i=r+(a.options.width??1),e>=r&&e{As=t.options.x??0,js=As+(t.options.width??1),e>=As&&e=this._debounceThresholdMS)this._lastRefreshMs=r,this._innerRefresh();else if(!this._additionalRefreshRequested){let e=r-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout(()=>{this._lastRefreshMs=performance.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0},t)}}_innerRefresh(){if(this._rowStart===void 0||this._rowEnd===void 0||this._rowCount===void 0)return;let e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}},Is=20,Ls=!1,Rs=class extends I{constructor(e,t,n,r){super(),this._terminal=e,this._coreBrowserService=n,this._renderService=r,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce=``;let i=this._coreBrowserService.mainDocument;this._accessibilityContainer=i.createElement(`div`),this._accessibilityContainer.classList.add(`xterm-accessibility`),this._rowContainer=i.createElement(`div`),this._rowContainer.setAttribute(`role`,`list`),this._rowContainer.classList.add(`xterm-accessibility-tree`),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener(`focus`,this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener(`focus`,this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=i.createElement(`div`),this._liveRegion.classList.add(`live-region`),this._liveRegion.setAttribute(`aria-live`,`assertive`),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new Fs(this._renderRows.bind(this))),!this._terminal.element)throw Error(`Cannot enable accessibility before Terminal.open`);Ls?(this._accessibilityContainer.classList.add(`debug`),this._rowContainer.classList.add(`debug`),this._debugRootContainer=i.createElement(`div`),this._debugRootContainer.classList.add(`xterm`),this._debugRootContainer.appendChild(i.createTextNode(`------start a11y------`)),this._debugRootContainer.appendChild(this._accessibilityContainer),this._debugRootContainer.appendChild(i.createTextNode(`------end a11y------`)),this._terminal.element.insertAdjacentElement(`afterend`,this._debugRootContainer)):this._terminal.element.insertAdjacentElement(`afterbegin`,this._accessibilityContainer),this._register(this._terminal.onResize(e=>this._handleResize(e.rows))),this._register(this._terminal.onRender(e=>this._refreshRows(e.start,e.end))),this._register(this._terminal.onScroll(()=>this._refreshRows())),this._register(this._terminal.onA11yChar(e=>this._handleChar(e))),this._register(this._terminal.onLineFeed(()=>this._handleChar(` +`))),this._register(this._terminal.onA11yTab(e=>this._handleTab(e))),this._register(this._terminal.onKey(e=>this._handleKey(e.key))),this._register(this._terminal.onBlur(()=>this._clearLiveRegion())),this._register(this._renderService.onDimensionsChange(()=>this._refreshRowsDimensions())),this._register(z(i,`selectionchange`,()=>this._handleSelectionChange())),this._register(this._coreBrowserService.onDprChange(()=>this._refreshRowsDimensions())),this._refreshRowsDimensions(),this._refreshRows(),this._register(F(()=>{Ls?this._debugRootContainer.remove():this._accessibilityContainer.remove(),this._rowElements.length=0}))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,e===` +`&&(this._liveRegionLineCount++,this._liveRegionLineCount===Is+1&&(this._liveRegion.textContent+=w.get())))}_clearLiveRegion(){this._liveRegion.textContent=``,this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){let n=this._terminal.buffer,r=n.lines.length.toString();for(let i=e;i<=t;i++){let e=n.lines.get(n.ydisp+i),t=[],a=e?.translateToString(!0,void 0,void 0,t)||``,o=(n.ydisp+i+1).toString(),s=this._rowElements[i];s&&(a.length===0?(s.textContent=`\xA0`,this._rowColumns.set(s,[0,1])):(s.textContent=a,this._rowColumns.set(s,t)),s.setAttribute(`aria-posinset`,o),s.setAttribute(`aria-setsize`,r),this._alignRowWidth(s))}this._announceCharacters()}_announceCharacters(){this._charsToAnnounce.length!==0&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce=``)}_handleBoundaryFocus(e,t){let n=e.target,r=this._rowElements[t===0?1:this._rowElements.length-2];if(n.getAttribute(`aria-posinset`)===(t===0?`1`:`${this._terminal.buffer.lines.length}`)||e.relatedTarget!==r)return;let i,a;if(t===0?(i=n,a=this._rowElements.pop(),this._rowContainer.removeChild(a)):(i=this._rowElements.shift(),a=n,this._rowContainer.removeChild(i)),i.removeEventListener(`focus`,this._topBoundaryFocusListener),a.removeEventListener(`focus`,this._bottomBoundaryFocusListener),t===0){let e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement(`afterbegin`,e)}else{let e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener(`focus`,this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener(`focus`,this._bottomBoundaryFocusListener),this._terminal.scrollLines(t===0?-1:1),this._rowElements[t===0?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleSelectionChange(){if(this._rowElements.length===0)return;let e=this._coreBrowserService.mainDocument.getSelection();if(!e)return;if(e.isCollapsed){this._rowContainer.contains(e.anchorNode)&&this._terminal.clearSelection();return}if(!e.anchorNode||!e.focusNode){console.error(`anchorNode and/or focusNode are null`);return}let t={node:e.anchorNode,offset:e.anchorOffset},n={node:e.focusNode,offset:e.focusOffset};if((t.node.compareDocumentPosition(n.node)&Node.DOCUMENT_POSITION_PRECEDING||t.node===n.node&&t.offset>n.offset)&&([t,n]=[n,t]),t.node.compareDocumentPosition(this._rowElements[0])&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_FOLLOWING)&&(t={node:this._rowElements[0].childNodes[0],offset:0}),!this._rowContainer.contains(t.node))return;let r=this._rowElements.slice(-1)[0];if(n.node.compareDocumentPosition(r)&(Node.DOCUMENT_POSITION_CONTAINED_BY|Node.DOCUMENT_POSITION_PRECEDING)&&(n={node:r,offset:r.textContent?.length??0}),!this._rowContainer.contains(n.node))return;let i=({node:e,offset:t})=>{let n=e instanceof Text?e.parentNode:e,r=parseInt(n?.getAttribute(`aria-posinset`),10)-1;if(isNaN(r))return console.warn(`row is invalid. Race condition?`),null;let i=this._rowColumns.get(n);if(!i)return console.warn(`columns is null. Race condition?`),null;let a=t=this._terminal.cols&&(++r,a=0),{row:r,column:a}},a=i(t),o=i(n);if(!(!a||!o)){if(a.row>o.row||a.row===o.row&&a.column>=o.column)throw Error(`invalid range`);this._terminal.select(a.column,a.row,(o.row-a.row)*this._terminal.cols-a.column+o.column)}}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener(`focus`,this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener(`focus`,this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){let e=this._coreBrowserService.mainDocument.createElement(`div`);return e.setAttribute(`role`,`listitem`),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){Object.assign(this._accessibilityContainer.style,{width:`${this._renderService.dimensions.css.canvas.width}px`,fontSize:`${this._terminal.options.fontSize}px`}),this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{ct(this._linkCacheDisposables),this._linkCacheDisposables.length=0,this._lastMouseEvent=void 0,this._activeProviderReplies?.clear()})),this._register(this._bufferService.onResize(()=>{this._clearCurrentLink(),this._wasResized=!0})),this._register(z(this._element,`mouseleave`,()=>{this._isMouseOut=!0,this._clearCurrentLink()})),this._register(z(this._element,`mousemove`,this._handleMouseMove.bind(this))),this._register(z(this._element,`mousedown`,this._handleMouseDown.bind(this))),this._register(z(this._element,`mouseup`,this._handleMouseUp.bind(this)))}get currentLink(){return this._currentLink}_handleMouseMove(e){this._lastMouseEvent=e;let t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;let n=e.composedPath();for(let e=0;e{e?.forEach(e=>{e.link.dispose&&e.link.dispose()})}),this._activeProviderReplies=new Map,this._activeLine=e.y);let n=!1;for(let[r,i]of this._linkProviderService.linkProviders.entries())t?this._activeProviderReplies?.get(r)&&(n=this._checkLinkProviderResult(r,e,n)):i.provideLinks(e.y,t=>{if(this._isMouseOut)return;let i=t?.map(e=>({link:e}));this._activeProviderReplies?.set(r,i),n=this._checkLinkProviderResult(r,e,n),this._activeProviderReplies?.size===this._linkProviderService.linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)})}_removeIntersectingLinks(e,t){let n=new Set;for(let r=0;re?this._bufferService.cols:r.link.range.end.x;for(let e=a;e<=o;e++){if(n.has(e)){i.splice(t--,1);break}n.add(e)}}}}_checkLinkProviderResult(e,t,n){if(!this._activeProviderReplies)return n;let r=this._activeProviderReplies.get(e),i=!1;for(let t=0;tthis._linkAtPosition(e.link,t));e&&(n=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviderService.linkProviders.length&&!n)for(let e=0;ethis._linkAtPosition(e.link,t));if(r){n=!0,this._handleNewLink(r);break}}return n}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._currentLink)return;let t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink&&Bs(this._mouseDownLink.link,this._currentLink.link)&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){!this._currentLink||!this._lastMouseEvent||(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,ct(this._linkCacheDisposables),this._linkCacheDisposables.length=0)}_handleNewLink(e){if(!this._lastMouseEvent)return;let t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:e.link.decorations===void 0?!0:e.link.decorations.underline,pointerCursor:e.link.decorations===void 0?!0:e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>this._currentLink?.state?.decorations.pointerCursor,set:e=>{this._currentLink?.state&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&this._element.classList.toggle(`xterm-cursor-pointer`,e))}},underline:{get:()=>this._currentLink?.state?.decorations.underline,set:t=>{this._currentLink?.state&&this._currentLink?.state?.decorations.underline!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(e=>{if(!this._currentLink)return;let t=e.start===0?0:e.start+1+this._bufferService.buffer.ydisp,n=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=n&&(this._clearCurrentLink(t,n),this._lastMouseEvent)){let e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}})))}_linkHover(e,t,n){this._currentLink?.state&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add(`xterm-cursor-pointer`)),t.hover&&t.hover(n,t.text)}_fireUnderlineEvent(e,t){let n=e.range,r=this._bufferService.buffer.ydisp,i=this._createLinkUnderlineEvent(n.start.x-1,n.start.y-r-1,n.end.x,n.end.y-r-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(i)}_linkLeave(e,t,n){this._currentLink?.state&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove(`xterm-cursor-pointer`)),t.leave&&t.leave(n,t.text)}_linkAtPosition(e,t){let n=e.range.start.y*this._bufferService.cols+e.range.start.x,r=e.range.end.y*this._bufferService.cols+e.range.end.x,i=t.y*this._bufferService.cols+t.x;return n<=i&&i<=r}_positionFromMouseEvent(e,t,n){let r=n.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(r)return{x:r[0],y:r[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,n,r,i){return{x1:e,y1:t,x2:n,y2:r,cols:this._bufferService.cols,fg:i}}};zs=x([S(1,Oe),S(2,ke),S(3,P),S(4,Ne)],zs);function Bs(e,t){return e.text===t.text&&e.range.start.x===t.range.start.x&&e.range.start.y===t.range.start.y&&e.range.end.x===t.range.end.x&&e.range.end.y===t.range.end.y}var Vs=class extends Es{constructor(e={}){super(e),this._linkifier=this._register(new ft),this.browser=ta,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this._register(new ft),this._onCursorMove=this._register(new R),this.onCursorMove=this._onCursorMove.event,this._onKey=this._register(new R),this.onKey=this._onKey.event,this._onRender=this._register(new R),this.onRender=this._onRender.event,this._onSelectionChange=this._register(new R),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this._register(new R),this.onTitleChange=this._onTitleChange.event,this._onBell=this._register(new R),this.onBell=this._onBell.event,this._onFocus=this._register(new R),this._onBlur=this._register(new R),this._onA11yCharEmitter=this._register(new R),this._onA11yTabEmitter=this._register(new R),this._onWillOpen=this._register(new R),this._setup(),this._decorationService=this._instantiationService.createInstance(Ms),this._instantiationService.setService(Ce,this._decorationService),this._linkProviderService=this._instantiationService.createInstance(Xi),this._instantiationService.setService(Ne,this._linkProviderService),this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(we)),this._register(this._inputHandler.onRequestBell(()=>this._onBell.fire())),this._register(this._inputHandler.onRequestRefreshRows(e=>this.refresh(e?.start??0,e?.end??this.rows-1))),this._register(this._inputHandler.onRequestSendFocus(()=>this._reportFocus())),this._register(this._inputHandler.onRequestReset(()=>this.reset())),this._register(this._inputHandler.onRequestWindowsOptionsReport(e=>this._reportWindowsOptions(e))),this._register(this._inputHandler.onColor(e=>this._handleColorEvent(e))),this._register(xt.forward(this._inputHandler.onCursorMove,this._onCursorMove)),this._register(xt.forward(this._inputHandler.onTitleChange,this._onTitleChange)),this._register(xt.forward(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this._register(xt.forward(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this._register(this._bufferService.onResize(e=>this._afterResize(e.cols,e.rows))),this._register(F(()=>{this._customKeyEventHandler=void 0,this.element?.parentNode?.removeChild(this.element)}))}get linkifier(){return this._linkifier.value}get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}_handleColorEvent(e){if(this._themeService)for(let t of e){let e,n=``;switch(t.index){case 256:e=`foreground`,n=`10`;break;case 257:e=`background`,n=`11`;break;case 258:e=`cursor`,n=`12`;break;default:e=`ansi`,n=`4;`+t.index}switch(t.type){case 0:let r=U.toColorRGB(e===`ansi`?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${B.ESC}]${n};${us(r)}${mi.ST}`);break;case 1:if(e===`ansi`)this._themeService.modifyColors(e=>e.ansi[t.index]=H.toColor(...t.color));else{let n=e;this._themeService.modifyColors(e=>e[n]=H.toColor(...t.color))}break;case 2:this._themeService.restoreColor(t.index);break}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(Rs,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(B.ESC+`[I`),this.element.classList.add(`focus`),this._showCursor(),this._onFocus.fire()}blur(){return this.textarea?.blur()}_handleTextAreaBlur(){this.textarea.value=``,this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(B.ESC+`[O`),this.element.classList.remove(`focus`),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;let e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;let n=Math.min(this.buffer.x,this.cols-1),r=this._renderService.dimensions.css.cell.height,i=t.getWidth(n),a=this._renderService.dimensions.css.cell.width*i,o=this.buffer.y*this._renderService.dimensions.css.cell.height,s=n*this._renderService.dimensions.css.cell.width;this.textarea.style.left=s+`px`,this.textarea.style.top=o+`px`,this.textarea.style.width=a+`px`,this.textarea.style.height=r+`px`,this.textarea.style.lineHeight=r+`px`,this.textarea.style.zIndex=`-5`}_initGlobal(){this._bindKeys(),this._register(z(this.element,`copy`,e=>{this.hasSelection()&&D(e,this._selectionService)}));let e=e=>ne(e,this.textarea,this.coreService,this.optionsService);this._register(z(this.textarea,`paste`,e)),this._register(z(this.element,`paste`,e)),aa?this._register(z(this.element,`mousedown`,e=>{e.button===2&&k(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})):this._register(z(this.element,`contextmenu`,e=>{k(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)})),pa&&this._register(z(this.element,`auxclick`,e=>{e.button===1&&re(e,this.textarea,this.screenElement)}))}_bindKeys(){this._register(z(this.textarea,`keyup`,e=>this._keyUp(e),!0)),this._register(z(this.textarea,`keydown`,e=>this._keyDown(e),!0)),this._register(z(this.textarea,`keypress`,e=>this._keyPress(e),!0)),this._register(z(this.textarea,`compositionstart`,()=>this._compositionHelper.compositionstart())),this._register(z(this.textarea,`compositionupdate`,e=>this._compositionHelper.compositionupdate(e))),this._register(z(this.textarea,`compositionend`,()=>this._compositionHelper.compositionend())),this._register(z(this.textarea,`input`,e=>this._inputEvent(e),!0)),this._register(this.onRender(()=>this._compositionHelper.updateCompositionElements()))}open(e){if(!e)throw Error(`Terminal requires a parent element.`);if(e.isConnected||this._logService.debug(`Terminal.open was called on an element that was not attached to the DOM`),this.element?.ownerDocument.defaultView&&this._coreBrowserService){this.element.ownerDocument.defaultView!==this._coreBrowserService.window&&(this._coreBrowserService.window=this.element.ownerDocument.defaultView);return}this._document=e.ownerDocument,this.options.documentOverride&&this.options.documentOverride instanceof Document&&(this._document=this.optionsService.rawOptions.documentOverride),this.element=this._document.createElement(`div`),this.element.dir=`ltr`,this.element.classList.add(`terminal`),this.element.classList.add(`xterm`),e.appendChild(this.element);let t=this._document.createDocumentFragment();this._viewportElement=this._document.createElement(`div`),this._viewportElement.classList.add(`xterm-viewport`),t.appendChild(this._viewportElement),this.screenElement=this._document.createElement(`div`),this.screenElement.classList.add(`xterm-screen`),this._register(z(this.screenElement,`mousemove`,e=>this.updateCursorStyle(e))),this._helperContainer=this._document.createElement(`div`),this._helperContainer.classList.add(`xterm-helpers`),this.screenElement.appendChild(this._helperContainer),t.appendChild(this.screenElement);let n=this.textarea=this._document.createElement(`textarea`);this.textarea.classList.add(`xterm-helper-textarea`),this.textarea.setAttribute(`aria-label`,te.get()),ma||this.textarea.setAttribute(`aria-multiline`,`false`),this.textarea.setAttribute(`autocorrect`,`off`),this.textarea.setAttribute(`autocapitalize`,`off`),this.textarea.setAttribute(`spellcheck`,`false`),this.textarea.tabIndex=0,this._register(this.optionsService.onSpecificOptionChange(`disableStdin`,()=>n.readOnly=this.optionsService.rawOptions.disableStdin)),this.textarea.readOnly=this.optionsService.rawOptions.disableStdin,this._coreBrowserService=this._register(this._instantiationService.createInstance(Ji,this.textarea,e.ownerDocument.defaultView??window,this._document??typeof window<`u`?window.document:null)),this._instantiationService.setService(De,this._coreBrowserService),this._register(z(this.textarea,`focus`,e=>this._handleTextAreaFocus(e))),this._register(z(this.textarea,`blur`,()=>this._handleTextAreaBlur())),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(Wi,this._document,this._helperContainer),this._instantiationService.setService(Ee,this._charSizeService),this._themeService=this._instantiationService.createInstance(Qa),this._instantiationService.setService(Me,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(Ti),this._instantiationService.setService(je,this._characterJoinerService),this._renderService=this._register(this._instantiationService.createInstance(ba,this.rows,this.screenElement)),this._instantiationService.setService(ke,this._renderService),this._register(this._renderService.onRenderedViewportChange(e=>this._onRender.fire(e))),this.onResize(e=>this._renderService.resize(e.cols,e.rows)),this._compositionView=this._document.createElement(`div`),this._compositionView.classList.add(`composition-view`),this._compositionHelper=this._instantiationService.createInstance(hi,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this._mouseService=this._instantiationService.createInstance($i),this._instantiationService.setService(Oe,this._mouseService);let r=this._linkifier.value=this._register(this._instantiationService.createInstance(zs,this.screenElement));this.element.appendChild(t);try{this._onWillOpen.fire(this.element)}catch{}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._register(this.onCursorMove(()=>{this._renderService.handleCursorMove(),this._syncTextArea()})),this._register(this.onResize(()=>this._renderService.handleResize(this.cols,this.rows))),this._register(this.onBlur(()=>this._renderService.handleBlur())),this._register(this.onFocus(()=>this._renderService.handleFocus())),this._viewport=this._register(this._instantiationService.createInstance(oi,this.element,this.screenElement)),this._register(this._viewport.onRequestScrollLines(e=>{super.scrollLines(e,!1),this.refresh(0,this.rows-1)})),this._selectionService=this._register(this._instantiationService.createInstance(Ha,this.element,this.screenElement,r)),this._instantiationService.setService(Ae,this._selectionService),this._register(this._selectionService.onRequestScrollLines(e=>this.scrollLines(e.amount,e.suppressScrollEvent))),this._register(this._selectionService.onSelectionChange(()=>this._onSelectionChange.fire())),this._register(this._selectionService.onRequestRedraw(e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode))),this._register(this._selectionService.onLinuxMouseSelection(e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()})),this._register(xt.any(this._onScroll.event,this._inputHandler.onScroll)(()=>{this._selectionService.refresh(),this._viewport?.queueSync()})),this._register(this._instantiationService.createInstance(si,this.screenElement)),this._register(z(this.element,`mousedown`,e=>this._selectionService.handleMouseDown(e))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add(`enable-mouse-events`)):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(Rs,this)),this._register(this.optionsService.onSpecificOptionChange(`screenReaderMode`,e=>this._handleScreenReaderModeOptionChange(e))),this.options.overviewRuler.width&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(fi,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange(`overviewRuler`,e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this._register(this._instantiationService.createInstance(fi,this._viewportElement,this.screenElement)))}),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(Ui,this,this._document,this.element,this.screenElement,this._viewportElement,this._helperContainer,this.linkifier)}bindMouse(){let e=this,t=this.element;function n(t){let n=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!n)return!1;let r,i;switch(t.overrideType||t.type){case`mousemove`:i=32,t.buttons===void 0?(r=3,t.button!==void 0&&(r=t.button<3?t.button:3)):r=t.buttons&1?0:t.buttons&4?1:t.buttons&2?2:3;break;case`mouseup`:i=0,r=t.button<3?t.button:3;break;case`mousedown`:i=1,r=t.button<3?t.button:3;break;case`wheel`:if(e._customWheelEventHandler&&e._customWheelEventHandler(t)===!1)return!1;let n=t.deltaY;if(n===0||e.coreMouseService.consumeWheelEvent(t,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr)===0)return!1;i=n<0?0:1,r=4;break;default:return!1}return i===void 0||r===void 0||r>4?!1:e.coreMouseService.triggerMouseEvent({col:n.col,row:n.row,x:n.x,y:n.y,button:r,action:i,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}let r={mouseup:null,wheel:null,mousedrag:null,mousemove:null},i={mouseup:e=>(n(e),e.buttons||(this._document.removeEventListener(`mouseup`,r.mouseup),r.mousedrag&&this._document.removeEventListener(`mousemove`,r.mousedrag)),this.cancel(e)),wheel:e=>(n(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&n(e)},mousemove:e=>{e.buttons||n(e)}};this._register(this.coreMouseService.onProtocolChange(e=>{e?(this.optionsService.rawOptions.logLevel===`debug`&&this._logService.debug(`Binding to mouse events:`,this.coreMouseService.explainEvents(e)),this.element.classList.add(`enable-mouse-events`),this._selectionService.disable()):(this._logService.debug(`Unbinding from mouse events.`),this.element.classList.remove(`enable-mouse-events`),this._selectionService.enable()),e&8?r.mousemove||=(t.addEventListener(`mousemove`,i.mousemove),i.mousemove):(t.removeEventListener(`mousemove`,r.mousemove),r.mousemove=null),e&16?r.wheel||=(t.addEventListener(`wheel`,i.wheel,{passive:!1}),i.wheel):(t.removeEventListener(`wheel`,r.wheel),r.wheel=null),e&2?r.mouseup||=i.mouseup:(this._document.removeEventListener(`mouseup`,r.mouseup),r.mouseup=null),e&4?r.mousedrag||=i.mousedrag:(this._document.removeEventListener(`mousemove`,r.mousedrag),r.mousedrag=null)})),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this._register(z(t,`mousedown`,e=>{if(e.preventDefault(),this.focus(),!(!this.coreMouseService.areMouseEventsActive||this._selectionService.shouldForceSelection(e)))return n(e),r.mouseup&&this._document.addEventListener(`mouseup`,r.mouseup),r.mousedrag&&this._document.addEventListener(`mousemove`,r.mousedrag),this.cancel(e)})),this._register(z(t,`wheel`,t=>{if(!r.wheel){if(this._customWheelEventHandler&&this._customWheelEventHandler(t)===!1)return!1;if(!this.buffer.hasScrollback){if(t.deltaY===0)return!1;if(e.coreMouseService.consumeWheelEvent(t,e._renderService?.dimensions?.device?.cell?.height,e._coreBrowserService?.dpr)===0)return this.cancel(t,!0);let n=B.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?`O`:`[`)+(t.deltaY<0?`A`:`B`);return this.coreService.triggerDataEvent(n,!0),this.cancel(t,!0)}}},{passive:!1}))}refresh(e,t){this._renderService?.refreshRows(e,t)}updateCursorStyle(e){this._selectionService?.shouldColumnSelect(e)?this.element.classList.add(`column-select`):this.element.classList.remove(`column-select`)}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t){this._viewport?this._viewport.scrollLines(e):super.scrollLines(e,t),this.refresh(0,this.rows-1)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(e){e&&this._viewport?this._viewport.scrollToLine(this.buffer.ybase,!0):this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){let t=e-this._bufferService.buffer.ydisp;t!==0&&this.scrollLines(t)}paste(e){O(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}attachCustomWheelEventHandler(e){this._customWheelEventHandler=e}registerLinkProvider(e){return this._linkProviderService.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw Error(`Terminal must be opened first`);let t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw Error(`Terminal must be opened first`);this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return this._selectionService?this._selectionService.hasSelection:!1}select(e,t,n){this._selectionService.setSelection(e,t,n)}getSelection(){return this._selectionService?this._selectionService.selectionText:``}getSelectionPosition(){if(!(!this._selectionService||!this._selectionService.hasSelection))return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){this._selectionService?.clearSelection()}selectAll(){this._selectionService?.selectAll()}selectLines(e,t){this._selectionService?.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;let t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(!0),!1;!t&&(e.key===`Dead`||e.key===`AltGraph`)&&(this._unprocessedDeadKey=!0);let n=Os(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),n.type===3||n.type===2){let t=this.rows-1;return this.scrollLines(n.type===2?-t:t),this.cancel(e,!0)}if(n.type===1&&this.selectAll(),this._isThirdLevelShift(this.browser,e)||(n.cancel&&this.cancel(e,!0),!n.key)||e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.key.length===1&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)return!0;if(this._unprocessedDeadKey)return this._unprocessedDeadKey=!1,!0;if((n.key===B.ETX||n.key===B.CR)&&(this.textarea.value=``),this._onKey.fire({key:n.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(n.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey)return this.cancel(e,!0);this._keyDownHandled=!0}_isThirdLevelShift(e,t){let n=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState(`AltGraph`);return t.type===`keypress`?n:n&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,!(this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)&&(Hs(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled||this._customKeyEventHandler&&this._customKeyEventHandler(e)===!1)return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(e.which===null||e.which===void 0)t=e.keyCode;else if(e.which!==0&&e.charCode!==0)t=e.which;else return!1;return!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)?!1:(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,!0)}_inputEvent(e){if(e.data&&e.inputType===`insertText`&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;let t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){if(e===this.cols&&t===this.rows){this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure();return}super.resize(e,t)}_afterResize(e,t){this._charSizeService?.measure()}clear(){if(!(this.buffer.ybase===0&&this.buffer.y===0)){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){let n={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(n),t.dispose=()=>this._wrappedAddonDispose(n),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let n=0;n=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new M)}translateToString(e,t,n){return this._line.translateToString(e,t,n)}},Gs=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){let t=this._buffer.lines.get(e);if(t)return new Ws(t)}getNullCell(){return new M}},Ks=class extends I{constructor(e){super(),this._core=e,this._onBufferChange=this._register(new R),this.onBufferChange=this._onBufferChange.event,this._normal=new Gs(this._core.buffers.normal,`normal`),this._alternate=new Gs(this._core.buffers.alt,`alternate`),this._core.buffers.onBufferActivate(()=>this._onBufferChange.fire(this.active))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw Error(`Active buffer is neither normal nor alternate`)}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}},qs=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,e=>t(e.toArray()))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,(e,n)=>t(e,n.toArray()))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}},Js=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}},Ys=[`cols`,`rows`],Xs=0,Zs=class extends I{constructor(e){super(),this._core=this._register(new Vs(e)),this._addonManager=this._register(new Us),this._publicOptions={...this._core.options};let t=e=>this._core.options[e],n=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(let e in this._core.options){let r={get:t.bind(this,e),set:n.bind(this,e)};Object.defineProperty(this._publicOptions,e,r)}}_checkReadonlyOptions(e){if(Ys.includes(e))throw Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw Error(`You must set the allowProposedApi option to true to use proposed API`)}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||=new qs(this._core),this._parser}get unicode(){return this._checkProposedApi(),new Js(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||=this._register(new Ks(this._core)),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){let e=this._core.coreService.decPrivateModes,t=`none`;switch(this._core.coreMouseService.activeProtocol){case`X10`:t=`x10`;break;case`VT200`:t=`vt200`;break;case`DRAG`:t=`drag`;break;case`ANY`:t=`any`;break}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,synchronizedOutputMode:e.synchronizedOutput,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(let t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}input(e,t=!0){this._core.input(e,t)}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}attachCustomWheelEventHandler(e){this._core.attachCustomWheelEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){return this._checkProposedApi(),this._verifyPositiveIntegers(e.x??0,e.width??0,e.height??0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,n){this._verifyIntegers(e,t,n),this._core.select(e,t,n)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write(`\r +`,t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return{get promptLabel(){return te.get()},set promptLabel(e){te.set(e)},get tooMuchOutput(){return w.get()},set tooMuchOutput(e){w.set(e)}}}_verifyIntegers(...e){for(Xs of e)if(Xs===1/0||isNaN(Xs)||Xs%1!=0)throw Error(`This API only accepts integers`)}_verifyPositiveIntegers(...e){for(Xs of e)if(Xs&&(Xs===1/0||isNaN(Xs)||Xs%1!=0||Xs<0))throw Error(`This API only accepts positive integers`)}},Qs=2,$s=1,ec=class{activate(e){this._terminal=e}dispose(){}fit(){let e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;let t=this._terminal._core;(this._terminal.rows!==e.rows||this._terminal.cols!==e.cols)&&(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal||!this._terminal.element||!this._terminal.element.parentElement)return;let e=this._terminal._core._renderService.dimensions;if(e.css.cell.width===0||e.css.cell.height===0)return;let t=this._terminal.options.scrollback===0?0:this._terminal.options.overviewRuler?.width||14,n=window.getComputedStyle(this._terminal.element.parentElement),r=parseInt(n.getPropertyValue(`height`)),i=Math.max(0,parseInt(n.getPropertyValue(`width`))),a=window.getComputedStyle(this._terminal.element),o={top:parseInt(a.getPropertyValue(`padding-top`)),bottom:parseInt(a.getPropertyValue(`padding-bottom`)),right:parseInt(a.getPropertyValue(`padding-right`)),left:parseInt(a.getPropertyValue(`padding-left`))},s=o.top+o.bottom,c=o.right+o.left,l=r-s,u=i-c-t;return{cols:Math.max(Qs,Math.floor(u/e.css.cell.width)),rows:Math.max($s,Math.floor(l/e.css.cell.height))}}},tc=class{constructor(e,t,n,r={}){this._terminal=e,this._regex=t,this._handler=n,this._options=r}provideLinks(e,t){let n=rc.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map(e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){let{range:r}=e;this._options.hover(t,n,r)}},e))}};function nc(e){try{let t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch{return!1}}var rc=class e{static computeLink(t,n,r,i){let a=new RegExp(n.source,(n.flags||``)+`g`),[o,s]=e._getWindowedLineStrings(t-1,r),c=o.join(``),l,u=[];for(;l=a.exec(c);){let t=l[0];if(!nc(t))continue;let[n,a]=e._mapStrIdx(r,s,0,l.index),[o,c]=e._mapStrIdx(r,n,a,t.length);if(n===-1||a===-1||o===-1||c===-1)continue;let d={start:{x:a+1,y:n+1},end:{x:c,y:o+1}};u.push({range:d,text:t,activate:i})}return u}static _getWindowedLineStrings(e,t){let n,r=e,i=e,a=0,o=``,s=[];if(n=t.buffer.active.getLine(e)){let e=n.translateToString(!0);if(n.isWrapped&&e[0]!==` `){for(a=0;(n=t.buffer.active.getLine(--r))&&a<2048&&(o=n.translateToString(!0),a+=o.length,s.push(o),!(!n.isWrapped||o.indexOf(` `)!==-1)););s.reverse()}for(s.push(e),a=0;(n=t.buffer.active.getLine(++i))&&n.isWrapped&&a<2048&&(o=n.translateToString(!0),a+=o.length,s.push(o),o.indexOf(` `)===-1););}return[s,r]}static _mapStrIdx(e,t,n,r){let i=e.buffer.active,a=i.getNullCell(),o=n;for(;r;){let e=i.getLine(t);if(!e)return[-1,-1];for(let n=o;n`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function ac(e,t){let n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn(`Opening link blocked as opener could not be cleared`)}var oc=class{constructor(e=ac,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;let t=this._options,n=t.urlRegex||ic;this._linkProvider=this._terminal.registerLinkProvider(new tc(this._terminal,n,this._handler,t))}dispose(){this._linkProvider?.dispose()}},sc=new class{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout(()=>{throw e.stack?fc.isErrorNoTelemetry(e)?new fc(e.message+` + +`+e.stack):Error(e.message+` + +`+e.stack):e},0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach(t=>{t(e)})}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}};function cc(e){uc(e)||sc.onUnexpectedError(e)}var lc=`Canceled`;function uc(e){return e instanceof dc?!0:e instanceof Error&&e.name===lc&&e.message===lc}var dc=class extends Error{constructor(){super(lc),this.name=this.message}},fc=class e extends Error{constructor(e){super(e),this.name=`CodeExpectedError`}static fromError(t){if(t instanceof e)return t;let n=new e;return n.message=t.message,n.stack=t.stack,n}static isErrorNoTelemetry(e){return e.name===`CodeExpectedError`}};function pc(e,t,n=0,r=e.length){let i=n,a=r;for(;i{function t(e){return e<0}e.isLessThan=t;function n(e){return e<=0}e.isLessThanOrEqual=n;function r(e){return e>0}e.isGreaterThan=r;function i(e){return e===0}e.isNeitherLessOrGreaterThan=i,e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0})(hc||={});function gc(e,t){return(n,r)=>t(e(n),e(r))}var _c=(e,t)=>e-t,vc=class e{constructor(e){this.iterate=e}forEach(e){this.iterate(t=>(e(t),!0))}toArray(){let e=[];return this.iterate(t=>(e.push(t),!0)),e}filter(t){return new e(e=>this.iterate(n=>t(n)?e(n):!0))}map(t){return new e(e=>this.iterate(n=>e(t(n))))}some(e){let t=!1;return this.iterate(n=>(t=e(n),!t)),t}findFirst(e){let t;return this.iterate(n=>e(n)?(t=n,!1):!0),t}findLast(e){let t;return this.iterate(n=>(e(n)&&(t=n),!0)),t}findLastMaxBy(e){let t,n=!0;return this.iterate(r=>((n||hc.isGreaterThan(e(r,t)))&&(n=!1,t=r),!0)),t}};vc.empty=new vc(e=>{});function yc(e,t){let n=Object.create(null);for(let r of e){let e=t(r),i=n[e];i||=n[e]=[],i.push(r)}return n}var bc=class{constructor(){this.map=new Map}add(e,t){let n=this.map.get(e);n||(n=new Set,this.map.set(e,n)),n.add(t)}delete(e,t){let n=this.map.get(e);n&&(n.delete(t),n.size===0&&this.map.delete(e))}forEach(e,t){let n=this.map.get(e);n&&n.forEach(t)}get(e){return this.map.get(e)||new Set}};function xc(e,t){let n=this,r=!1,i;return function(){if(r)return i;if(r=!0,t)try{i=e.apply(n,arguments)}finally{t()}else i=e.apply(n,arguments);return i}}var Sc;(e=>{function t(e){return e&&typeof e==`object`&&typeof e[Symbol.iterator]==`function`}e.is=t;let n=Object.freeze([]);function r(){return n}e.empty=r;function*i(e){yield e}e.single=i;function a(e){return t(e)?e:i(e)}e.wrap=a;function o(e){return e||n}e.from=o;function*s(e){for(let t=e.length-1;t>=0;t--)yield e[t]}e.reverse=s;function c(e){return!e||e[Symbol.iterator]().next().done===!0}e.isEmpty=c;function l(e){return e[Symbol.iterator]().next().value}e.first=l;function u(e,t){let n=0;for(let r of e)if(t(r,n++))return!0;return!1}e.some=u;function d(e,t){for(let n of e)if(t(n))return n}e.find=d;function*f(e,t){for(let n of e)t(n)&&(yield n)}e.filter=f;function*p(e,t){let n=0;for(let r of e)yield t(r,n++)}e.map=p;function*m(e,t){let n=0;for(let r of e)yield*t(r,n++)}e.flatMap=m;function*h(...e){for(let t of e)yield*t}e.concat=h;function g(e,t,n){let r=n;for(let n of e)r=t(r,n);return r}e.reduce=g;function*_(e,t,n=e.length){for(t<0&&(t+=e.length),n<0?n+=e.length:n>e.length&&(n=e.length);tt.source!==null&&!this.getRootParent(t,e).isSingleton).flatMap(([e])=>e)}computeLeakingDisposables(e=10,t){let n;if(t)n=t;else{let e=new Map,t=[...this.livingDisposables.values()].filter(t=>t.source!==null&&!this.getRootParent(t,e).isSingleton);if(t.length===0)return;let r=new Set(t.map(e=>e.value));if(n=t.filter(e=>!(e.parent&&r.has(e.parent))),n.length===0)throw Error(`There are cyclic diposable chains!`)}if(!n)return;function r(e){function t(e,t){for(;e.length>0&&t.some(t=>typeof t==`string`?t===e[0]:e[0].match(t));)e.shift()}let n=e.source.split(` +`).map(e=>e.trim().replace(`at `,``)).filter(e=>e!==``);return t(n,[`Error`,/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),n.reverse()}let i=new bc;for(let e of n){let t=r(e);for(let n=0;n<=t.length;n++)i.add(t.slice(0,n).join(` +`),e)}n.sort(gc(e=>e.idx,_c));let a=``,o=0;for(let t of n.slice(0,e)){o++;let e=r(t),s=[];for(let t=0;tr(e)[t]),e=>e);delete o[e[t]];for(let[e,t]of Object.entries(o))s.unshift(` - stacktraces of ${t.length} other leaks continue with ${e}`);s.unshift(a)}a+=` + + +==================== Leaking disposable ${o}/${n.length}: ${t.value.constructor.name} ==================== +${s.join(` +`)} +============================================================ + +`}return n.length>e&&(a+=` + + +... and ${n.length-e} more leaking disposables + +`),{leaks:n,details:a}}};Tc.idx=0;function Ec(e){wc=e}if(Cc){let e=`__is_disposable_tracked__`;Ec(new class{trackDisposable(t){let n=Error(`Potentially leaked disposable`).stack;setTimeout(()=>{t[e]||console.log(n)},3e3)}setParent(t,n){if(t&&t!==Ic.None)try{t[e]=!0}catch{}}markAsDisposed(t){if(t&&t!==Ic.None)try{t[e]=!0}catch{}}markAsSingleton(e){}})}function Dc(e){return wc?.trackDisposable(e),e}function Oc(e){wc?.markAsDisposed(e)}function kc(e,t){wc?.setParent(e,t)}function Ac(e,t){if(wc)for(let n of e)wc.setParent(n,t)}function jc(e){if(Sc.is(e)){let t=[];for(let n of e)if(n)try{n.dispose()}catch(e){t.push(e)}if(t.length===1)throw t[0];if(t.length>1)throw AggregateError(t,`Encountered errors while disposing of store`);return Array.isArray(e)?[]:e}else if(e)return e.dispose(),e}function Mc(...e){let t=Nc(()=>jc(e));return Ac(e,t),t}function Nc(e){let t=Dc({dispose:xc(()=>{Oc(t),e()})});return t}var Pc=class e{constructor(){this._toDispose=new Set,this._isDisposed=!1,Dc(this)}dispose(){this._isDisposed||(Oc(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(this._toDispose.size!==0)try{jc(this._toDispose)}finally{this._toDispose.clear()}}add(t){if(!t)return t;if(t===this)throw Error(`Cannot register a disposable on itself!`);return kc(t,this),this._isDisposed?e.DISABLE_DISPOSED_WARNING||console.warn(Error(`Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!`).stack):this._toDispose.add(t),t}delete(e){if(e){if(e===this)throw Error(`Cannot dispose a disposable on itself!`);this._toDispose.delete(e),e.dispose()}}deleteAndLeak(e){e&&this._toDispose.has(e)&&(this._toDispose.delete(e),kc(e,null))}};Pc.DISABLE_DISPOSED_WARNING=!1;var Fc=Pc,Ic=class{constructor(){this._store=new Fc,Dc(this),kc(this._store,this)}dispose(){Oc(this),this._store.dispose()}_register(e){if(e===this)throw Error(`Cannot register a disposable on itself!`);return this._store.add(e)}};Ic.None=Object.freeze({dispose(){}});var Lc=class{constructor(){this._isDisposed=!1,Dc(this)}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),e&&kc(e,this),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,Oc(this),this._value?.dispose(),this._value=void 0}clearAndLeak(){let e=this._value;return this._value=void 0,e&&kc(e,null),e}},Rc=class e{constructor(t){this.element=t,this.next=e.Undefined,this.prev=e.Undefined}};Rc.Undefined=new Rc(void 0);var zc=globalThis.performance&&typeof globalThis.performance.now==`function`,Bc=class e{static create(t){return new e(t)}constructor(e){this._now=zc&&e===!1?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return this._stopTime===-1?this._now()-this._startTime:this._stopTime-this._startTime}},Vc=!1,Hc=!1,Uc=!1,Wc;(e=>{e.None=()=>Ic.None;function t(e){if(Uc){let{onDidAddListener:t}=e,n=Xc.create(),r=0;e.onDidAddListener=()=>{++r===2&&(console.warn(`snapshotted emitter LIKELY used public and SHOULD HAVE BEEN created with DisposableStore. snapshotted here`),n.print()),t?.()}}}function n(e,t){return f(e,()=>{},0,void 0,!0,void 0,t)}e.defer=n;function r(e){return(t,n=null,r)=>{let i=!1,a;return a=e(e=>{if(!i)return a?a.dispose():i=!0,t.call(n,e)},null,r),i&&a.dispose(),a}}e.once=r;function i(e,t,n){return u((n,r=null,i)=>e(e=>n.call(r,t(e)),null,i),n)}e.map=i;function a(e,t,n){return u((n,r=null,i)=>e(e=>{t(e),n.call(r,e)},null,i),n)}e.forEach=a;function o(e,t,n){return u((n,r=null,i)=>e(e=>t(e)&&n.call(r,e),null,i),n)}e.filter=o;function s(e){return e}e.signal=s;function c(...e){return(t,n=null,r)=>d(Mc(...e.map(e=>e(e=>t.call(n,e)))),r)}e.any=c;function l(e,t,n,r){let a=n;return i(e,e=>(a=t(a,e),a),r)}e.reduce=l;function u(e,n){let r,i={onWillAddFirstListener(){r=e(a.fire,a)},onDidRemoveLastListener(){r?.dispose()}};n||t(i);let a=new il(i);return n?.add(a),a.event}function d(e,t){return t instanceof Array?t.push(e):t&&t.add(e),e}function f(e,n,r=100,i=!1,a=!1,o,s){let c,l,u,d=0,f,p={leakWarningThreshold:o,onWillAddFirstListener(){c=e(e=>{d++,l=n(l,e),i&&!u&&(m.fire(l),l=void 0),f=()=>{let e=l;l=void 0,u=void 0,(!i||d>1)&&m.fire(e),d=0},typeof r==`number`?(clearTimeout(u),u=setTimeout(f,r)):u===void 0&&(u=0,queueMicrotask(f))})},onWillRemoveListener(){a&&d>0&&f?.()},onDidRemoveLastListener(){f=void 0,c.dispose()}};s||t(p);let m=new il(p);return s?.add(m),m.event}e.debounce=f;function p(t,n=0,r){return e.debounce(t,(e,t)=>e?(e.push(t),e):[t],n,void 0,!0,void 0,r)}e.accumulate=p;function m(e,t=(e,t)=>e===t,n){let r=!0,i;return o(e,e=>{let n=r||!t(e,i);return r=!1,i=e,n},n)}e.latch=m;function h(t,n,r){return[e.filter(t,n,r),e.filter(t,e=>!n(e),r)]}e.split=h;function g(e,t=!1,n=[],r){let i=n.slice(),a=e(e=>{i?i.push(e):s.fire(e)});r&&r.add(a);let o=()=>{i?.forEach(e=>s.fire(e)),i=null},s=new il({onWillAddFirstListener(){a||(a=e(e=>s.fire(e)),r&&r.add(a))},onDidAddFirstListener(){i&&(t?setTimeout(o):o())},onDidRemoveLastListener(){a&&a.dispose(),a=null}});return r&&r.add(s),s.event}e.buffer=g;function _(e,t){return(n,r,i)=>{let a=t(new y);return e(function(e){let t=a.evaluate(e);t!==v&&n.call(r,t)},void 0,i)}}e.chain=_;let v=Symbol(`HaltChainable`);class y{constructor(){this.steps=[]}map(e){return this.steps.push(e),this}forEach(e){return this.steps.push(t=>(e(t),t)),this}filter(e){return this.steps.push(t=>e(t)?t:v),this}reduce(e,t){let n=t;return this.steps.push(t=>(n=e(n,t),n)),this}latch(e=(e,t)=>e===t){let t=!0,n;return this.steps.push(r=>{let i=t||!e(r,n);return t=!1,n=r,i?r:v}),this}evaluate(e){for(let t of this.steps)if(e=t(e),e===v)break;return e}}function b(e,t,n=e=>e){let r=(...e)=>i.fire(n(...e)),i=new il({onWillAddFirstListener:()=>e.on(t,r),onDidRemoveLastListener:()=>e.removeListener(t,r)});return i.event}e.fromNodeEventEmitter=b;function x(e,t,n=e=>e){let r=(...e)=>i.fire(n(...e)),i=new il({onWillAddFirstListener:()=>e.addEventListener(t,r),onDidRemoveLastListener:()=>e.removeEventListener(t,r)});return i.event}e.fromDOMEventEmitter=x;function S(e){return new Promise(t=>r(e)(t))}e.toPromise=S;function ee(e){let t=new il;return e.then(e=>{t.fire(e)},()=>{t.fire(void 0)}).finally(()=>{t.dispose()}),t.event}e.fromPromise=ee;function te(e,t){return e(e=>t.fire(e))}e.forward=te;function C(e,t,n){return t(n),e(e=>t(e))}e.runAndSubscribe=C;class w{constructor(e,n){this._observable=e,this._counter=0,this._hasChanged=!1;let r={onWillAddFirstListener:()=>{e.addObserver(this)},onDidRemoveLastListener:()=>{e.removeObserver(this)}};n||t(r),this.emitter=new il(r),n&&n.add(this.emitter)}beginUpdate(e){this._counter++}handlePossibleChange(e){}handleChange(e,t){this._hasChanged=!0}endUpdate(e){this._counter--,this._counter===0&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}function T(e,t){return new w(e,t).emitter.event}e.fromObservable=T;function E(e){return(t,n,r)=>{let i=0,a=!1,o={beginUpdate(){i++},endUpdate(){i--,i===0&&(e.reportChanges(),a&&(a=!1,t.call(n)))},handlePossibleChange(){},handleChange(){a=!0}};e.addObserver(o),e.reportChanges();let s={dispose(){e.removeObserver(o)}};return r instanceof Fc?r.add(s):Array.isArray(r)&&r.push(s),s}}e.fromObservableLight=E})(Wc||={});var Gc=class e{constructor(t){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${t}_${e._idPool++}`,e.all.add(this)}start(e){this._stopWatch=new Bc,this.listenerCount=e}stop(){if(this._stopWatch){let e=this._stopWatch.elapsed();this.durations.push(e),this.elapsedOverall+=e,this.invocationCount+=1,this._stopWatch=void 0}}};Gc.all=new Set,Gc._idPool=0;var Kc=Gc,qc=-1,Jc=class e{constructor(t,n,r=(e._idPool++).toString(16).padStart(3,`0`)){this._errorHandler=t,this.threshold=n,this.name=r,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(e,t){let n=this.threshold;if(n<=0||t{let t=this._stacks.get(e.value)||0;this._stacks.set(e.value,t-1)}}getMostFrequentStack(){if(!this._stacks)return;let e,t=0;for(let[n,r]of this._stacks)(!e||t{if(e instanceof el)t(e);else for(let n=0;n{e.length!==0&&(console.warn(`[LEAKING LISTENERS] GC'ed these listeners that were NOT yet disposed:`),console.warn(e.join(` +`)),e.length=0)},3e3),rl=new FinalizationRegistry(t=>{typeof t==`string`&&e.push(t)})}var il=class{constructor(e){this._size=0,this._options=e,this._leakageMon=qc>0||this._options?.leakWarningThreshold?new Yc(e?.onListenerError??cc,this._options?.leakWarningThreshold??qc):void 0,this._perfMon=this._options?._profName?new Kc(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){if(!this._disposed){if(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners){if(Hc){let e=this._listeners;queueMicrotask(()=>{nl(e,e=>e.stack?.print())})}this._listeners=void 0,this._size=0}this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose()}}get event(){return this._event??=(e,t,n)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){let e=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(e);let t=this._leakageMon.getMostFrequentStack()??[`UNKNOWN stack`,-1],n=new Qc(`${e}. HINT: Stack shows most frequent listener (${t[1]}-times)`,t[0]);return(this._options?.onListenerError||cc)(n),Ic.None}if(this._disposed)return Ic.None;t&&(e=e.bind(t));let r=new el(e),i;this._leakageMon&&this._size>=Math.ceil(this._leakageMon.threshold*.2)&&(r.stack=Xc.create(),i=this._leakageMon.check(r.stack,this._size+1)),Hc&&(r.stack=Xc.create()),this._listeners?this._listeners instanceof el?(this._deliveryQueue??=new al,this._listeners=[this._listeners,r]):this._listeners.push(r):(this._options?.onWillAddFirstListener?.(this),this._listeners=r,this._options?.onDidAddFirstListener?.(this)),this._size++;let a=Nc(()=>{rl?.unregister(a),i?.(),this._removeListener(r)});if(n instanceof Fc?n.add(a):Array.isArray(n)&&n.push(a),rl){let e=Error().stack.split(` +`).slice(2,3).join(` +`).trim(),t=/(file:|vscode-file:\/\/vscode-app)?(\/[^:]*:\d+:\d+)/.exec(e);rl.register(a,t?.[2]??e,a)}return a},this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(this._size===1){this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),this._size=0;return}let t=this._listeners,n=t.indexOf(e);if(n===-1)throw console.log(`disposed?`,this._disposed),console.log(`size?`,this._size),console.log(`arr?`,JSON.stringify(this._listeners)),Error(`Attempted to dispose unknown listener`);this._size--,t[n]=void 0;let r=this._deliveryQueue.current===this;if(this._size*tl<=t.length){let e=0;for(let n=0;n0}},al=class{constructor(){this.i=-1,this.end=0}enqueue(e,t,n){this.i=0,this.end=n,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}},ol=Object.freeze(function(e,t){let n=setTimeout(e.bind(t),0);return{dispose(){clearTimeout(n)}}}),sl;(e=>{function t(t){return t===e.None||t===e.Cancelled||t instanceof cl?!0:!t||typeof t!=`object`?!1:typeof t.isCancellationRequested==`boolean`&&typeof t.onCancellationRequested==`function`}e.isCancellationToken=t,e.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:Wc.None}),e.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:ol})})(sl||={});var cl=class{constructor(){this._isCancelled=!1,this._emitter=null}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?ol:(this._emitter||=new il,this._emitter.event)}dispose(){this._emitter&&=(this._emitter.dispose(),null)}},ll=`en`,ul=!1,dl=!1,fl=ll,pl,ml=globalThis,hl;typeof ml.vscode<`u`&&typeof ml.vscode.process<`u`?hl=ml.vscode.process:typeof process<`u`&&typeof process?.versions?.node==`string`&&(hl=process);var gl=typeof hl?.versions?.electron==`string`&&hl?.type===`renderer`;if(typeof hl==`object`){hl.platform,hl.platform,ul=hl.platform===`linux`,ul&&hl.env.SNAP&&hl.env.SNAP_REVISION,hl.env.CI||hl.env.BUILD_ARTIFACTSTAGINGDIRECTORY,fl=ll;let e=hl.env.VSCODE_NLS_CONFIG;if(e)try{let t=JSON.parse(e);t.userLocale,t.osLocale,fl=t.resolvedLanguage||ll,t.languagePack?.translationsConfigFile}catch{}}else typeof navigator==`object`&&!gl?(pl=navigator.userAgent,pl.indexOf(`Windows`),pl.indexOf(`Macintosh`),(pl.indexOf(`Macintosh`)>=0||pl.indexOf(`iPad`)>=0||pl.indexOf(`iPhone`)>=0)&&navigator.maxTouchPoints&&navigator.maxTouchPoints,ul=pl.indexOf(`Linux`)>=0,pl?.indexOf(`Mobi`),dl=!0,fl=globalThis._VSCODE_NLS_LANGUAGE||ll,navigator.language.toLowerCase()):console.error(`Unable to resolve platform.`);dl&&typeof ml.importScripts==`function`&&ml.origin;var _l=pl,vl=fl,yl;(e=>{function t(){return vl}e.value=t;function n(){return vl.length===2?vl===`en`:vl.length>=3?vl[0]===`e`&&vl[1]===`n`&&vl[2]===`-`:!1}e.isDefaultVariant=n;function r(){return vl===`en`}e.isDefault=r})(yl||={});var bl=typeof ml.postMessage==`function`&&!ml.importScripts;(()=>{if(bl){let e=[];ml.addEventListener(`message`,t=>{if(t.data&&t.data.vscodeScheduleAsyncWork)for(let n=0,r=e.length;n{let r=++t;e.push({id:r,callback:n}),ml.postMessage({vscodeScheduleAsyncWork:r},`*`)}}return e=>setTimeout(e)})();var xl=!!(_l&&_l.indexOf(`Chrome`)>=0);_l&&_l.indexOf(`Firefox`),!xl&&_l&&_l.indexOf(`Safari`),_l&&_l.indexOf(`Edg/`),_l&&_l.indexOf(`Android`);function Sl(e,t=0,n){let r=setTimeout(()=>{e(),n&&i.dispose()},t),i=Nc(()=>{clearTimeout(r),n?.deleteAndLeak(i)});return n?.add(i),i}(function(){typeof globalThis.requestIdleCallback!=`function`||globalThis.cancelIdleCallback})();var Cl;(e=>{async function t(e){let t,n=await Promise.all(e.map(e=>e.then(e=>e,e=>{t||=e})));if(typeof t<`u`)throw t;return n}e.settled=t;function n(e){return new Promise(async(t,n)=>{try{await e(t,n)}catch(e){n(e)}})}e.withAsyncBody=n})(Cl||={});var wl=class e{static fromArray(t){return new e(e=>{e.emitMany(t)})}static fromPromise(t){return new e(async e=>{e.emitMany(await t)})}static fromPromises(t){return new e(async e=>{await Promise.all(t.map(async t=>e.emitOne(await t)))})}static merge(t){return new e(async e=>{await Promise.all(t.map(async t=>{for await(let n of t)e.emitOne(n)}))})}constructor(e,t){this._state=0,this._results=[],this._error=null,this._onReturn=t,this._onStateChanged=new il,queueMicrotask(async()=>{let t={emitOne:e=>this.emitOne(e),emitMany:e=>this.emitMany(e),reject:e=>this.reject(e)};try{await Promise.resolve(e(t)),this.resolve()}catch(e){this.reject(e)}finally{t.emitOne=void 0,t.emitMany=void 0,t.reject=void 0}})}[Symbol.asyncIterator](){let e=0;return{next:async()=>{do{if(this._state===2)throw this._error;if(e(this._onReturn?.(),{done:!0,value:void 0})}}static map(t,n){return new e(async e=>{for await(let r of t)e.emitOne(n(r))})}map(t){return e.map(this,t)}static filter(t,n){return new e(async e=>{for await(let r of t)n(r)&&e.emitOne(r)})}filter(t){return e.filter(this,t)}static coalesce(t){return e.filter(t,e=>!!e)}coalesce(){return e.coalesce(this)}static async toPromise(e){let t=[];for await(let n of e)t.push(n);return t}toPromise(){return e.toPromise(this)}emitOne(e){this._state===0&&(this._results.push(e),this._onStateChanged.fire())}emitMany(e){this._state===0&&(this._results=this._results.concat(e),this._onStateChanged.fire())}resolve(){this._state===0&&(this._state=1,this._onStateChanged.fire())}reject(e){this._state===0&&(this._state=2,this._error=e,this._onStateChanged.fire())}};wl.EMPTY=wl.fromArray([]);var Tl=class extends Ic{constructor(e){super(),this._terminal=e,this._linesCacheTimeout=this._register(new Lc),this._linesCacheDisposables=this._register(new Lc),this._register(Nc(()=>this._destroyLinesCache()))}initLinesCache(){this._linesCache||(this._linesCache=Array(this._terminal.buffer.active.length),this._linesCacheDisposables.value=Mc(this._terminal.onLineFeed(()=>this._destroyLinesCache()),this._terminal.onCursorMove(()=>this._destroyLinesCache()),this._terminal.onResize(()=>this._destroyLinesCache()))),this._linesCacheTimeout.value=Sl(()=>this._destroyLinesCache(),15e3)}_destroyLinesCache(){this._linesCache=void 0,this._linesCacheDisposables.clear(),this._linesCacheTimeout.clear()}getLineFromCache(e){return this._linesCache?.[e]}setLineInCache(e,t){this._linesCache&&(this._linesCache[e]=t)}translateBufferLineToStringWithWrap(e,t){let n=[],r=[0],i=this._terminal.buffer.active.getLine(e);for(;i;){let a=this._terminal.buffer.active.getLine(e+1),o=a?a.isWrapped:!1,s=i.translateToString(!o&&t);if(o&&a){let e=i.getCell(i.length-1);e&&e.getCode()===0&&e.getWidth()===1&&a.getCell(0)?.getWidth()===2&&(s=s.slice(0,-1))}if(n.push(s),o)r.push(r[r.length-1]+s.length);else break;e++,i=a}return[n.join(``),r]}},El=class{get cachedSearchTerm(){return this._cachedSearchTerm}set cachedSearchTerm(e){this._cachedSearchTerm=e}get lastSearchOptions(){return this._lastSearchOptions}set lastSearchOptions(e){this._lastSearchOptions=e}isValidSearchTerm(e){return!!(e&&e.length>0)}didOptionsChange(e){return this._lastSearchOptions?e?this._lastSearchOptions.caseSensitive!==e.caseSensitive||this._lastSearchOptions.regex!==e.regex||this._lastSearchOptions.wholeWord!==e.wholeWord:!1:!0}shouldUpdateHighlighting(e,t){return t?.decorations?this._cachedSearchTerm===void 0||e!==this._cachedSearchTerm||this.didOptionsChange(t):!1}clearCachedTerm(){this._cachedSearchTerm=void 0}reset(){this._cachedSearchTerm=void 0,this._lastSearchOptions=void 0}},Dl=class{constructor(e,t){this._terminal=e,this._lineCache=t}find(e,t,n,r){if(!e||e.length===0){this._terminal.clearSelection();return}if(n>this._terminal.cols)throw Error(`Invalid col: ${n} to search in terminal of ${this._terminal.cols} cols`);this._lineCache.initLinesCache();let i={startRow:t,startCol:n},a=this._findInLine(e,i,r);if(!a)for(let n=t+1;n=0&&(o.startRow=n,s=this._findInLine(e,o,t,!0),!s);n--);}if(!s&&i!==this._terminal.buffer.active.baseY+this._terminal.rows-1)for(let n=this._terminal.buffer.active.baseY+this._terminal.rows-1;n>=i&&(o.startRow=n,s=this._findInLine(e,o,t,!0),!s);n--);return s}_isWholeWord(e,t,n){return(e===0||` ~!@#$%^&*()+\`-=[]{}|\\;:"',./<>?`.includes(t[e-1]))&&(e+n.length===t.length||` ~!@#$%^&*()+\`-=[]{}|\\;:"',./<>?`.includes(t[e+n.length]))}_findInLine(e,t,n={},r=!1){let i=t.startRow,a=t.startCol;if(this._terminal.buffer.active.getLine(i)?.isWrapped){if(r){t.startCol+=this._terminal.cols;return}return t.startRow--,t.startCol+=this._terminal.cols,this._findInLine(e,t,n)}let o=this._lineCache.getLineFromCache(i);o||(o=this._lineCache.translateBufferLineToStringWithWrap(i,!0),this._lineCache.setLineInCache(i,o));let[s,c]=o,l=this._bufferColsToStringOffset(i,a),u=e,d=s;n.regex||(u=n.caseSensitive?e:e.toLowerCase(),d=n.caseSensitive?s:s.toLowerCase());let f=-1;if(n.regex){let t=RegExp(u,n.caseSensitive?`g`:`gi`),i;if(r)for(;i=t.exec(d.slice(0,l));)f=t.lastIndex-i[0].length,e=i[0],t.lastIndex-=e.length-1;else i=t.exec(d.slice(l)),i&&i[0].length>0&&(f=l+(t.lastIndex-i[0].length),e=i[0])}else r?l-u.length>=0&&(f=d.lastIndexOf(u,l-u.length)):f=d.indexOf(u,l);if(f>=0){if(n.wholeWord&&!this._isWholeWord(f,d,e))return;let t=0;for(;t=c[t+1];)t++;let r=t;for(;r=c[r+1];)r++;let a=f-c[t],o=f+e.length-c[r],s=this._stringLengthToBufferSize(i+t,a),l=this._stringLengthToBufferSize(i+r,o)-s+this._terminal.cols*(r-t);return{term:e,col:s,row:i+t,size:l}}}_stringLengthToBufferSize(e,t){let n=this._terminal.buffer.active.getLine(e);if(!n)return 0;for(let e=0;e1&&(t-=i.length-1);let a=n.getCell(e+1);a&&a.getWidth()===0&&t++}return t}_bufferColsToStringOffset(e,t){let n=e,r=0,i=this._terminal.buffer.active.getLine(n);for(;t>0&&i;){for(let e=0;ethis.clearHighlightDecorations()))}createHighlightDecorations(e,t){this.clearHighlightDecorations();for(let n of e){let e=this._createResultDecorations(n,t,!1);if(e)for(let t of e)this._storeDecoration(t,n)}}createActiveDecoration(e,t){let n=this._createResultDecorations(e,t,!0);if(n)return{decorations:n,match:e,dispose(){jc(n)}}}clearHighlightDecorations(){jc(this._highlightDecorations),this._highlightDecorations=[],this._highlightedLines.clear()}_storeDecoration(e,t){this._highlightedLines.add(e.marker.line),this._highlightDecorations.push({decoration:e,match:t,dispose(){e.dispose()}})}_applyStyles(e,t,n){e.classList.contains(`xterm-find-result-decoration`)||(e.classList.add(`xterm-find-result-decoration`),t&&(e.style.outline=`1px solid ${t}`)),n&&e.classList.add(`xterm-find-active-result-decoration`)}_createResultDecorations(e,t,n){let r=[],i=e.col,a=e.size,o=-this._terminal.buffer.active.baseY-this._terminal.buffer.active.cursorY+e.row;for(;a>0;){let e=Math.min(this._terminal.cols-i,a);r.push([o,i,e]),i=0,a-=e,o++}let s=[];for(let e of r){let r=this._terminal.registerMarker(e[0]),i=this._terminal.registerDecoration({marker:r,x:e[1],width:e[2],backgroundColor:n?t.activeMatchBackground:t.matchBackground,overviewRulerOptions:this._highlightedLines.has(r.line)?void 0:{color:n?t.activeMatchColorOverviewRuler:t.matchOverviewRuler,position:`center`}});if(i){let e=[];e.push(r),e.push(i.onRender(e=>this._applyStyles(e,n?t.activeMatchBorder:t.matchBorder,!1))),e.push(i.onDispose(()=>jc(e))),s.push(i)}}return s.length===0?void 0:s}},kl=class extends Ic{constructor(){super(...arguments),this._searchResults=[],this._onDidChangeResults=this._register(new il)}get onDidChangeResults(){return this._onDidChangeResults.event}get searchResults(){return this._searchResults}get selectedDecoration(){return this._selectedDecoration}set selectedDecoration(e){this._selectedDecoration=e}updateResults(e,t){this._searchResults=e.slice(0,t)}clearResults(){this._searchResults=[]}clearSelectedDecoration(){this._selectedDecoration&&=(this._selectedDecoration.dispose(),void 0)}findResultIndex(e){for(let t=0;tthis._updateMatches())),this._register(this._terminal.onResize(()=>this._updateMatches())),this._register(Nc(()=>this.clearDecorations()))}_updateMatches(){this._highlightTimeout.clear(),this._state.cachedSearchTerm&&this._state.lastSearchOptions?.decorations&&(this._highlightTimeout.value=Sl(()=>{let e=this._state.cachedSearchTerm;this._state.clearCachedTerm(),this.findPrevious(e,{...this._state.lastSearchOptions,incremental:!0},{noScroll:!0})},200))}clearDecorations(e){this._resultTracker.clearSelectedDecoration(),this._decorationManager?.clearHighlightDecorations(),this._resultTracker.clearResults(),e||this._state.clearCachedTerm()}clearActiveDecoration(){this._resultTracker.clearSelectedDecoration()}findNext(e,t,n){if(!this._terminal||!this._engine)throw Error(`Cannot use addon until it has been loaded`);this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);let r=this._findNextAndSelect(e,t,n);return this._fireResults(t),this._state.cachedSearchTerm=e,r}_highlightAllMatches(e,t){if(!this._terminal||!this._engine||!this._decorationManager)throw Error(`Cannot use addon until it has been loaded`);if(!this._state.isValidSearchTerm(e)){this.clearDecorations();return}this.clearDecorations(!0);let n=[],r,i=this._engine.find(e,0,0,t);for(;i&&(r?.row!==i.row||r?.col!==i.col)&&!(n.length>=this._highlightLimit);)r=i,n.push(r),i=this._engine.find(e,r.col+r.term.length>=this._terminal.cols?r.row+1:r.row,r.col+r.term.length>=this._terminal.cols?0:r.col+1,t);this._resultTracker.updateResults(n,this._highlightLimit),t.decorations&&this._decorationManager.createHighlightDecorations(n,t.decorations)}_findNextAndSelect(e,t,n){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;let r=this._engine.findNextWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(r,t?.decorations,n?.noScroll)}findPrevious(e,t,n){if(!this._terminal||!this._engine)throw Error(`Cannot use addon until it has been loaded`);this._state.lastSearchOptions=t,this._state.shouldUpdateHighlighting(e,t)&&this._highlightAllMatches(e,t);let r=this._findPreviousAndSelect(e,t,n);return this._fireResults(t),this._state.cachedSearchTerm=e,r}_fireResults(e){this._resultTracker.fireResultsChanged(!!e?.decorations)}_findPreviousAndSelect(e,t,n){if(!this._terminal||!this._engine)return!1;if(!this._state.isValidSearchTerm(e))return this._terminal.clearSelection(),this.clearDecorations(),!1;let r=this._engine.findPreviousWithSelection(e,t,this._state.cachedSearchTerm);return this._selectResult(r,t?.decorations,n?.noScroll)}_selectResult(e,t,n){if(!this._terminal||!this._decorationManager)return!1;if(this._resultTracker.clearSelectedDecoration(),!e)return this._terminal.clearSelection(),!1;if(this._terminal.select(e.col,e.row,e.size),t){let n=this._decorationManager.createActiveDecoration(e,t);n&&(this._resultTracker.selectedDecoration=n)}if(!n&&(e.row>=this._terminal.buffer.active.viewportY+this._terminal.rows||e.rowt[r][1])return!1;for(;r>=n;)if(i=n+r>>1,e>t[i][1])n=i+1;else if(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}charProperties(e,t){let n=this.wcwidth(e),r=n===0&&t!==0;if(r){let e=Pu.extractWidth(t);e===0?r=!1:e>n&&(n=e)}return Pu.createPropertyValue(0,n,r)}},Il=new class{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout(()=>{throw e.stack?Vl.isErrorNoTelemetry(e)?new Vl(e.message+` + +`+e.stack):Error(e.message+` + +`+e.stack):e},0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach(t=>{t(e)})}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}};function Ll(e){zl(e)||Il.onUnexpectedError(e)}var Rl=`Canceled`;function zl(e){return e instanceof Bl?!0:e instanceof Error&&e.name===Rl&&e.message===Rl}var Bl=class extends Error{constructor(){super(Rl),this.name=this.message}},Vl=class e extends Error{constructor(e){super(e),this.name=`CodeExpectedError`}static fromError(t){if(t instanceof e)return t;let n=new e;return n.message=t.message,n.stack=t.stack,n}static isErrorNoTelemetry(e){return e.name===`CodeExpectedError`}};function Hl(e,t){let n=this,r=!1,i;return function(){if(r)return i;if(r=!0,t)try{i=e.apply(n,arguments)}finally{t()}else i=e.apply(n,arguments);return i}}function Ul(e,t,n=0,r=e.length){let i=n,a=r;for(;i{function t(e){return e<0}e.isLessThan=t;function n(e){return e<=0}e.isLessThanOrEqual=n;function r(e){return e>0}e.isGreaterThan=r;function i(e){return e===0}e.isNeitherLessOrGreaterThan=i,e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0})(Gl||={});function Kl(e,t){return(n,r)=>t(e(n),e(r))}var ql=(e,t)=>e-t,Jl=class e{constructor(e){this.iterate=e}forEach(e){this.iterate(t=>(e(t),!0))}toArray(){let e=[];return this.iterate(t=>(e.push(t),!0)),e}filter(t){return new e(e=>this.iterate(n=>t(n)?e(n):!0))}map(t){return new e(e=>this.iterate(n=>e(t(n))))}some(e){let t=!1;return this.iterate(n=>(t=e(n),!t)),t}findFirst(e){let t;return this.iterate(n=>e(n)?(t=n,!1):!0),t}findLast(e){let t;return this.iterate(n=>(e(n)&&(t=n),!0)),t}findLastMaxBy(e){let t,n=!0;return this.iterate(r=>((n||Gl.isGreaterThan(e(r,t)))&&(n=!1,t=r),!0)),t}};Jl.empty=new Jl(e=>{});function Yl(e,t){let n=Object.create(null);for(let r of e){let e=t(r),i=n[e];i||=n[e]=[],i.push(r)}return n}var Xl=class{constructor(){this.map=new Map}add(e,t){let n=this.map.get(e);n||(n=new Set,this.map.set(e,n)),n.add(t)}delete(e,t){let n=this.map.get(e);n&&(n.delete(t),n.size===0&&this.map.delete(e))}forEach(e,t){let n=this.map.get(e);n&&n.forEach(t)}get(e){return this.map.get(e)||new Set}},Zl;(e=>{function t(e){return e&&typeof e==`object`&&typeof e[Symbol.iterator]==`function`}e.is=t;let n=Object.freeze([]);function r(){return n}e.empty=r;function*i(e){yield e}e.single=i;function a(e){return t(e)?e:i(e)}e.wrap=a;function o(e){return e||n}e.from=o;function*s(e){for(let t=e.length-1;t>=0;t--)yield e[t]}e.reverse=s;function c(e){return!e||e[Symbol.iterator]().next().done===!0}e.isEmpty=c;function l(e){return e[Symbol.iterator]().next().value}e.first=l;function u(e,t){let n=0;for(let r of e)if(t(r,n++))return!0;return!1}e.some=u;function d(e,t){for(let n of e)if(t(n))return n}e.find=d;function*f(e,t){for(let n of e)t(n)&&(yield n)}e.filter=f;function*p(e,t){let n=0;for(let r of e)yield t(r,n++)}e.map=p;function*m(e,t){let n=0;for(let r of e)yield*t(r,n++)}e.flatMap=m;function*h(...e){for(let t of e)yield*t}e.concat=h;function g(e,t,n){let r=n;for(let n of e)r=t(r,n);return r}e.reduce=g;function*_(e,t,n=e.length){for(t<0&&(t+=e.length),n<0?n+=e.length:n>e.length&&(n=e.length);tt.source!==null&&!this.getRootParent(t,e).isSingleton).flatMap(([e])=>e)}computeLeakingDisposables(e=10,t){let n;if(t)n=t;else{let e=new Map,t=[...this.livingDisposables.values()].filter(t=>t.source!==null&&!this.getRootParent(t,e).isSingleton);if(t.length===0)return;let r=new Set(t.map(e=>e.value));if(n=t.filter(e=>!(e.parent&&r.has(e.parent))),n.length===0)throw Error(`There are cyclic diposable chains!`)}if(!n)return;function r(e){function t(e,t){for(;e.length>0&&t.some(t=>typeof t==`string`?t===e[0]:e[0].match(t));)e.shift()}let n=e.source.split(` +`).map(e=>e.trim().replace(`at `,``)).filter(e=>e!==``);return t(n,[`Error`,/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),n.reverse()}let i=new Xl;for(let e of n){let t=r(e);for(let n=0;n<=t.length;n++)i.add(t.slice(0,n).join(` +`),e)}n.sort(Kl(e=>e.idx,ql));let a=``,o=0;for(let t of n.slice(0,e)){o++;let e=r(t),s=[];for(let t=0;tr(e)[t]),e=>e);delete o[e[t]];for(let[e,t]of Object.entries(o))s.unshift(` - stacktraces of ${t.length} other leaks continue with ${e}`);s.unshift(a)}a+=` + + +==================== Leaking disposable ${o}/${n.length}: ${t.value.constructor.name} ==================== +${s.join(` +`)} +============================================================ + +`}return n.length>e&&(a+=` + + +... and ${n.length-e} more leaking disposables + +`),{leaks:n,details:a}}};eu.idx=0;function tu(e){$l=e}if(Ql){let e=`__is_disposable_tracked__`;tu(new class{trackDisposable(t){let n=Error(`Potentially leaked disposable`).stack;setTimeout(()=>{t[e]||console.log(n)},3e3)}setParent(t,n){if(t&&t!==du.None)try{t[e]=!0}catch{}}markAsDisposed(t){if(t&&t!==du.None)try{t[e]=!0}catch{}}markAsSingleton(e){}})}function nu(e){return $l?.trackDisposable(e),e}function ru(e){$l?.markAsDisposed(e)}function iu(e,t){$l?.setParent(e,t)}function au(e,t){if($l)for(let n of e)$l.setParent(n,t)}function ou(e){if(Zl.is(e)){let t=[];for(let n of e)if(n)try{n.dispose()}catch(e){t.push(e)}if(t.length===1)throw t[0];if(t.length>1)throw AggregateError(t,`Encountered errors while disposing of store`);return Array.isArray(e)?[]:e}else if(e)return e.dispose(),e}function su(...e){let t=cu(()=>ou(e));return au(e,t),t}function cu(e){let t=nu({dispose:Hl(()=>{ru(t),e()})});return t}var lu=class e{constructor(){this._toDispose=new Set,this._isDisposed=!1,nu(this)}dispose(){this._isDisposed||(ru(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(this._toDispose.size!==0)try{ou(this._toDispose)}finally{this._toDispose.clear()}}add(t){if(!t)return t;if(t===this)throw Error(`Cannot register a disposable on itself!`);return iu(t,this),this._isDisposed?e.DISABLE_DISPOSED_WARNING||console.warn(Error(`Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!`).stack):this._toDispose.add(t),t}delete(e){if(e){if(e===this)throw Error(`Cannot dispose a disposable on itself!`);this._toDispose.delete(e),e.dispose()}}deleteAndLeak(e){e&&this._toDispose.has(e)&&(this._toDispose.delete(e),iu(e,null))}};lu.DISABLE_DISPOSED_WARNING=!1;var uu=lu,du=class{constructor(){this._store=new uu,nu(this),iu(this._store,this)}dispose(){ru(this),this._store.dispose()}_register(e){if(e===this)throw Error(`Cannot register a disposable on itself!`);return this._store.add(e)}};du.None=Object.freeze({dispose(){}});var fu=class e{constructor(t){this.element=t,this.next=e.Undefined,this.prev=e.Undefined}};fu.Undefined=new fu(void 0);var pu=globalThis.performance&&typeof globalThis.performance.now==`function`,mu=class e{static create(t){return new e(t)}constructor(e){this._now=pu&&e===!1?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return this._stopTime===-1?this._now()-this._startTime:this._stopTime-this._startTime}},hu=!1,gu=!1,_u=!1,vu;(e=>{e.None=()=>du.None;function t(e){if(_u){let{onDidAddListener:t}=e,n=wu.create(),r=0;e.onDidAddListener=()=>{++r===2&&(console.warn(`snapshotted emitter LIKELY used public and SHOULD HAVE BEEN created with DisposableStore. snapshotted here`),n.print()),t?.()}}}function n(e,t){return f(e,()=>{},0,void 0,!0,void 0,t)}e.defer=n;function r(e){return(t,n=null,r)=>{let i=!1,a;return a=e(e=>{if(!i)return a?a.dispose():i=!0,t.call(n,e)},null,r),i&&a.dispose(),a}}e.once=r;function i(e,t,n){return u((n,r=null,i)=>e(e=>n.call(r,t(e)),null,i),n)}e.map=i;function a(e,t,n){return u((n,r=null,i)=>e(e=>{t(e),n.call(r,e)},null,i),n)}e.forEach=a;function o(e,t,n){return u((n,r=null,i)=>e(e=>t(e)&&n.call(r,e),null,i),n)}e.filter=o;function s(e){return e}e.signal=s;function c(...e){return(t,n=null,r)=>d(su(...e.map(e=>e(e=>t.call(n,e)))),r)}e.any=c;function l(e,t,n,r){let a=n;return i(e,e=>(a=t(a,e),a),r)}e.reduce=l;function u(e,n){let r,i={onWillAddFirstListener(){r=e(a.fire,a)},onDidRemoveLastListener(){r?.dispose()}};n||t(i);let a=new Mu(i);return n?.add(a),a.event}function d(e,t){return t instanceof Array?t.push(e):t&&t.add(e),e}function f(e,n,r=100,i=!1,a=!1,o,s){let c,l,u,d=0,f,p={leakWarningThreshold:o,onWillAddFirstListener(){c=e(e=>{d++,l=n(l,e),i&&!u&&(m.fire(l),l=void 0),f=()=>{let e=l;l=void 0,u=void 0,(!i||d>1)&&m.fire(e),d=0},typeof r==`number`?(clearTimeout(u),u=setTimeout(f,r)):u===void 0&&(u=0,queueMicrotask(f))})},onWillRemoveListener(){a&&d>0&&f?.()},onDidRemoveLastListener(){f=void 0,c.dispose()}};s||t(p);let m=new Mu(p);return s?.add(m),m.event}e.debounce=f;function p(t,n=0,r){return e.debounce(t,(e,t)=>e?(e.push(t),e):[t],n,void 0,!0,void 0,r)}e.accumulate=p;function m(e,t=(e,t)=>e===t,n){let r=!0,i;return o(e,e=>{let n=r||!t(e,i);return r=!1,i=e,n},n)}e.latch=m;function h(t,n,r){return[e.filter(t,n,r),e.filter(t,e=>!n(e),r)]}e.split=h;function g(e,t=!1,n=[],r){let i=n.slice(),a=e(e=>{i?i.push(e):s.fire(e)});r&&r.add(a);let o=()=>{i?.forEach(e=>s.fire(e)),i=null},s=new Mu({onWillAddFirstListener(){a||(a=e(e=>s.fire(e)),r&&r.add(a))},onDidAddFirstListener(){i&&(t?setTimeout(o):o())},onDidRemoveLastListener(){a&&a.dispose(),a=null}});return r&&r.add(s),s.event}e.buffer=g;function _(e,t){return(n,r,i)=>{let a=t(new y);return e(function(e){let t=a.evaluate(e);t!==v&&n.call(r,t)},void 0,i)}}e.chain=_;let v=Symbol(`HaltChainable`);class y{constructor(){this.steps=[]}map(e){return this.steps.push(e),this}forEach(e){return this.steps.push(t=>(e(t),t)),this}filter(e){return this.steps.push(t=>e(t)?t:v),this}reduce(e,t){let n=t;return this.steps.push(t=>(n=e(n,t),n)),this}latch(e=(e,t)=>e===t){let t=!0,n;return this.steps.push(r=>{let i=t||!e(r,n);return t=!1,n=r,i?r:v}),this}evaluate(e){for(let t of this.steps)if(e=t(e),e===v)break;return e}}function b(e,t,n=e=>e){let r=(...e)=>i.fire(n(...e)),i=new Mu({onWillAddFirstListener:()=>e.on(t,r),onDidRemoveLastListener:()=>e.removeListener(t,r)});return i.event}e.fromNodeEventEmitter=b;function x(e,t,n=e=>e){let r=(...e)=>i.fire(n(...e)),i=new Mu({onWillAddFirstListener:()=>e.addEventListener(t,r),onDidRemoveLastListener:()=>e.removeEventListener(t,r)});return i.event}e.fromDOMEventEmitter=x;function S(e){return new Promise(t=>r(e)(t))}e.toPromise=S;function ee(e){let t=new Mu;return e.then(e=>{t.fire(e)},()=>{t.fire(void 0)}).finally(()=>{t.dispose()}),t.event}e.fromPromise=ee;function te(e,t){return e(e=>t.fire(e))}e.forward=te;function C(e,t,n){return t(n),e(e=>t(e))}e.runAndSubscribe=C;class w{constructor(e,n){this._observable=e,this._counter=0,this._hasChanged=!1;let r={onWillAddFirstListener:()=>{e.addObserver(this)},onDidRemoveLastListener:()=>{e.removeObserver(this)}};n||t(r),this.emitter=new Mu(r),n&&n.add(this.emitter)}beginUpdate(e){this._counter++}handlePossibleChange(e){}handleChange(e,t){this._hasChanged=!0}endUpdate(e){this._counter--,this._counter===0&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}function T(e,t){return new w(e,t).emitter.event}e.fromObservable=T;function E(e){return(t,n,r)=>{let i=0,a=!1,o={beginUpdate(){i++},endUpdate(){i--,i===0&&(e.reportChanges(),a&&(a=!1,t.call(n)))},handlePossibleChange(){},handleChange(){a=!0}};e.addObserver(o),e.reportChanges();let s={dispose(){e.removeObserver(o)}};return r instanceof uu?r.add(s):Array.isArray(r)&&r.push(s),s}}e.fromObservableLight=E})(vu||={});var yu=class e{constructor(t){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${t}_${e._idPool++}`,e.all.add(this)}start(e){this._stopWatch=new mu,this.listenerCount=e}stop(){if(this._stopWatch){let e=this._stopWatch.elapsed();this.durations.push(e),this.elapsedOverall+=e,this.invocationCount+=1,this._stopWatch=void 0}}};yu.all=new Set,yu._idPool=0;var bu=yu,xu=-1,Su=class e{constructor(t,n,r=(e._idPool++).toString(16).padStart(3,`0`)){this._errorHandler=t,this.threshold=n,this.name=r,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(e,t){let n=this.threshold;if(n<=0||t{let t=this._stacks.get(e.value)||0;this._stacks.set(e.value,t-1)}}getMostFrequentStack(){if(!this._stacks)return;let e,t=0;for(let[n,r]of this._stacks)(!e||t{if(e instanceof Ou)t(e);else for(let n=0;n{e.length!==0&&(console.warn(`[LEAKING LISTENERS] GC'ed these listeners that were NOT yet disposed:`),console.warn(e.join(` +`)),e.length=0)},3e3),ju=new FinalizationRegistry(t=>{typeof t==`string`&&e.push(t)})}var Mu=class{constructor(e){this._size=0,this._options=e,this._leakageMon=xu>0||this._options?.leakWarningThreshold?new Cu(e?.onListenerError??Ll,this._options?.leakWarningThreshold??xu):void 0,this._perfMon=this._options?._profName?new bu(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){if(!this._disposed){if(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners){if(gu){let e=this._listeners;queueMicrotask(()=>{Au(e,e=>e.stack?.print())})}this._listeners=void 0,this._size=0}this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose()}}get event(){return this._event??=(e,t,n)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){let e=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(e);let t=this._leakageMon.getMostFrequentStack()??[`UNKNOWN stack`,-1],n=new Eu(`${e}. HINT: Stack shows most frequent listener (${t[1]}-times)`,t[0]);return(this._options?.onListenerError||Ll)(n),du.None}if(this._disposed)return du.None;t&&(e=e.bind(t));let r=new Ou(e),i;this._leakageMon&&this._size>=Math.ceil(this._leakageMon.threshold*.2)&&(r.stack=wu.create(),i=this._leakageMon.check(r.stack,this._size+1)),gu&&(r.stack=wu.create()),this._listeners?this._listeners instanceof Ou?(this._deliveryQueue??=new Nu,this._listeners=[this._listeners,r]):this._listeners.push(r):(this._options?.onWillAddFirstListener?.(this),this._listeners=r,this._options?.onDidAddFirstListener?.(this)),this._size++;let a=cu(()=>{ju?.unregister(a),i?.(),this._removeListener(r)});if(n instanceof uu?n.add(a):Array.isArray(n)&&n.push(a),ju){let e=Error().stack.split(` +`).slice(2,3).join(` +`).trim(),t=/(file:|vscode-file:\/\/vscode-app)?(\/[^:]*:\d+:\d+)/.exec(e);ju.register(a,t?.[2]??e,a)}return a},this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(this._size===1){this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),this._size=0;return}let t=this._listeners,n=t.indexOf(e);if(n===-1)throw console.log(`disposed?`,this._disposed),console.log(`size?`,this._size),console.log(`arr?`,JSON.stringify(this._listeners)),Error(`Attempted to dispose unknown listener`);this._size--,t[n]=void 0;let r=this._deliveryQueue.current===this;if(this._size*ku<=t.length){let e=0;for(let n=0;n0}},Nu=class{constructor(){this.i=-1,this.end=0}enqueue(e,t,n){this.i=0,this.end=n,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}},Pu=class e{constructor(){this._providers=Object.create(null),this._active=``,this._onChange=new Mu,this.onChange=this._onChange.event;let e=new Fl;this.register(e),this._active=e.version,this._activeProvider=e}static extractShouldJoin(e){return(e&1)!=0}static extractWidth(e){return e>>1&3}static extractCharKind(e){return e>>3}static createPropertyValue(e,t,n=!1){return(e&16777215)<<3|(t&3)<<1|(n?1:0)}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(t){let n=0,r=0,i=t.length;for(let a=0;a=i)return n+this.wcwidth(o);let e=t.charCodeAt(a);56320<=e&&e<=57343?o=(o-55296)*1024+e-56320+65536:n+=this.wcwidth(e)}let s=this.charProperties(o,r),c=e.extractWidth(s);e.extractShouldJoin(s)&&(c-=e.extractWidth(r)),n+=c,r=s}return n}charProperties(e,t){return this._activeProvider.charProperties(e,t)}},Fu=[[768,879],[1155,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1541],[1552,1562],[1564,1564],[1611,1631],[1648,1648],[1750,1757],[1759,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2045,2045],[2070,2073],[2075,2083],[2085,2087],[2089,2093],[2137,2139],[2259,2306],[2362,2362],[2364,2364],[2369,2376],[2381,2381],[2385,2391],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2558,2558],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2641,2641],[2672,2673],[2677,2677],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2810,2815],[2817,2817],[2876,2876],[2879,2879],[2881,2884],[2893,2893],[2902,2902],[2914,2915],[2946,2946],[3008,3008],[3021,3021],[3072,3072],[3076,3076],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3170,3171],[3201,3201],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3328,3329],[3387,3388],[3393,3396],[3405,3405],[3426,3427],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3981,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4151],[4153,4154],[4157,4158],[4184,4185],[4190,4192],[4209,4212],[4226,4226],[4229,4230],[4237,4237],[4253,4253],[4448,4607],[4957,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6158],[6277,6278],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6683,6683],[6742,6742],[6744,6750],[6752,6752],[6754,6754],[6757,6764],[6771,6780],[6783,6783],[6832,6846],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7040,7041],[7074,7077],[7080,7081],[7083,7085],[7142,7142],[7144,7145],[7149,7149],[7151,7153],[7212,7219],[7222,7223],[7376,7378],[7380,7392],[7394,7400],[7405,7405],[7412,7412],[7416,7417],[7616,7673],[7675,7679],[8203,8207],[8234,8238],[8288,8292],[8294,8303],[8400,8432],[11503,11505],[11647,11647],[11744,11775],[12330,12333],[12441,12442],[42607,42610],[42612,42621],[42654,42655],[42736,42737],[43010,43010],[43014,43014],[43019,43019],[43045,43046],[43204,43205],[43232,43249],[43263,43263],[43302,43309],[43335,43345],[43392,43394],[43443,43443],[43446,43449],[43452,43453],[43493,43493],[43561,43566],[43569,43570],[43573,43574],[43587,43587],[43596,43596],[43644,43644],[43696,43696],[43698,43700],[43703,43704],[43710,43711],[43713,43713],[43756,43757],[43766,43766],[44005,44005],[44008,44008],[44013,44013],[64286,64286],[65024,65039],[65056,65071],[65279,65279],[65529,65531]],Iu=[[66045,66045],[66272,66272],[66422,66426],[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[68325,68326],[68900,68903],[69446,69456],[69633,69633],[69688,69702],[69759,69761],[69811,69814],[69817,69818],[69821,69821],[69837,69837],[69888,69890],[69927,69931],[69933,69940],[70003,70003],[70016,70017],[70070,70078],[70089,70092],[70191,70193],[70196,70196],[70198,70199],[70206,70206],[70367,70367],[70371,70378],[70400,70401],[70459,70460],[70464,70464],[70502,70508],[70512,70516],[70712,70719],[70722,70724],[70726,70726],[70750,70750],[70835,70840],[70842,70842],[70847,70848],[70850,70851],[71090,71093],[71100,71101],[71103,71104],[71132,71133],[71219,71226],[71229,71229],[71231,71232],[71339,71339],[71341,71341],[71344,71349],[71351,71351],[71453,71455],[71458,71461],[71463,71467],[71727,71735],[71737,71738],[72148,72151],[72154,72155],[72160,72160],[72193,72202],[72243,72248],[72251,72254],[72263,72263],[72273,72278],[72281,72283],[72330,72342],[72344,72345],[72752,72758],[72760,72765],[72767,72767],[72850,72871],[72874,72880],[72882,72883],[72885,72886],[73009,73014],[73018,73018],[73020,73021],[73023,73029],[73031,73031],[73104,73105],[73109,73109],[73111,73111],[73459,73460],[78896,78904],[92912,92916],[92976,92982],[94031,94031],[94095,94098],[113821,113822],[113824,113827],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[121344,121398],[121403,121452],[121461,121461],[121476,121476],[121499,121503],[121505,121519],[122880,122886],[122888,122904],[122907,122913],[122915,122916],[122918,122922],[123184,123190],[123628,123631],[125136,125142],[125252,125258],[917505,917505],[917536,917631],[917760,917999]],Lu=[[4352,4447],[8986,8987],[9001,9002],[9193,9196],[9200,9200],[9203,9203],[9725,9726],[9748,9749],[9800,9811],[9855,9855],[9875,9875],[9889,9889],[9898,9899],[9917,9918],[9924,9925],[9934,9934],[9940,9940],[9962,9962],[9970,9971],[9973,9973],[9978,9978],[9981,9981],[9989,9989],[9994,9995],[10024,10024],[10060,10060],[10062,10062],[10067,10069],[10071,10071],[10133,10135],[10160,10160],[10175,10175],[11035,11036],[11088,11088],[11093,11093],[11904,11929],[11931,12019],[12032,12245],[12272,12283],[12288,12329],[12334,12350],[12353,12438],[12443,12543],[12549,12591],[12593,12686],[12688,12730],[12736,12771],[12784,12830],[12832,12871],[12880,19903],[19968,42124],[42128,42182],[43360,43388],[44032,55203],[63744,64255],[65040,65049],[65072,65106],[65108,65126],[65128,65131],[65281,65376],[65504,65510]],Ru=[[94176,94179],[94208,100343],[100352,101106],[110592,110878],[110928,110930],[110948,110951],[110960,111355],[126980,126980],[127183,127183],[127374,127374],[127377,127386],[127488,127490],[127504,127547],[127552,127560],[127568,127569],[127584,127589],[127744,127776],[127789,127797],[127799,127868],[127870,127891],[127904,127946],[127951,127955],[127968,127984],[127988,127988],[127992,128062],[128064,128064],[128066,128252],[128255,128317],[128331,128334],[128336,128359],[128378,128378],[128405,128406],[128420,128420],[128507,128591],[128640,128709],[128716,128716],[128720,128722],[128725,128725],[128747,128748],[128756,128762],[128992,129003],[129293,129393],[129395,129398],[129402,129442],[129445,129450],[129454,129482],[129485,129535],[129648,129651],[129656,129658],[129664,129666],[129680,129685],[131072,196605],[196608,262141]],zu;function Bu(e,t){let n=0,r=t.length-1,i;if(et[r][1])return!1;for(;r>=n;)if(i=n+r>>1,e>t[i][1])n=i+1;else if(en&&(n=e)}return Pu.createPropertyValue(0,n,r)}},Hu=class{activate(e){e.unicode.register(new Vu)}dispose(){}},Uu=Object.defineProperty,Wu=Object.getOwnPropertyDescriptor,Gu=(e,t,n,r)=>{for(var i=r>1?void 0:r?Wu(t,n):t,a=e.length-1,o;a>=0;a--)(o=e[a])&&(i=(r?o(t,n,i):o(i))||i);return r&&i&&Uu(t,n,i),i},Ku=(e,t)=>(n,r)=>t(n,r,e),qu=new class{constructor(){this.listeners=[],this.unexpectedErrorHandler=function(e){setTimeout(()=>{throw e.stack?Qu.isErrorNoTelemetry(e)?new Qu(e.message+` + +`+e.stack):Error(e.message+` + +`+e.stack):e},0)}}addListener(e){return this.listeners.push(e),()=>{this._removeListener(e)}}emit(e){this.listeners.forEach(t=>{t(e)})}_removeListener(e){this.listeners.splice(this.listeners.indexOf(e),1)}setUnexpectedErrorHandler(e){this.unexpectedErrorHandler=e}getUnexpectedErrorHandler(){return this.unexpectedErrorHandler}onUnexpectedError(e){this.unexpectedErrorHandler(e),this.emit(e)}onUnexpectedExternalError(e){this.unexpectedErrorHandler(e)}};function Ju(e){Xu(e)||qu.onUnexpectedError(e)}var Yu=`Canceled`;function Xu(e){return e instanceof Zu?!0:e instanceof Error&&e.name===Yu&&e.message===Yu}var Zu=class extends Error{constructor(){super(Yu),this.name=this.message}},Qu=class e extends Error{constructor(e){super(e),this.name=`CodeExpectedError`}static fromError(t){if(t instanceof e)return t;let n=new e;return n.message=t.message,n.stack=t.stack,n}static isErrorNoTelemetry(e){return e.name===`CodeExpectedError`}};function $u(e,t,n=0,r=e.length){let i=n,a=r;for(;i{function t(e){return e<0}e.isLessThan=t;function n(e){return e<=0}e.isLessThanOrEqual=n;function r(e){return e>0}e.isGreaterThan=r;function i(e){return e===0}e.isNeitherLessOrGreaterThan=i,e.greaterThan=1,e.lessThan=-1,e.neitherLessOrGreaterThan=0})(td||={});function nd(e,t){return(n,r)=>t(e(n),e(r))}var rd=(e,t)=>e-t,id=class e{constructor(e){this.iterate=e}forEach(e){this.iterate(t=>(e(t),!0))}toArray(){let e=[];return this.iterate(t=>(e.push(t),!0)),e}filter(t){return new e(e=>this.iterate(n=>t(n)?e(n):!0))}map(t){return new e(e=>this.iterate(n=>e(t(n))))}some(e){let t=!1;return this.iterate(n=>(t=e(n),!t)),t}findFirst(e){let t;return this.iterate(n=>e(n)?(t=n,!1):!0),t}findLast(e){let t;return this.iterate(n=>(e(n)&&(t=n),!0)),t}findLastMaxBy(e){let t,n=!0;return this.iterate(r=>((n||td.isGreaterThan(e(r,t)))&&(n=!1,t=r),!0)),t}};id.empty=new id(e=>{});function ad(e,t){let n=Object.create(null);for(let r of e){let e=t(r),i=n[e];i||=n[e]=[],i.push(r)}return n}var od=class{constructor(){this.map=new Map}add(e,t){let n=this.map.get(e);n||(n=new Set,this.map.set(e,n)),n.add(t)}delete(e,t){let n=this.map.get(e);n&&(n.delete(t),n.size===0&&this.map.delete(e))}forEach(e,t){let n=this.map.get(e);n&&n.forEach(t)}get(e){return this.map.get(e)||new Set}};function sd(e,t){let n=this,r=!1,i;return function(){if(r)return i;if(r=!0,t)try{i=e.apply(n,arguments)}finally{t()}else i=e.apply(n,arguments);return i}}var cd;(e=>{function t(e){return e&&typeof e==`object`&&typeof e[Symbol.iterator]==`function`}e.is=t;let n=Object.freeze([]);function r(){return n}e.empty=r;function*i(e){yield e}e.single=i;function a(e){return t(e)?e:i(e)}e.wrap=a;function o(e){return e||n}e.from=o;function*s(e){for(let t=e.length-1;t>=0;t--)yield e[t]}e.reverse=s;function c(e){return!e||e[Symbol.iterator]().next().done===!0}e.isEmpty=c;function l(e){return e[Symbol.iterator]().next().value}e.first=l;function u(e,t){let n=0;for(let r of e)if(t(r,n++))return!0;return!1}e.some=u;function d(e,t){for(let n of e)if(t(n))return n}e.find=d;function*f(e,t){for(let n of e)t(n)&&(yield n)}e.filter=f;function*p(e,t){let n=0;for(let r of e)yield t(r,n++)}e.map=p;function*m(e,t){let n=0;for(let r of e)yield*t(r,n++)}e.flatMap=m;function*h(...e){for(let t of e)yield*t}e.concat=h;function g(e,t,n){let r=n;for(let n of e)r=t(r,n);return r}e.reduce=g;function*_(e,t,n=e.length){for(t<0&&(t+=e.length),n<0?n+=e.length:n>e.length&&(n=e.length);tt.source!==null&&!this.getRootParent(t,e).isSingleton).flatMap(([e])=>e)}computeLeakingDisposables(e=10,t){let n;if(t)n=t;else{let e=new Map,t=[...this.livingDisposables.values()].filter(t=>t.source!==null&&!this.getRootParent(t,e).isSingleton);if(t.length===0)return;let r=new Set(t.map(e=>e.value));if(n=t.filter(e=>!(e.parent&&r.has(e.parent))),n.length===0)throw Error(`There are cyclic diposable chains!`)}if(!n)return;function r(e){function t(e,t){for(;e.length>0&&t.some(t=>typeof t==`string`?t===e[0]:e[0].match(t));)e.shift()}let n=e.source.split(` +`).map(e=>e.trim().replace(`at `,``)).filter(e=>e!==``);return t(n,[`Error`,/^trackDisposable \(.*\)$/,/^DisposableTracker.trackDisposable \(.*\)$/]),n.reverse()}let i=new od;for(let e of n){let t=r(e);for(let n=0;n<=t.length;n++)i.add(t.slice(0,n).join(` +`),e)}n.sort(nd(e=>e.idx,rd));let a=``,o=0;for(let t of n.slice(0,e)){o++;let e=r(t),s=[];for(let t=0;tr(e)[t]),e=>e);delete o[e[t]];for(let[e,t]of Object.entries(o))s.unshift(` - stacktraces of ${t.length} other leaks continue with ${e}`);s.unshift(a)}a+=` + + +==================== Leaking disposable ${o}/${n.length}: ${t.value.constructor.name} ==================== +${s.join(` +`)} +============================================================ + +`}return n.length>e&&(a+=` + + +... and ${n.length-e} more leaking disposables + +`),{leaks:n,details:a}}};dd.idx=0;function fd(e){ud=e}if(ld){let e=`__is_disposable_tracked__`;fd(new class{trackDisposable(t){let n=Error(`Potentially leaked disposable`).stack;setTimeout(()=>{t[e]||console.log(n)},3e3)}setParent(t,n){if(t&&t!==Sd.None)try{t[e]=!0}catch{}}markAsDisposed(t){if(t&&t!==Sd.None)try{t[e]=!0}catch{}}markAsSingleton(e){}})}function pd(e){return ud?.trackDisposable(e),e}function md(e){ud?.markAsDisposed(e)}function hd(e,t){ud?.setParent(e,t)}function gd(e,t){if(ud)for(let n of e)ud.setParent(n,t)}function _d(e){if(cd.is(e)){let t=[];for(let n of e)if(n)try{n.dispose()}catch(e){t.push(e)}if(t.length===1)throw t[0];if(t.length>1)throw AggregateError(t,`Encountered errors while disposing of store`);return Array.isArray(e)?[]:e}else if(e)return e.dispose(),e}function vd(...e){let t=yd(()=>_d(e));return gd(e,t),t}function yd(e){let t=pd({dispose:sd(()=>{md(t),e()})});return t}var bd=class e{constructor(){this._toDispose=new Set,this._isDisposed=!1,pd(this)}dispose(){this._isDisposed||(md(this),this._isDisposed=!0,this.clear())}get isDisposed(){return this._isDisposed}clear(){if(this._toDispose.size!==0)try{_d(this._toDispose)}finally{this._toDispose.clear()}}add(t){if(!t)return t;if(t===this)throw Error(`Cannot register a disposable on itself!`);return hd(t,this),this._isDisposed?e.DISABLE_DISPOSED_WARNING||console.warn(Error(`Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!`).stack):this._toDispose.add(t),t}delete(e){if(e){if(e===this)throw Error(`Cannot dispose a disposable on itself!`);this._toDispose.delete(e),e.dispose()}}deleteAndLeak(e){e&&this._toDispose.has(e)&&(this._toDispose.delete(e),hd(e,null))}};bd.DISABLE_DISPOSED_WARNING=!1;var xd=bd,Sd=class{constructor(){this._store=new xd,pd(this),hd(this._store,this)}dispose(){md(this),this._store.dispose()}_register(e){if(e===this)throw Error(`Cannot register a disposable on itself!`);return this._store.add(e)}};Sd.None=Object.freeze({dispose(){}});var Cd=class{constructor(){this._isDisposed=!1,pd(this)}get value(){return this._isDisposed?void 0:this._value}set value(e){this._isDisposed||e===this._value||(this._value?.dispose(),e&&hd(e,this),this._value=e)}clear(){this.value=void 0}dispose(){this._isDisposed=!0,md(this),this._value?.dispose(),this._value=void 0}clearAndLeak(){let e=this._value;return this._value=void 0,e&&hd(e,null),e}},wd=typeof process<`u`&&`title`in process,Td=wd?`node`:navigator.userAgent,Ed=wd?`node`:navigator.platform,Dd=Td.includes(`Firefox`),Od=Td.includes(`Edge`),kd=/^((?!chrome|android).)*safari/i.test(Td);function Ad(){if(!kd)return 0;let e=Td.match(/Version\/(\d+)/);return e===null||e.length<2?0:parseInt(e[1])}[`Macintosh`,`MacIntel`,`MacPPC`,`Mac68K`].includes(Ed),[`Windows`,`Win16`,`Win32`,`WinCE`].includes(Ed),Ed.indexOf(`Linux`),/\bCrOS\b/.test(Td);var jd=``,Md=0,Nd=0,Pd=0,Y=0,Fd={css:`#00000000`,rgba:0},Id;(e=>{function t(e,t,n,r){return r===void 0?`#${Vd(e)}${Vd(t)}${Vd(n)}`:`#${Vd(e)}${Vd(t)}${Vd(n)}${Vd(r)}`}e.toCss=t;function n(e,t,n,r=255){return(e<<24|t<<16|n<<8|r)>>>0}e.toRgba=n;function r(t,n,r,i){return{css:e.toCss(t,n,r,i),rgba:e.toRgba(t,n,r,i)}}e.toColor=r})(Id||={});var Ld;(e=>{function t(e,t){if(Y=(t.rgba&255)/255,Y===1)return{css:t.css,rgba:t.rgba};let n=t.rgba>>24&255,r=t.rgba>>16&255,i=t.rgba>>8&255,a=e.rgba>>24&255,o=e.rgba>>16&255,s=e.rgba>>8&255;return Md=a+Math.round((n-a)*Y),Nd=o+Math.round((r-o)*Y),Pd=s+Math.round((i-s)*Y),{css:Id.toCss(Md,Nd,Pd),rgba:Id.toRgba(Md,Nd,Pd)}}e.blend=t;function n(e){return(e.rgba&255)==255}e.isOpaque=n;function r(e,t,n){let r=Bd.ensureContrastRatio(e.rgba,t.rgba,n);if(r)return Id.toColor(r>>24&255,r>>16&255,r>>8&255)}e.ensureContrastRatio=r;function i(e){let t=(e.rgba|255)>>>0;return[Md,Nd,Pd]=Bd.toChannels(t),{css:Id.toCss(Md,Nd,Pd),rgba:t}}e.opaque=i;function a(e,t){return Y=Math.round(t*255),[Md,Nd,Pd]=Bd.toChannels(e.rgba),{css:Id.toCss(Md,Nd,Pd,Y),rgba:Id.toRgba(Md,Nd,Pd,Y)}}e.opacity=a;function o(e,t){return Y=e.rgba&255,a(e,Y*t/255)}e.multiplyOpacity=o;function s(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}e.toColorRGB=s})(Ld||={});var Rd;(e=>{let t,n;try{let e=document.createElement(`canvas`);e.width=1,e.height=1;let r=e.getContext(`2d`,{willReadFrequently:!0});r&&(t=r,t.globalCompositeOperation=`copy`,n=t.createLinearGradient(0,0,1,1))}catch{}function r(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return Md=parseInt(e.slice(1,2).repeat(2),16),Nd=parseInt(e.slice(2,3).repeat(2),16),Pd=parseInt(e.slice(3,4).repeat(2),16),Id.toColor(Md,Nd,Pd);case 5:return Md=parseInt(e.slice(1,2).repeat(2),16),Nd=parseInt(e.slice(2,3).repeat(2),16),Pd=parseInt(e.slice(3,4).repeat(2),16),Y=parseInt(e.slice(4,5).repeat(2),16),Id.toColor(Md,Nd,Pd,Y);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}let r=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(r)return Md=parseInt(r[1]),Nd=parseInt(r[2]),Pd=parseInt(r[3]),Y=Math.round((r[5]===void 0?1:parseFloat(r[5]))*255),Id.toColor(Md,Nd,Pd,Y);if(!t||!n||(t.fillStyle=n,t.fillStyle=e,typeof t.fillStyle!=`string`)||(t.fillRect(0,0,1,1),[Md,Nd,Pd,Y]=t.getImageData(0,0,1,1).data,Y!==255))throw Error(`css.toColor: Unsupported css format`);return{rgba:Id.toRgba(Md,Nd,Pd,Y),css:e}}e.toColor=r})(Rd||={});var zd;(e=>{function t(e){return n(e>>16&255,e>>8&255,e&255)}e.relativeLuminance=t;function n(e,t,n){let r=e/255,i=t/255,a=n/255,o=r<=.03928?r/12.92:((r+.055)/1.055)**2.4,s=i<=.03928?i/12.92:((i+.055)/1.055)**2.4,c=a<=.03928?a/12.92:((a+.055)/1.055)**2.4;return o*.2126+s*.7152+c*.0722}e.relativeLuminance2=n})(zd||={});var Bd;(e=>{function t(e,t){if(Y=(t&255)/255,Y===1)return t;let n=t>>24&255,r=t>>16&255,i=t>>8&255,a=e>>24&255,o=e>>16&255,s=e>>8&255;return Md=a+Math.round((n-a)*Y),Nd=o+Math.round((r-o)*Y),Pd=s+Math.round((i-s)*Y),Id.toRgba(Md,Nd,Pd)}e.blend=t;function n(e,t,n){let a=zd.relativeLuminance(e>>8),o=zd.relativeLuminance(t>>8);if(Hd(a,o)>8));if(sHd(a,zd.relativeLuminance(r>>8))?o:r}return o}let s=i(e,t,n),c=Hd(a,zd.relativeLuminance(s>>8));if(cHd(a,zd.relativeLuminance(i>>8))?s:i}return s}}e.ensureContrastRatio=n;function r(e,t,n){let r=e>>24&255,i=e>>16&255,a=e>>8&255,o=t>>24&255,s=t>>16&255,c=t>>8&255,l=Hd(zd.relativeLuminance2(o,s,c),zd.relativeLuminance2(r,i,a));for(;l0||s>0||c>0);)o-=Math.max(0,Math.ceil(o*.1)),s-=Math.max(0,Math.ceil(s*.1)),c-=Math.max(0,Math.ceil(c*.1)),l=Hd(zd.relativeLuminance2(o,s,c),zd.relativeLuminance2(r,i,a));return(o<<24|s<<16|c<<8|255)>>>0}e.reduceLuminance=r;function i(e,t,n){let r=e>>24&255,i=e>>16&255,a=e>>8&255,o=t>>24&255,s=t>>16&255,c=t>>8&255,l=Hd(zd.relativeLuminance2(o,s,c),zd.relativeLuminance2(r,i,a));for(;l>>0}e.increaseLuminance=i;function a(e){return[e>>24&255,e>>16&255,e>>8&255,e&255]}e.toChannels=a})(Bd||={});function Vd(e){let t=e.toString(16);return t.length<2?`0`+t:t}function Hd(e,t){return e=128512&&e<=128591||e>=127744&&e<=128511||e>=128640&&e<=128767||e>=9728&&e<=9983||e>=9984&&e<=10175||e>=65024&&e<=65039||e>=129280&&e<=129535||e>=127462&&e<=127487}function Jd(e,t,n,r){return t===1&&n>Math.ceil(r*1.5)&&e!==void 0&&e>255&&!qd(e)&&!Ud(e)&&!Gd(e)}function Yd(e){return Ud(e)||Kd(e)}function Xd(){return{css:{canvas:Zd(),cell:Zd()},device:{canvas:Zd(),cell:Zd(),char:{width:0,height:0,left:0,top:0}}}}function Zd(){return{width:0,height:0}}function Qd(e,t,n=0){return(e-(Math.round(t)*2-n))%(Math.round(t)*2)}var $d=0,ef=0,tf=!1,nf=!1,rf=!1,af,of=0,sf=class{constructor(e,t,n,r,i,a){this._terminal=e,this._optionService=t,this._selectionRenderModel=n,this._decorationService=r,this._coreBrowserService=i,this._themeService=a,this.result={fg:0,bg:0,ext:0}}resolve(e,t,n,r){if(this.result.bg=e.bg,this.result.fg=e.fg,this.result.ext=e.bg&268435456?e.extended.ext:0,ef=0,$d=0,nf=!1,tf=!1,rf=!1,af=this._themeService.colors,of=0,e.getCode()!==0&&e.extended.underlineStyle===4){let e=Math.max(1,Math.floor(this._optionService.rawOptions.fontSize*this._coreBrowserService.dpr/15));of=t*r%(Math.round(e)*2)}if(this._decorationService.forEachDecorationAtCell(t,n,`bottom`,e=>{e.backgroundColorRGB&&(ef=e.backgroundColorRGB.rgba>>8&16777215,nf=!0),e.foregroundColorRGB&&($d=e.foregroundColorRGB.rgba>>8&16777215,tf=!0)}),rf=this._selectionRenderModel.isCellSelected(this._terminal,t,n),rf){if(this.result.fg&67108864||this.result.bg&50331648){if(this.result.fg&67108864)switch(this.result.fg&50331648){case 16777216:case 33554432:ef=this._themeService.colors.ansi[this.result.fg&255].rgba;break;case 50331648:ef=(this.result.fg&16777215)<<8|255;break;case 0:default:ef=this._themeService.colors.foreground.rgba}else switch(this.result.bg&50331648){case 16777216:case 33554432:ef=this._themeService.colors.ansi[this.result.bg&255].rgba;break;case 50331648:ef=(this.result.bg&16777215)<<8|255;break}ef=Bd.blend(ef,(this._coreBrowserService.isFocused?af.selectionBackgroundOpaque:af.selectionInactiveBackgroundOpaque).rgba&4294967040|128)>>8&16777215}else ef=(this._coreBrowserService.isFocused?af.selectionBackgroundOpaque:af.selectionInactiveBackgroundOpaque).rgba>>8&16777215;if(nf=!0,af.selectionForeground&&($d=af.selectionForeground.rgba>>8&16777215,tf=!0),Yd(e.getCode())){if(this.result.fg&67108864&&!(this.result.bg&50331648))$d=(this._coreBrowserService.isFocused?af.selectionBackgroundOpaque:af.selectionInactiveBackgroundOpaque).rgba>>8&16777215;else{if(this.result.fg&67108864)switch(this.result.bg&50331648){case 16777216:case 33554432:$d=this._themeService.colors.ansi[this.result.bg&255].rgba;break;case 50331648:$d=(this.result.bg&16777215)<<8|255;break}else switch(this.result.fg&50331648){case 16777216:case 33554432:$d=this._themeService.colors.ansi[this.result.fg&255].rgba;break;case 50331648:$d=(this.result.fg&16777215)<<8|255;break;case 0:default:$d=this._themeService.colors.foreground.rgba}$d=Bd.blend($d,(this._coreBrowserService.isFocused?af.selectionBackgroundOpaque:af.selectionInactiveBackgroundOpaque).rgba&4294967040|128)>>8&16777215}tf=!0}}this._decorationService.forEachDecorationAtCell(t,n,`top`,e=>{e.backgroundColorRGB&&(ef=e.backgroundColorRGB.rgba>>8&16777215,nf=!0),e.foregroundColorRGB&&($d=e.foregroundColorRGB.rgba>>8&16777215,tf=!0)}),nf&&(ef=rf?e.bg&-150994944|ef|50331648:e.bg&-16777216|ef|50331648),tf&&($d=e.fg&-83886080|$d|50331648),this.result.fg&67108864&&(nf&&!tf&&($d=this.result.bg&50331648?this.result.fg&-134217728|this.result.bg&67108863:this.result.fg&-134217728|af.background.rgba>>8&16777215|50331648,tf=!0),!nf&&tf&&(ef=this.result.fg&50331648?this.result.bg&-67108864|this.result.fg&67108863:this.result.bg&-67108864|af.foreground.rgba>>8&16777215|50331648,nf=!0)),af=void 0,this.result.bg=nf?ef:this.result.bg,this.result.fg=tf?$d:this.result.fg,this.result.ext&=536870911,this.result.ext|=of<<29&3758096384}},cf=.5,lf=Dd||Od?`bottom`:`ideographic`,uf={"▀":[{x:0,y:0,w:8,h:4}],"▁":[{x:0,y:7,w:8,h:1}],"▂":[{x:0,y:6,w:8,h:2}],"▃":[{x:0,y:5,w:8,h:3}],"▄":[{x:0,y:4,w:8,h:4}],"▅":[{x:0,y:3,w:8,h:5}],"▆":[{x:0,y:2,w:8,h:6}],"▇":[{x:0,y:1,w:8,h:7}],"█":[{x:0,y:0,w:8,h:8}],"▉":[{x:0,y:0,w:7,h:8}],"▊":[{x:0,y:0,w:6,h:8}],"▋":[{x:0,y:0,w:5,h:8}],"▌":[{x:0,y:0,w:4,h:8}],"▍":[{x:0,y:0,w:3,h:8}],"▎":[{x:0,y:0,w:2,h:8}],"▏":[{x:0,y:0,w:1,h:8}],"▐":[{x:4,y:0,w:4,h:8}],"▔":[{x:0,y:0,w:8,h:1}],"▕":[{x:7,y:0,w:1,h:8}],"▖":[{x:0,y:4,w:4,h:4}],"▗":[{x:4,y:4,w:4,h:4}],"▘":[{x:0,y:0,w:4,h:4}],"▙":[{x:0,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}],"▚":[{x:0,y:0,w:4,h:4},{x:4,y:4,w:4,h:4}],"▛":[{x:0,y:0,w:4,h:8},{x:4,y:0,w:4,h:4}],"▜":[{x:0,y:0,w:8,h:4},{x:4,y:0,w:4,h:8}],"▝":[{x:4,y:0,w:4,h:4}],"▞":[{x:4,y:0,w:4,h:4},{x:0,y:4,w:4,h:4}],"▟":[{x:4,y:0,w:4,h:8},{x:0,y:4,w:8,h:4}],"🭰":[{x:1,y:0,w:1,h:8}],"🭱":[{x:2,y:0,w:1,h:8}],"🭲":[{x:3,y:0,w:1,h:8}],"🭳":[{x:4,y:0,w:1,h:8}],"🭴":[{x:5,y:0,w:1,h:8}],"🭵":[{x:6,y:0,w:1,h:8}],"🭶":[{x:0,y:1,w:8,h:1}],"🭷":[{x:0,y:2,w:8,h:1}],"🭸":[{x:0,y:3,w:8,h:1}],"🭹":[{x:0,y:4,w:8,h:1}],"🭺":[{x:0,y:5,w:8,h:1}],"🭻":[{x:0,y:6,w:8,h:1}],"🭼":[{x:0,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}],"🭽":[{x:0,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}],"🭾":[{x:7,y:0,w:1,h:8},{x:0,y:0,w:8,h:1}],"🭿":[{x:7,y:0,w:1,h:8},{x:0,y:7,w:8,h:1}],"🮀":[{x:0,y:0,w:8,h:1},{x:0,y:7,w:8,h:1}],"🮁":[{x:0,y:0,w:8,h:1},{x:0,y:2,w:8,h:1},{x:0,y:4,w:8,h:1},{x:0,y:7,w:8,h:1}],"🮂":[{x:0,y:0,w:8,h:2}],"🮃":[{x:0,y:0,w:8,h:3}],"🮄":[{x:0,y:0,w:8,h:5}],"🮅":[{x:0,y:0,w:8,h:6}],"🮆":[{x:0,y:0,w:8,h:7}],"🮇":[{x:6,y:0,w:2,h:8}],"🮈":[{x:5,y:0,w:3,h:8}],"🮉":[{x:3,y:0,w:5,h:8}],"🮊":[{x:2,y:0,w:6,h:8}],"🮋":[{x:1,y:0,w:7,h:8}],"🮕":[{x:0,y:0,w:2,h:2},{x:4,y:0,w:2,h:2},{x:2,y:2,w:2,h:2},{x:6,y:2,w:2,h:2},{x:0,y:4,w:2,h:2},{x:4,y:4,w:2,h:2},{x:2,y:6,w:2,h:2},{x:6,y:6,w:2,h:2}],"🮖":[{x:2,y:0,w:2,h:2},{x:6,y:0,w:2,h:2},{x:0,y:2,w:2,h:2},{x:4,y:2,w:2,h:2},{x:2,y:4,w:2,h:2},{x:6,y:4,w:2,h:2},{x:0,y:6,w:2,h:2},{x:4,y:6,w:2,h:2}],"🮗":[{x:0,y:2,w:8,h:2},{x:0,y:6,w:8,h:2}]},df={"░":[[1,0,0,0],[0,0,0,0],[0,0,1,0],[0,0,0,0]],"▒":[[1,0],[0,0],[0,1],[0,0]],"▓":[[0,1],[1,1],[1,0],[1,1]]},ff={"─":{1:`M0,.5 L1,.5`},"━":{3:`M0,.5 L1,.5`},"│":{1:`M.5,0 L.5,1`},"┃":{3:`M.5,0 L.5,1`},"┌":{1:`M0.5,1 L.5,.5 L1,.5`},"┏":{3:`M0.5,1 L.5,.5 L1,.5`},"┐":{1:`M0,.5 L.5,.5 L.5,1`},"┓":{3:`M0,.5 L.5,.5 L.5,1`},"└":{1:`M.5,0 L.5,.5 L1,.5`},"┗":{3:`M.5,0 L.5,.5 L1,.5`},"┘":{1:`M.5,0 L.5,.5 L0,.5`},"┛":{3:`M.5,0 L.5,.5 L0,.5`},"├":{1:`M.5,0 L.5,1 M.5,.5 L1,.5`},"┣":{3:`M.5,0 L.5,1 M.5,.5 L1,.5`},"┤":{1:`M.5,0 L.5,1 M.5,.5 L0,.5`},"┫":{3:`M.5,0 L.5,1 M.5,.5 L0,.5`},"┬":{1:`M0,.5 L1,.5 M.5,.5 L.5,1`},"┳":{3:`M0,.5 L1,.5 M.5,.5 L.5,1`},"┴":{1:`M0,.5 L1,.5 M.5,.5 L.5,0`},"┻":{3:`M0,.5 L1,.5 M.5,.5 L.5,0`},"┼":{1:`M0,.5 L1,.5 M.5,0 L.5,1`},"╋":{3:`M0,.5 L1,.5 M.5,0 L.5,1`},"╴":{1:`M.5,.5 L0,.5`},"╸":{3:`M.5,.5 L0,.5`},"╵":{1:`M.5,.5 L.5,0`},"╹":{3:`M.5,.5 L.5,0`},"╶":{1:`M.5,.5 L1,.5`},"╺":{3:`M.5,.5 L1,.5`},"╷":{1:`M.5,.5 L.5,1`},"╻":{3:`M.5,.5 L.5,1`},"═":{1:(e,t)=>`M0,${.5-t} L1,${.5-t} M0,${.5+t} L1,${.5+t}`},"║":{1:(e,t)=>`M${.5-e},0 L${.5-e},1 M${.5+e},0 L${.5+e},1`},"╒":{1:(e,t)=>`M.5,1 L.5,${.5-t} L1,${.5-t} M.5,${.5+t} L1,${.5+t}`},"╓":{1:(e,t)=>`M${.5-e},1 L${.5-e},.5 L1,.5 M${.5+e},.5 L${.5+e},1`},"╔":{1:(e,t)=>`M1,${.5-t} L${.5-e},${.5-t} L${.5-e},1 M1,${.5+t} L${.5+e},${.5+t} L${.5+e},1`},"╕":{1:(e,t)=>`M0,${.5-t} L.5,${.5-t} L.5,1 M0,${.5+t} L.5,${.5+t}`},"╖":{1:(e,t)=>`M${.5+e},1 L${.5+e},.5 L0,.5 M${.5-e},.5 L${.5-e},1`},"╗":{1:(e,t)=>`M0,${.5+t} L${.5-e},${.5+t} L${.5-e},1 M0,${.5-t} L${.5+e},${.5-t} L${.5+e},1`},"╘":{1:(e,t)=>`M.5,0 L.5,${.5+t} L1,${.5+t} M.5,${.5-t} L1,${.5-t}`},"╙":{1:(e,t)=>`M1,.5 L${.5-e},.5 L${.5-e},0 M${.5+e},.5 L${.5+e},0`},"╚":{1:(e,t)=>`M1,${.5-t} L${.5+e},${.5-t} L${.5+e},0 M1,${.5+t} L${.5-e},${.5+t} L${.5-e},0`},"╛":{1:(e,t)=>`M0,${.5+t} L.5,${.5+t} L.5,0 M0,${.5-t} L.5,${.5-t}`},"╜":{1:(e,t)=>`M0,.5 L${.5+e},.5 L${.5+e},0 M${.5-e},.5 L${.5-e},0`},"╝":{1:(e,t)=>`M0,${.5-t} L${.5-e},${.5-t} L${.5-e},0 M0,${.5+t} L${.5+e},${.5+t} L${.5+e},0`},"╞":{1:(e,t)=>`M.5,0 L.5,1 M.5,${.5-t} L1,${.5-t} M.5,${.5+t} L1,${.5+t}`},"╟":{1:(e,t)=>`M${.5-e},0 L${.5-e},1 M${.5+e},0 L${.5+e},1 M${.5+e},.5 L1,.5`},"╠":{1:(e,t)=>`M${.5-e},0 L${.5-e},1 M1,${.5+t} L${.5+e},${.5+t} L${.5+e},1 M1,${.5-t} L${.5+e},${.5-t} L${.5+e},0`},"╡":{1:(e,t)=>`M.5,0 L.5,1 M0,${.5-t} L.5,${.5-t} M0,${.5+t} L.5,${.5+t}`},"╢":{1:(e,t)=>`M0,.5 L${.5-e},.5 M${.5-e},0 L${.5-e},1 M${.5+e},0 L${.5+e},1`},"╣":{1:(e,t)=>`M${.5+e},0 L${.5+e},1 M0,${.5+t} L${.5-e},${.5+t} L${.5-e},1 M0,${.5-t} L${.5-e},${.5-t} L${.5-e},0`},"╤":{1:(e,t)=>`M0,${.5-t} L1,${.5-t} M0,${.5+t} L1,${.5+t} M.5,${.5+t} L.5,1`},"╥":{1:(e,t)=>`M0,.5 L1,.5 M${.5-e},.5 L${.5-e},1 M${.5+e},.5 L${.5+e},1`},"╦":{1:(e,t)=>`M0,${.5-t} L1,${.5-t} M0,${.5+t} L${.5-e},${.5+t} L${.5-e},1 M1,${.5+t} L${.5+e},${.5+t} L${.5+e},1`},"╧":{1:(e,t)=>`M.5,0 L.5,${.5-t} M0,${.5-t} L1,${.5-t} M0,${.5+t} L1,${.5+t}`},"╨":{1:(e,t)=>`M0,.5 L1,.5 M${.5-e},.5 L${.5-e},0 M${.5+e},.5 L${.5+e},0`},"╩":{1:(e,t)=>`M0,${.5+t} L1,${.5+t} M0,${.5-t} L${.5-e},${.5-t} L${.5-e},0 M1,${.5-t} L${.5+e},${.5-t} L${.5+e},0`},"╪":{1:(e,t)=>`M.5,0 L.5,1 M0,${.5-t} L1,${.5-t} M0,${.5+t} L1,${.5+t}`},"╫":{1:(e,t)=>`M0,.5 L1,.5 M${.5-e},0 L${.5-e},1 M${.5+e},0 L${.5+e},1`},"╬":{1:(e,t)=>`M0,${.5+t} L${.5-e},${.5+t} L${.5-e},1 M1,${.5+t} L${.5+e},${.5+t} L${.5+e},1 M0,${.5-t} L${.5-e},${.5-t} L${.5-e},0 M1,${.5-t} L${.5+e},${.5-t} L${.5+e},0`},"╱":{1:`M1,0 L0,1`},"╲":{1:`M0,0 L1,1`},"╳":{1:`M1,0 L0,1 M0,0 L1,1`},"╼":{1:`M.5,.5 L0,.5`,3:`M.5,.5 L1,.5`},"╽":{1:`M.5,.5 L.5,0`,3:`M.5,.5 L.5,1`},"╾":{1:`M.5,.5 L1,.5`,3:`M.5,.5 L0,.5`},"╿":{1:`M.5,.5 L.5,1`,3:`M.5,.5 L.5,0`},"┍":{1:`M.5,.5 L.5,1`,3:`M.5,.5 L1,.5`},"┎":{1:`M.5,.5 L1,.5`,3:`M.5,.5 L.5,1`},"┑":{1:`M.5,.5 L.5,1`,3:`M.5,.5 L0,.5`},"┒":{1:`M.5,.5 L0,.5`,3:`M.5,.5 L.5,1`},"┕":{1:`M.5,.5 L.5,0`,3:`M.5,.5 L1,.5`},"┖":{1:`M.5,.5 L1,.5`,3:`M.5,.5 L.5,0`},"┙":{1:`M.5,.5 L.5,0`,3:`M.5,.5 L0,.5`},"┚":{1:`M.5,.5 L0,.5`,3:`M.5,.5 L.5,0`},"┝":{1:`M.5,0 L.5,1`,3:`M.5,.5 L1,.5`},"┞":{1:`M0.5,1 L.5,.5 L1,.5`,3:`M.5,.5 L.5,0`},"┟":{1:`M.5,0 L.5,.5 L1,.5`,3:`M.5,.5 L.5,1`},"┠":{1:`M.5,.5 L1,.5`,3:`M.5,0 L.5,1`},"┡":{1:`M.5,.5 L.5,1`,3:`M.5,0 L.5,.5 L1,.5`},"┢":{1:`M.5,.5 L.5,0`,3:`M0.5,1 L.5,.5 L1,.5`},"┥":{1:`M.5,0 L.5,1`,3:`M.5,.5 L0,.5`},"┦":{1:`M0,.5 L.5,.5 L.5,1`,3:`M.5,.5 L.5,0`},"┧":{1:`M.5,0 L.5,.5 L0,.5`,3:`M.5,.5 L.5,1`},"┨":{1:`M.5,.5 L0,.5`,3:`M.5,0 L.5,1`},"┩":{1:`M.5,.5 L.5,1`,3:`M.5,0 L.5,.5 L0,.5`},"┪":{1:`M.5,.5 L.5,0`,3:`M0,.5 L.5,.5 L.5,1`},"┭":{1:`M0.5,1 L.5,.5 L1,.5`,3:`M.5,.5 L0,.5`},"┮":{1:`M0,.5 L.5,.5 L.5,1`,3:`M.5,.5 L1,.5`},"┯":{1:`M.5,.5 L.5,1`,3:`M0,.5 L1,.5`},"┰":{1:`M0,.5 L1,.5`,3:`M.5,.5 L.5,1`},"┱":{1:`M.5,.5 L1,.5`,3:`M0,.5 L.5,.5 L.5,1`},"┲":{1:`M.5,.5 L0,.5`,3:`M0.5,1 L.5,.5 L1,.5`},"┵":{1:`M.5,0 L.5,.5 L1,.5`,3:`M.5,.5 L0,.5`},"┶":{1:`M.5,0 L.5,.5 L0,.5`,3:`M.5,.5 L1,.5`},"┷":{1:`M.5,.5 L.5,0`,3:`M0,.5 L1,.5`},"┸":{1:`M0,.5 L1,.5`,3:`M.5,.5 L.5,0`},"┹":{1:`M.5,.5 L1,.5`,3:`M.5,0 L.5,.5 L0,.5`},"┺":{1:`M.5,.5 L0,.5`,3:`M.5,0 L.5,.5 L1,.5`},"┽":{1:`M.5,0 L.5,1 M.5,.5 L1,.5`,3:`M.5,.5 L0,.5`},"┾":{1:`M.5,0 L.5,1 M.5,.5 L0,.5`,3:`M.5,.5 L1,.5`},"┿":{1:`M.5,0 L.5,1`,3:`M0,.5 L1,.5`},"╀":{1:`M0,.5 L1,.5 M.5,.5 L.5,1`,3:`M.5,.5 L.5,0`},"╁":{1:`M.5,.5 L.5,0 M0,.5 L1,.5`,3:`M.5,.5 L.5,1`},"╂":{1:`M0,.5 L1,.5`,3:`M.5,0 L.5,1`},"╃":{1:`M0.5,1 L.5,.5 L1,.5`,3:`M.5,0 L.5,.5 L0,.5`},"╄":{1:`M0,.5 L.5,.5 L.5,1`,3:`M.5,0 L.5,.5 L1,.5`},"╅":{1:`M.5,0 L.5,.5 L1,.5`,3:`M0,.5 L.5,.5 L.5,1`},"╆":{1:`M.5,0 L.5,.5 L0,.5`,3:`M0.5,1 L.5,.5 L1,.5`},"╇":{1:`M.5,.5 L.5,1`,3:`M.5,.5 L.5,0 M0,.5 L1,.5`},"╈":{1:`M.5,.5 L.5,0`,3:`M0,.5 L1,.5 M.5,.5 L.5,1`},"╉":{1:`M.5,.5 L1,.5`,3:`M.5,0 L.5,1 M.5,.5 L0,.5`},"╊":{1:`M.5,.5 L0,.5`,3:`M.5,0 L.5,1 M.5,.5 L1,.5`},"╌":{1:`M.1,.5 L.4,.5 M.6,.5 L.9,.5`},"╍":{3:`M.1,.5 L.4,.5 M.6,.5 L.9,.5`},"┄":{1:`M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5`},"┅":{3:`M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5`},"┈":{1:`M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5`},"┉":{3:`M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5`},"╎":{1:`M.5,.1 L.5,.4 M.5,.6 L.5,.9`},"╏":{3:`M.5,.1 L.5,.4 M.5,.6 L.5,.9`},"┆":{1:`M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333`},"┇":{3:`M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333`},"┊":{1:`M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95`},"┋":{3:`M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95`},"╭":{1:(e,t)=>`M.5,1 L.5,${.5+t/.15*.5} C.5,${.5+t/.15*.5},.5,.5,1,.5`},"╮":{1:(e,t)=>`M.5,1 L.5,${.5+t/.15*.5} C.5,${.5+t/.15*.5},.5,.5,0,.5`},"╯":{1:(e,t)=>`M.5,0 L.5,${.5-t/.15*.5} C.5,${.5-t/.15*.5},.5,.5,0,.5`},"╰":{1:(e,t)=>`M.5,0 L.5,${.5-t/.15*.5} C.5,${.5-t/.15*.5},.5,.5,1,.5`}},pf={"":{d:`M.3,1 L.03,1 L.03,.88 C.03,.82,.06,.78,.11,.73 C.15,.7,.2,.68,.28,.65 L.43,.6 C.49,.58,.53,.56,.56,.53 C.59,.5,.6,.47,.6,.43 L.6,.27 L.4,.27 L.69,.1 L.98,.27 L.78,.27 L.78,.46 C.78,.52,.76,.56,.72,.61 C.68,.66,.63,.67,.56,.7 L.48,.72 C.42,.74,.38,.76,.35,.78 C.32,.8,.31,.84,.31,.88 L.31,1 M.3,.5 L.03,.59 L.03,.09 L.3,.09 L.3,.655`,type:0},"":{d:`M.7,.4 L.7,.47 L.2,.47 L.2,.03 L.355,.03 L.355,.4 L.705,.4 M.7,.5 L.86,.5 L.86,.95 L.69,.95 L.44,.66 L.46,.86 L.46,.95 L.3,.95 L.3,.49 L.46,.49 L.71,.78 L.69,.565 L.69,.5`,type:0},"":{d:`M.25,.94 C.16,.94,.11,.92,.11,.87 L.11,.53 C.11,.48,.15,.455,.23,.45 L.23,.3 C.23,.25,.26,.22,.31,.19 C.36,.16,.43,.15,.51,.15 C.59,.15,.66,.16,.71,.19 C.77,.22,.79,.26,.79,.3 L.79,.45 C.87,.45,.91,.48,.91,.53 L.91,.87 C.91,.92,.86,.94,.77,.94 L.24,.94 M.53,.2 C.49,.2,.45,.21,.42,.23 C.39,.25,.38,.27,.38,.3 L.38,.45 L.68,.45 L.68,.3 C.68,.27,.67,.25,.64,.23 C.61,.21,.58,.2,.53,.2 M.58,.82 L.58,.66 C.63,.65,.65,.63,.65,.6 C.65,.58,.64,.57,.61,.56 C.58,.55,.56,.54,.52,.54 C.48,.54,.46,.55,.43,.56 C.4,.57,.39,.59,.39,.6 C.39,.63,.41,.64,.46,.66 L.46,.82 L.57,.82`,type:0},"":{d:`M0,0 L1,.5 L0,1`,type:0,rightPadding:2},"":{d:`M-1,-.5 L1,.5 L-1,1.5`,type:1,leftPadding:1,rightPadding:1},"":{d:`M1,0 L0,.5 L1,1`,type:0,leftPadding:2},"":{d:`M2,-.5 L0,.5 L2,1.5`,type:1,leftPadding:1,rightPadding:1},"":{d:`M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0`,type:0,rightPadding:1},"":{d:`M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0`,type:1,rightPadding:1},"":{d:`M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0`,type:0,leftPadding:1},"":{d:`M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0`,type:1,leftPadding:1},"":{d:`M-.5,-.5 L1.5,1.5 L-.5,1.5`,type:0},"":{d:`M-.5,-.5 L1.5,1.5`,type:1,leftPadding:1,rightPadding:1},"":{d:`M1.5,-.5 L-.5,1.5 L1.5,1.5`,type:0},"":{d:`M1.5,-.5 L-.5,1.5 L-.5,-.5`,type:0},"":{d:`M1.5,-.5 L-.5,1.5`,type:1,leftPadding:1,rightPadding:1},"":{d:`M-.5,-.5 L1.5,1.5 L1.5,-.5`,type:0}};pf[``]=pf[``],pf[``]=pf[``];function mf(e,t,n,r,i,a,o,s){let c=uf[t];if(c)return hf(e,c,n,r,i,a),!0;let l=df[t];if(l)return _f(e,l,n,r,i,a),!0;let u=ff[t];if(u)return vf(e,u,n,r,i,a,s),!0;let d=pf[t];return d?(yf(e,d,n,r,i,a,o,s),!0):!1}function hf(e,t,n,r,i,a){for(let o=0;o7&&parseInt(s.slice(7,9),16)||1;else if(s.startsWith(`rgba`))[u,d,f,p]=s.substring(5,s.length-1).split(`,`).map(e=>parseFloat(e));else throw Error(`Unexpected fillStyle color format "${s}" when drawing pattern glyph`);for(let e=0;ee.bezierCurveTo(t[0],t[1],t[2],t[3],t[4],t[5]),L:(e,t)=>e.lineTo(t[0],t[1]),M:(e,t)=>e.moveTo(t[0],t[1])};function Sf(e,t,n,r,i,a,o,s=0,c=0){let l=e.map(e=>parseFloat(e)||parseInt(e));if(l.length<2)throw Error(`Too few arguments for instruction`);for(let e=0;ei){r-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(r-t))}ms`),this._start();return}r=i}this.clear()}},Ef=class extends Tf{_requestCallback(e){return setTimeout(()=>e(this._createDeadline(16)))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){let t=performance.now()+e;return{timeRemaining:()=>Math.max(0,t-performance.now())}}},Df=class extends Tf{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}},Of=!wd&&`requestIdleCallback`in window?Df:Ef,kf=class e{constructor(){this.fg=0,this.bg=0,this.extended=new Af}static toColorRGB(e){return[e>>>16&255,e>>>8&255,e&255]}static fromColorRGB(e){return(e[0]&255)<<16|(e[1]&255)<<8|e[2]&255}clone(){let t=new e;return t.fg=this.fg,t.bg=this.bg,t.extended=this.extended.clone(),t}isInverse(){return this.fg&67108864}isBold(){return this.fg&134217728}isUnderline(){return this.hasExtendedAttrs()&&this.extended.underlineStyle!==0?1:this.fg&268435456}isBlink(){return this.fg&536870912}isInvisible(){return this.fg&1073741824}isItalic(){return this.bg&67108864}isDim(){return this.bg&134217728}isStrikethrough(){return this.fg&2147483648}isProtected(){return this.bg&536870912}isOverline(){return this.bg&1073741824}getFgColorMode(){return this.fg&50331648}getBgColorMode(){return this.bg&50331648}isFgRGB(){return(this.fg&50331648)==50331648}isBgRGB(){return(this.bg&50331648)==50331648}isFgPalette(){return(this.fg&50331648)==16777216||(this.fg&50331648)==33554432}isBgPalette(){return(this.bg&50331648)==16777216||(this.bg&50331648)==33554432}isFgDefault(){return(this.fg&50331648)==0}isBgDefault(){return(this.bg&50331648)==0}isAttributeDefault(){return this.fg===0&&this.bg===0}getFgColor(){switch(this.fg&50331648){case 16777216:case 33554432:return this.fg&255;case 50331648:return this.fg&16777215;default:return-1}}getBgColor(){switch(this.bg&50331648){case 16777216:case 33554432:return this.bg&255;case 50331648:return this.bg&16777215;default:return-1}}hasExtendedAttrs(){return this.bg&268435456}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(this.bg&268435456&&~this.extended.underlineColor)switch(this.extended.underlineColor&50331648){case 16777216:case 33554432:return this.extended.underlineColor&255;case 50331648:return this.extended.underlineColor&16777215;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return this.bg&268435456&&~this.extended.underlineColor?this.extended.underlineColor&50331648:this.getFgColorMode()}isUnderlineColorRGB(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==50331648:this.isFgRGB()}isUnderlineColorPalette(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==16777216||(this.extended.underlineColor&50331648)==33554432:this.isFgPalette()}isUnderlineColorDefault(){return this.bg&268435456&&~this.extended.underlineColor?(this.extended.underlineColor&50331648)==0:this.isFgDefault()}getUnderlineStyle(){return this.fg&268435456?this.bg&268435456?this.extended.underlineStyle:1:0}getUnderlineVariantOffset(){return this.extended.underlineVariantOffset}},Af=class e{constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}get ext(){return this._urlId?this._ext&-469762049|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(this._ext&469762048)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return this._ext&67108863}set underlineColor(e){this._ext&=-67108864,this._ext|=e&67108863}get urlId(){return this._urlId}set urlId(e){this._urlId=e}get underlineVariantOffset(){let e=(this._ext&3758096384)>>29;return e<0?e^4294967288:e}set underlineVariantOffset(e){this._ext&=536870911,this._ext|=e<<29&3758096384}clone(){return new e(this._ext,this._urlId)}isEmpty(){return this.underlineStyle===0&&this._urlId===0}},jf=class e{constructor(t){this.element=t,this.next=e.Undefined,this.prev=e.Undefined}};jf.Undefined=new jf(void 0);var Mf=globalThis.performance&&typeof globalThis.performance.now==`function`,Nf=class e{static create(t){return new e(t)}constructor(e){this._now=Mf&&e===!1?Date.now:globalThis.performance.now.bind(globalThis.performance),this._startTime=this._now(),this._stopTime=-1}stop(){this._stopTime=this._now()}reset(){this._startTime=this._now(),this._stopTime=-1}elapsed(){return this._stopTime===-1?this._now()-this._startTime:this._stopTime-this._startTime}},Pf=!1,Ff=!1,If=!1,Lf;(e=>{e.None=()=>Sd.None;function t(e){if(If){let{onDidAddListener:t}=e,n=Uf.create(),r=0;e.onDidAddListener=()=>{++r===2&&(console.warn(`snapshotted emitter LIKELY used public and SHOULD HAVE BEEN created with DisposableStore. snapshotted here`),n.print()),t?.()}}}function n(e,t){return f(e,()=>{},0,void 0,!0,void 0,t)}e.defer=n;function r(e){return(t,n=null,r)=>{let i=!1,a;return a=e(e=>{if(!i)return a?a.dispose():i=!0,t.call(n,e)},null,r),i&&a.dispose(),a}}e.once=r;function i(e,t,n){return u((n,r=null,i)=>e(e=>n.call(r,t(e)),null,i),n)}e.map=i;function a(e,t,n){return u((n,r=null,i)=>e(e=>{t(e),n.call(r,e)},null,i),n)}e.forEach=a;function o(e,t,n){return u((n,r=null,i)=>e(e=>t(e)&&n.call(r,e),null,i),n)}e.filter=o;function s(e){return e}e.signal=s;function c(...e){return(t,n=null,r)=>d(vd(...e.map(e=>e(e=>t.call(n,e)))),r)}e.any=c;function l(e,t,n,r){let a=n;return i(e,e=>(a=t(a,e),a),r)}e.reduce=l;function u(e,n){let r,i={onWillAddFirstListener(){r=e(a.fire,a)},onDidRemoveLastListener(){r?.dispose()}};n||t(i);let a=new Z(i);return n?.add(a),a.event}function d(e,t){return t instanceof Array?t.push(e):t&&t.add(e),e}function f(e,n,r=100,i=!1,a=!1,o,s){let c,l,u,d=0,f,p={leakWarningThreshold:o,onWillAddFirstListener(){c=e(e=>{d++,l=n(l,e),i&&!u&&(m.fire(l),l=void 0),f=()=>{let e=l;l=void 0,u=void 0,(!i||d>1)&&m.fire(e),d=0},typeof r==`number`?(clearTimeout(u),u=setTimeout(f,r)):u===void 0&&(u=0,queueMicrotask(f))})},onWillRemoveListener(){a&&d>0&&f?.()},onDidRemoveLastListener(){f=void 0,c.dispose()}};s||t(p);let m=new Z(p);return s?.add(m),m.event}e.debounce=f;function p(t,n=0,r){return e.debounce(t,(e,t)=>e?(e.push(t),e):[t],n,void 0,!0,void 0,r)}e.accumulate=p;function m(e,t=(e,t)=>e===t,n){let r=!0,i;return o(e,e=>{let n=r||!t(e,i);return r=!1,i=e,n},n)}e.latch=m;function h(t,n,r){return[e.filter(t,n,r),e.filter(t,e=>!n(e),r)]}e.split=h;function g(e,t=!1,n=[],r){let i=n.slice(),a=e(e=>{i?i.push(e):s.fire(e)});r&&r.add(a);let o=()=>{i?.forEach(e=>s.fire(e)),i=null},s=new Z({onWillAddFirstListener(){a||(a=e(e=>s.fire(e)),r&&r.add(a))},onDidAddFirstListener(){i&&(t?setTimeout(o):o())},onDidRemoveLastListener(){a&&a.dispose(),a=null}});return r&&r.add(s),s.event}e.buffer=g;function _(e,t){return(n,r,i)=>{let a=t(new y);return e(function(e){let t=a.evaluate(e);t!==v&&n.call(r,t)},void 0,i)}}e.chain=_;let v=Symbol(`HaltChainable`);class y{constructor(){this.steps=[]}map(e){return this.steps.push(e),this}forEach(e){return this.steps.push(t=>(e(t),t)),this}filter(e){return this.steps.push(t=>e(t)?t:v),this}reduce(e,t){let n=t;return this.steps.push(t=>(n=e(n,t),n)),this}latch(e=(e,t)=>e===t){let t=!0,n;return this.steps.push(r=>{let i=t||!e(r,n);return t=!1,n=r,i?r:v}),this}evaluate(e){for(let t of this.steps)if(e=t(e),e===v)break;return e}}function b(e,t,n=e=>e){let r=(...e)=>i.fire(n(...e)),i=new Z({onWillAddFirstListener:()=>e.on(t,r),onDidRemoveLastListener:()=>e.removeListener(t,r)});return i.event}e.fromNodeEventEmitter=b;function x(e,t,n=e=>e){let r=(...e)=>i.fire(n(...e)),i=new Z({onWillAddFirstListener:()=>e.addEventListener(t,r),onDidRemoveLastListener:()=>e.removeEventListener(t,r)});return i.event}e.fromDOMEventEmitter=x;function S(e){return new Promise(t=>r(e)(t))}e.toPromise=S;function ee(e){let t=new Z;return e.then(e=>{t.fire(e)},()=>{t.fire(void 0)}).finally(()=>{t.dispose()}),t.event}e.fromPromise=ee;function te(e,t){return e(e=>t.fire(e))}e.forward=te;function C(e,t,n){return t(n),e(e=>t(e))}e.runAndSubscribe=C;class w{constructor(e,n){this._observable=e,this._counter=0,this._hasChanged=!1;let r={onWillAddFirstListener:()=>{e.addObserver(this)},onDidRemoveLastListener:()=>{e.removeObserver(this)}};n||t(r),this.emitter=new Z(r),n&&n.add(this.emitter)}beginUpdate(e){this._counter++}handlePossibleChange(e){}handleChange(e,t){this._hasChanged=!0}endUpdate(e){this._counter--,this._counter===0&&(this._observable.reportChanges(),this._hasChanged&&(this._hasChanged=!1,this.emitter.fire(this._observable.get())))}}function T(e,t){return new w(e,t).emitter.event}e.fromObservable=T;function E(e){return(t,n,r)=>{let i=0,a=!1,o={beginUpdate(){i++},endUpdate(){i--,i===0&&(e.reportChanges(),a&&(a=!1,t.call(n)))},handlePossibleChange(){},handleChange(){a=!0}};e.addObserver(o),e.reportChanges();let s={dispose(){e.removeObserver(o)}};return r instanceof xd?r.add(s):Array.isArray(r)&&r.push(s),s}}e.fromObservableLight=E})(Lf||={});var Rf=class e{constructor(t){this.listenerCount=0,this.invocationCount=0,this.elapsedOverall=0,this.durations=[],this.name=`${t}_${e._idPool++}`,e.all.add(this)}start(e){this._stopWatch=new Nf,this.listenerCount=e}stop(){if(this._stopWatch){let e=this._stopWatch.elapsed();this.durations.push(e),this.elapsedOverall+=e,this.invocationCount+=1,this._stopWatch=void 0}}};Rf.all=new Set,Rf._idPool=0;var zf=Rf,Bf=-1,Vf=class e{constructor(t,n,r=(e._idPool++).toString(16).padStart(3,`0`)){this._errorHandler=t,this.threshold=n,this.name=r,this._warnCountdown=0}dispose(){this._stacks?.clear()}check(e,t){let n=this.threshold;if(n<=0||t{let t=this._stacks.get(e.value)||0;this._stacks.set(e.value,t-1)}}getMostFrequentStack(){if(!this._stacks)return;let e,t=0;for(let[n,r]of this._stacks)(!e||t{if(e instanceof qf)t(e);else for(let n=0;n{e.length!==0&&(console.warn(`[LEAKING LISTENERS] GC'ed these listeners that were NOT yet disposed:`),console.warn(e.join(` +`)),e.length=0)},3e3),Xf=new FinalizationRegistry(t=>{typeof t==`string`&&e.push(t)})}var Z=class{constructor(e){this._size=0,this._options=e,this._leakageMon=Bf>0||this._options?.leakWarningThreshold?new Hf(e?.onListenerError??Ju,this._options?.leakWarningThreshold??Bf):void 0,this._perfMon=this._options?._profName?new zf(this._options._profName):void 0,this._deliveryQueue=this._options?.deliveryQueue}dispose(){if(!this._disposed){if(this._disposed=!0,this._deliveryQueue?.current===this&&this._deliveryQueue.reset(),this._listeners){if(Ff){let e=this._listeners;queueMicrotask(()=>{Yf(e,e=>e.stack?.print())})}this._listeners=void 0,this._size=0}this._options?.onDidRemoveLastListener?.(),this._leakageMon?.dispose()}}get event(){return this._event??=(e,t,n)=>{if(this._leakageMon&&this._size>this._leakageMon.threshold**2){let e=`[${this._leakageMon.name}] REFUSES to accept new listeners because it exceeded its threshold by far (${this._size} vs ${this._leakageMon.threshold})`;console.warn(e);let t=this._leakageMon.getMostFrequentStack()??[`UNKNOWN stack`,-1],n=new Gf(`${e}. HINT: Stack shows most frequent listener (${t[1]}-times)`,t[0]);return(this._options?.onListenerError||Ju)(n),Sd.None}if(this._disposed)return Sd.None;t&&(e=e.bind(t));let r=new qf(e),i;this._leakageMon&&this._size>=Math.ceil(this._leakageMon.threshold*.2)&&(r.stack=Uf.create(),i=this._leakageMon.check(r.stack,this._size+1)),Ff&&(r.stack=Uf.create()),this._listeners?this._listeners instanceof qf?(this._deliveryQueue??=new Zf,this._listeners=[this._listeners,r]):this._listeners.push(r):(this._options?.onWillAddFirstListener?.(this),this._listeners=r,this._options?.onDidAddFirstListener?.(this)),this._size++;let a=yd(()=>{Xf?.unregister(a),i?.(),this._removeListener(r)});if(n instanceof xd?n.add(a):Array.isArray(n)&&n.push(a),Xf){let e=Error().stack.split(` +`).slice(2,3).join(` +`).trim(),t=/(file:|vscode-file:\/\/vscode-app)?(\/[^:]*:\d+:\d+)/.exec(e);Xf.register(a,t?.[2]??e,a)}return a},this._event}_removeListener(e){if(this._options?.onWillRemoveListener?.(this),!this._listeners)return;if(this._size===1){this._listeners=void 0,this._options?.onDidRemoveLastListener?.(this),this._size=0;return}let t=this._listeners,n=t.indexOf(e);if(n===-1)throw console.log(`disposed?`,this._disposed),console.log(`size?`,this._size),console.log(`arr?`,JSON.stringify(this._listeners)),Error(`Attempted to dispose unknown listener`);this._size--,t[n]=void 0;let r=this._deliveryQueue.current===this;if(this._size*Jf<=t.length){let e=0;for(let n=0;n0}},Zf=class{constructor(){this.i=-1,this.end=0}enqueue(e,t,n){this.i=0,this.end=n,this.current=e,this.value=t}reset(){this.i=this.end,this.current=void 0,this.value=void 0}},Qf={texturePage:0,texturePosition:{x:0,y:0},texturePositionClipSpace:{x:0,y:0},offset:{x:0,y:0},size:{x:0,y:0},sizeClipSpace:{x:0,y:0}},$f=2,ep,tp=class e{constructor(e,t,n){this._document=e,this._config=t,this._unicodeService=n,this._didWarmUp=!1,this._cacheMap=new wf,this._cacheMapCombined=new wf,this._pages=[],this._activePages=[],this._workBoundingBox={top:0,left:0,bottom:0,right:0},this._workAttributeData=new kf,this._textureSize=512,this._onAddTextureAtlasCanvas=new Z,this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event,this._onRemoveTextureAtlasCanvas=new Z,this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event,this._requestClearModel=!1,this._createNewPage(),this._tmpCanvas=ap(e,this._config.deviceCellWidth*4+$f*2,this._config.deviceCellHeight+$f*2),this._tmpCtx=X(this._tmpCanvas.getContext(`2d`,{alpha:this._config.allowTransparency,willReadFrequently:!0}))}get pages(){return this._pages}dispose(){this._tmpCanvas.remove();for(let e of this.pages)e.canvas.remove();this._onAddTextureAtlasCanvas.dispose()}warmUp(){this._didWarmUp||=(this._doWarmUp(),!0)}_doWarmUp(){let e=new Of;for(let t=33;t<126;t++)e.enqueue(()=>{if(!this._cacheMap.get(t,0,0,0)){let e=this._drawToCache(t,0,0,0,!1,void 0);this._cacheMap.set(t,0,0,0,e)}})}beginFrame(){return this._requestClearModel}clearTexture(){if(!(this._pages[0].currentRow.x===0&&this._pages[0].currentRow.y===0)){for(let e of this._pages)e.clear();this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1}}_createNewPage(){if(e.maxAtlasPages&&this._pages.length>=Math.max(4,e.maxAtlasPages)){let t=this._pages.filter(t=>t.canvas.width*2<=(e.maxTextureSize||4096)).sort((e,t)=>t.canvas.width===e.canvas.width?t.percentageUsed-e.percentageUsed:t.canvas.width-e.canvas.width),n=-1,r=0;for(let e=0;ee.glyphs[0].texturePage).sort((e,t)=>e>t?1:-1),o=this.pages.length-i.length,s=this._mergePages(i,o);s.version++;for(let e=a.length-1;e>=0;e--)this._deletePage(a[e]);this.pages.push(s),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(s.canvas)}let t=new np(this._document,this._textureSize);return this._pages.push(t),this._activePages.push(t),this._onAddTextureAtlasCanvas.fire(t.canvas),t}_mergePages(e,t){let n=e[0].canvas.width*2,r=new np(this._document,n,e);for(let[i,a]of e.entries()){let e=i*a.canvas.width%n,o=Math.floor(i/2)*a.canvas.height;r.ctx.drawImage(a.canvas,e,o);for(let r of a.glyphs)r.texturePage=t,r.sizeClipSpace.x=r.size.x/n,r.sizeClipSpace.y=r.size.y/n,r.texturePosition.x+=e,r.texturePosition.y+=o,r.texturePositionClipSpace.x=r.texturePosition.x/n,r.texturePositionClipSpace.y=r.texturePosition.y/n;this._onRemoveTextureAtlasCanvas.fire(a.canvas);let s=this._activePages.indexOf(a);s!==-1&&this._activePages.splice(s,1)}return r}_deletePage(e){this._pages.splice(e,1);for(let t=e;t=this._config.colors.ansi.length)throw Error(`No color found for idx `+e);return this._config.colors.ansi[e]}_getBackgroundColor(e,t,n,r){if(this._config.allowTransparency)return Fd;let i;switch(e){case 16777216:case 33554432:i=this._getColorFromAnsiIndex(t);break;case 50331648:let e=kf.toColorRGB(t);i=Id.toColor(e[0],e[1],e[2]);break;default:i=n?Ld.opaque(this._config.colors.foreground):this._config.colors.background;break}return this._config.allowTransparency||(i=Ld.opaque(i)),i}_getForegroundColor(e,t,n,r,i,a,o,s,c,l){let u=this._getMinimumContrastColor(e,t,n,r,i,a,o,c,s,l);if(u)return u;let d;switch(i){case 16777216:case 33554432:this._config.drawBoldTextInBrightColors&&c&&a<8&&(a+=8),d=this._getColorFromAnsiIndex(a);break;case 50331648:let e=kf.toColorRGB(a);d=Id.toColor(e[0],e[1],e[2]);break;default:d=o?this._config.colors.background:this._config.colors.foreground}return this._config.allowTransparency&&(d=Ld.opaque(d)),s&&(d=Ld.multiplyOpacity(d,cf)),d}_resolveBackgroundRgba(e,t,n){switch(e){case 16777216:case 33554432:return this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;default:return n?this._config.colors.foreground.rgba:this._config.colors.background.rgba}}_resolveForegroundRgba(e,t,n,r){switch(e){case 16777216:case 33554432:return this._config.drawBoldTextInBrightColors&&r&&t<8&&(t+=8),this._getColorFromAnsiIndex(t).rgba;case 50331648:return t<<8;default:return n?this._config.colors.background.rgba:this._config.colors.foreground.rgba}}_getMinimumContrastColor(e,t,n,r,i,a,o,s,c,l){if(this._config.minimumContrastRatio===1||l)return;let u=this._getContrastCache(c),d=u.getColor(e,r);if(d!==void 0)return d||void 0;let f=this._resolveBackgroundRgba(t,n,o),p=this._resolveForegroundRgba(i,a,o,s),m=Bd.ensureContrastRatio(f,p,this._config.minimumContrastRatio/(c?2:1));if(!m){u.setColor(e,r,null);return}let h=Id.toColor(m>>24&255,m>>16&255,m>>8&255);return u.setColor(e,r,h),h}_getContrastCache(e){return e?this._config.colors.halfContrastCache:this._config.colors.contrastCache}_drawToCache(t,n,r,i,a,o){let s=typeof t==`number`?String.fromCharCode(t):t;o&&this._tmpCanvas.parentElement!==o&&(this._tmpCanvas.style.display=`none`,o.append(this._tmpCanvas));let c=Math.min(this._config.deviceCellWidth*Math.max(s.length,2)+$f*2,this._config.deviceMaxTextureSize);this._tmpCanvas.width=e?e*2-c:e-c;c>=e||f===0?(this._tmpCtx.setLineDash([Math.round(e),Math.round(e)]),this._tmpCtx.moveTo(s+f,r),this._tmpCtx.lineTo(l,r)):(this._tmpCtx.setLineDash([Math.round(e),Math.round(e)]),this._tmpCtx.moveTo(s,r),this._tmpCtx.lineTo(s+f,r),this._tmpCtx.moveTo(s+f+e,r),this._tmpCtx.lineTo(l,r)),c=Qd(l-s,e,c);break;case 5:let p=l-s,m=Math.floor(.6*p),h=Math.floor(.3*p),g=p-m-h;this._tmpCtx.setLineDash([m,h,g]),this._tmpCtx.moveTo(s,r),this._tmpCtx.lineTo(l,r);break;default:this._tmpCtx.moveTo(s,r),this._tmpCtx.lineTo(l,r);break}this._tmpCtx.stroke(),this._tmpCtx.restore()}if(this._tmpCtx.restore(),!E&&this._config.fontSize>=12&&!this._config.allowTransparency&&s!==` `){this._tmpCtx.save(),this._tmpCtx.textBaseline=`alphabetic`;let t=this._tmpCtx.measureText(s);if(this._tmpCtx.restore(),`actualBoundingBoxDescent`in t&&t.actualBoundingBoxDescent>0){this._tmpCtx.save();let t=new Path2D;t.rect(n,r-Math.ceil(e/2),this._config.deviceCellWidth*ne,o-r+Math.ceil(e/2)),this._tmpCtx.clip(t),this._tmpCtx.lineWidth=this._config.devicePixelRatio*3,this._tmpCtx.strokeStyle=x.css,this._tmpCtx.strokeText(s,T,T+this._config.deviceCharHeight),this._tmpCtx.restore()}}}if(g){let e=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/15)),t=e%2==1?.5:0;this._tmpCtx.lineWidth=e,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(T,T+t),this._tmpCtx.lineTo(T+this._config.deviceCharWidth*ne,T+t),this._tmpCtx.stroke()}if(E||this._tmpCtx.fillText(s,T,T+this._config.deviceCharHeight),s===`_`&&!this._config.allowTransparency){let e=rp(this._tmpCtx.getImageData(T,T,this._config.deviceCellWidth,this._config.deviceCellHeight),x,w,D);if(e)for(let t=1;t<=5&&(this._tmpCtx.save(),this._tmpCtx.fillStyle=x.css,this._tmpCtx.fillRect(0,0,this._tmpCanvas.width,this._tmpCanvas.height),this._tmpCtx.restore(),this._tmpCtx.fillText(s,T,T+this._config.deviceCharHeight-t),e=rp(this._tmpCtx.getImageData(T,T,this._config.deviceCellWidth,this._config.deviceCellHeight),x,w,D),e);t++);}if(h){let e=Math.max(1,Math.floor(this._config.fontSize*this._config.devicePixelRatio/10)),t=this._tmpCtx.lineWidth%2==1?.5:0;this._tmpCtx.lineWidth=e,this._tmpCtx.strokeStyle=this._tmpCtx.fillStyle,this._tmpCtx.beginPath(),this._tmpCtx.moveTo(T,T+Math.floor(this._config.deviceCharHeight/2)-t),this._tmpCtx.lineTo(T+this._config.deviceCharWidth*ne,T+Math.floor(this._config.deviceCharHeight/2)-t),this._tmpCtx.stroke()}this._tmpCtx.restore();let O=this._tmpCtx.getImageData(0,0,this._tmpCanvas.width,this._tmpCanvas.height),re;if(re=this._config.allowTransparency?ip(O):rp(O,x,w,D),re)return Qf;let k=this._findGlyphBoundingBox(O,this._workBoundingBox,c,C,E,T),A,j;for(;;){if(this._activePages.length===0){let e=this._createNewPage();A=e,j=e.currentRow,j.height=k.size.y;break}A=this._activePages[this._activePages.length-1],j=A.currentRow;for(let e of this._activePages)k.size.y<=e.currentRow.height&&(A=e,j=e.currentRow);for(let e=this._activePages.length-1;e>=0;e--)for(let t of this._activePages[e].fixedRows)t.height<=j.height&&k.size.y<=t.height&&(A=this._activePages[e],j=t);if(k.size.x>this._textureSize){this._overflowSizePage||(this._overflowSizePage=new np(this._document,this._config.deviceMaxTextureSize),this.pages.push(this._overflowSizePage),this._requestClearModel=!0,this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas)),A=this._overflowSizePage,j=this._overflowSizePage.currentRow,j.x+k.size.x>=A.canvas.width&&(j.x=0,j.y+=j.height,j.height=0);break}if(j.y+k.size.y>=A.canvas.height||j.height>k.size.y+2){let t=!1;if(A.currentRow.y+A.currentRow.height+k.size.y>=A.canvas.height){let n;for(let e of this._activePages)if(e.currentRow.y+e.currentRow.height+k.size.y=e.maxAtlasPages&&j.y+k.size.y<=A.canvas.height&&j.height>=k.size.y&&j.x+k.size.x<=A.canvas.width)t=!0;else{let e=this._createNewPage();A=e,j=e.currentRow,j.height=k.size.y,t=!0}}t||(A.currentRow.height>0&&A.fixedRows.push(A.currentRow),j={x:0,y:A.currentRow.y+A.currentRow.height,height:k.size.y},A.fixedRows.push(j),A.currentRow={x:0,y:j.y+j.height,height:0})}if(j.x+k.size.x<=A.canvas.width)break;j===A.currentRow?(j.x=0,j.y+=j.height,j.height=0):A.fixedRows.splice(A.fixedRows.indexOf(j),1)}return k.texturePage=this._pages.indexOf(A),k.texturePosition.x=j.x,k.texturePosition.y=j.y,k.texturePositionClipSpace.x=j.x/A.canvas.width,k.texturePositionClipSpace.y=j.y/A.canvas.height,k.sizeClipSpace.x/=A.canvas.width,k.sizeClipSpace.y/=A.canvas.height,j.height=Math.max(j.height,k.size.y),j.x+=k.size.x,A.ctx.putImageData(O,k.texturePosition.x-this._workBoundingBox.left,k.texturePosition.y-this._workBoundingBox.top,this._workBoundingBox.left,this._workBoundingBox.top,k.size.x,k.size.y),A.addGlyph(k),A.version++,k}_findGlyphBoundingBox(e,t,n,r,i,a){t.top=0;let o=r?this._config.deviceCellHeight:this._tmpCanvas.height,s=r?this._config.deviceCellWidth:n,c=!1;for(let n=0;n=a;n--){for(let r=0;r=0;n--){for(let r=0;r>>24,a=t.rgba>>>16&255,o=t.rgba>>>8&255,s=n.rgba>>>24,c=n.rgba>>>16&255,l=n.rgba>>>8&255,u=Math.floor((Math.abs(i-s)+Math.abs(a-c)+Math.abs(o-l))/12),d=!0;for(let t=0;t0)return!1;return!0}function ap(e,t,n){let r=e.createElement(`canvas`);return r.width=t,r.height=n,r}function op(e,t,n,r,i,a,o,s){let c={foreground:a.foreground,background:a.background,cursor:Fd,cursorAccent:Fd,selectionForeground:Fd,selectionBackgroundTransparent:Fd,selectionBackgroundOpaque:Fd,selectionInactiveBackgroundTransparent:Fd,selectionInactiveBackgroundOpaque:Fd,overviewRulerBorder:Fd,scrollbarSliderBackground:Fd,scrollbarSliderHoverBackground:Fd,scrollbarSliderActiveBackground:Fd,ansi:a.ansi.slice(),contrastCache:a.contrastCache,halfContrastCache:a.halfContrastCache};return{customGlyphs:i.customGlyphs,devicePixelRatio:o,deviceMaxTextureSize:s,letterSpacing:i.letterSpacing,lineHeight:i.lineHeight,deviceCellWidth:e,deviceCellHeight:t,deviceCharWidth:n,deviceCharHeight:r,fontFamily:i.fontFamily,fontSize:i.fontSize,fontWeight:i.fontWeight,fontWeightBold:i.fontWeightBold,allowTransparency:i.allowTransparency,drawBoldTextInBrightColors:i.drawBoldTextInBrightColors,minimumContrastRatio:i.minimumContrastRatio,colors:c}}function sp(e,t){for(let n=0;n=0){if(sp(n.config,l))return n.atlas;n.ownedBy.length===1?(n.atlas.dispose(),lp.splice(t,1)):n.ownedBy.splice(r,1);break}}for(let t=0;t{this._renderCallback(),this._animationFrame=void 0}))}_restartInterval(e=fp){this._blinkInterval&&=(this._coreBrowserService.window.clearInterval(this._blinkInterval),void 0),this._blinkStartTimeout=this._coreBrowserService.window.setTimeout(()=>{if(this._animationTimeRestarted){let e=fp-(Date.now()-this._animationTimeRestarted);if(this._animationTimeRestarted=void 0,e>0){this._restartInterval(e);return}}this.isCursorVisible=!1,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0}),this._blinkInterval=this._coreBrowserService.window.setInterval(()=>{if(this._animationTimeRestarted){let e=fp-(Date.now()-this._animationTimeRestarted);this._animationTimeRestarted=void 0,this._restartInterval(e);return}this.isCursorVisible=!this.isCursorVisible,this._animationFrame=this._coreBrowserService.window.requestAnimationFrame(()=>{this._renderCallback(),this._animationFrame=void 0})},fp)},e)}pause(){this.isCursorVisible=!0,this._blinkInterval&&=(this._coreBrowserService.window.clearInterval(this._blinkInterval),void 0),this._blinkStartTimeout&&=(this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout),void 0),this._animationFrame&&=(this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame),void 0)}resume(){this.pause(),this._animationTimeRestarted=void 0,this._restartInterval(),this.restartBlinkAnimation()}};function mp(e,t,n){let r=new t.ResizeObserver(t=>{let i=t.find(t=>t.target===e);if(!i)return;if(!(`devicePixelContentBoxSize`in i)){r?.disconnect(),r=void 0;return}let a=i.devicePixelContentBoxSize[0].inlineSize,o=i.devicePixelContentBoxSize[0].blockSize;a>0&&o>0&&n(a,o)});try{r.observe(e,{box:[`device-pixel-content-box`]})}catch{r.disconnect(),r=void 0}return yd(()=>r?.disconnect())}function hp(e){return e>65535?(e-=65536,String.fromCharCode((e>>10)+55296)+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)}var gp=class e extends kf{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new Af,this.combinedData=``}static fromCharData(t){let n=new e;return n.setFromCharData(t),n}isCombined(){return this.content&2097152}getWidth(){return this.content>>22}getChars(){return this.content&2097152?this.combinedData:this.content&2097151?hp(this.content&2097151):``}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):this.content&2097151}setFromCharData(e){this.fg=e[0],this.bg=0;let t=!1;if(e[1].length>2)t=!0;else if(e[1].length===2){let n=e[1].charCodeAt(0);if(55296<=n&&n<=56319){let r=e[1].charCodeAt(1);56320<=r&&r<=57343?this.content=(n-55296)*1024+r-56320+65536|e[2]<<22:t=!0}else t=!0}else this.content=e[1].charCodeAt(0)|e[2]<<22;t&&(this.combinedData=e[1],this.content=2097152|e[2]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}},_p=new Float32Array([2,0,0,0,0,-2,0,0,0,0,1,0,-1,1,0,1]);function vp(e,t,n){let r=X(e.createProgram());if(e.attachShader(r,X(yp(e,e.VERTEX_SHADER,t))),e.attachShader(r,X(yp(e,e.FRAGMENT_SHADER,n))),e.linkProgram(r),e.getProgramParameter(r,e.LINK_STATUS))return r;console.error(e.getProgramInfoLog(r)),e.deleteProgram(r)}function yp(e,t,n){let r=X(e.createShader(t));if(e.shaderSource(r,n),e.compileShader(r),e.getShaderParameter(r,e.COMPILE_STATUS))return r;console.error(e.getShaderInfoLog(r)),e.deleteShader(r)}function bp(e,t){let n=Math.min(e.length*2,t),r=new Float32Array(n);for(let t=0;ti.deleteProgram(this._program))),this._projectionLocation=X(i.getUniformLocation(this._program,`u_projection`)),this._resolutionLocation=X(i.getUniformLocation(this._program,`u_resolution`)),this._textureLocation=X(i.getUniformLocation(this._program,`u_texture`)),this._vertexArrayObject=i.createVertexArray(),i.bindVertexArray(this._vertexArrayObject);let a=new Float32Array([0,0,1,0,0,1,1,1]),o=i.createBuffer();this._register(yd(()=>i.deleteBuffer(o))),i.bindBuffer(i.ARRAY_BUFFER,o),i.bufferData(i.ARRAY_BUFFER,a,i.STATIC_DRAW),i.enableVertexAttribArray(0),i.vertexAttribPointer(0,2,this._gl.FLOAT,!1,0,0);let s=new Uint8Array([0,1,2,3]),c=i.createBuffer();this._register(yd(()=>i.deleteBuffer(c))),i.bindBuffer(i.ELEMENT_ARRAY_BUFFER,c),i.bufferData(i.ELEMENT_ARRAY_BUFFER,s,i.STATIC_DRAW),this._attributesBuffer=X(i.createBuffer()),this._register(yd(()=>i.deleteBuffer(this._attributesBuffer))),i.bindBuffer(i.ARRAY_BUFFER,this._attributesBuffer),i.enableVertexAttribArray(2),i.vertexAttribPointer(2,2,i.FLOAT,!1,Tp,0),i.vertexAttribDivisor(2,1),i.enableVertexAttribArray(3),i.vertexAttribPointer(3,2,i.FLOAT,!1,Tp,2*Float32Array.BYTES_PER_ELEMENT),i.vertexAttribDivisor(3,1),i.enableVertexAttribArray(4),i.vertexAttribPointer(4,1,i.FLOAT,!1,Tp,4*Float32Array.BYTES_PER_ELEMENT),i.vertexAttribDivisor(4,1),i.enableVertexAttribArray(5),i.vertexAttribPointer(5,2,i.FLOAT,!1,Tp,5*Float32Array.BYTES_PER_ELEMENT),i.vertexAttribDivisor(5,1),i.enableVertexAttribArray(6),i.vertexAttribPointer(6,2,i.FLOAT,!1,Tp,7*Float32Array.BYTES_PER_ELEMENT),i.vertexAttribDivisor(6,1),i.enableVertexAttribArray(1),i.vertexAttribPointer(1,2,i.FLOAT,!1,Tp,9*Float32Array.BYTES_PER_ELEMENT),i.vertexAttribDivisor(1,1),i.useProgram(this._program);let l=new Int32Array(tp.maxAtlasPages);for(let e=0;ei.deleteTexture(t.texture))),i.activeTexture(i.TEXTURE0+e),i.bindTexture(i.TEXTURE_2D,t.texture),i.texParameteri(i.TEXTURE_2D,i.TEXTURE_WRAP_S,i.CLAMP_TO_EDGE),i.texParameteri(i.TEXTURE_2D,i.TEXTURE_WRAP_T,i.CLAMP_TO_EDGE),i.texImage2D(i.TEXTURE_2D,0,i.RGBA,1,1,0,i.RGBA,i.UNSIGNED_BYTE,new Uint8Array([255,0,0,255])),this._atlasTextures[e]=t}i.enable(i.BLEND),i.blendFunc(i.SRC_ALPHA,i.ONE_MINUS_SRC_ALPHA),this.handleResize()}beginFrame(){return this._atlas?this._atlas.beginFrame():!0}updateCell(e,t,n,r,i,a,o,s,c){this._updateCell(this._vertices.attributes,e,t,n,r,i,a,o,s,c)}_updateCell(e,t,n,r,i,a,o,s,c,l){if(Q=(n*this._terminal.cols+t)*wp,r===0||r===void 0){e.fill(0,Q,Q+wp-1-Ep);return}this._atlas&&($=s&&s.length>1?this._atlas.getRasterizedGlyphCombinedChar(s,i,a,o,!1,this._terminal.element):this._atlas.getRasterizedGlyph(r,i,a,o,!1,this._terminal.element),Dp=Math.floor((this._dimensions.device.cell.width-this._dimensions.device.char.width)/2),i!==l&&$.offset.x>Dp?(Op=$.offset.x-Dp,e[Q]=-($.offset.x-Op)+this._dimensions.device.char.left,e[Q+1]=-$.offset.y+this._dimensions.device.char.top,e[Q+2]=($.size.x-Op)/this._dimensions.device.canvas.width,e[Q+3]=$.size.y/this._dimensions.device.canvas.height,e[Q+4]=$.texturePage,e[Q+5]=$.texturePositionClipSpace.x+Op/this._atlas.pages[$.texturePage].canvas.width,e[Q+6]=$.texturePositionClipSpace.y,e[Q+7]=$.sizeClipSpace.x-Op/this._atlas.pages[$.texturePage].canvas.width,e[Q+8]=$.sizeClipSpace.y):(e[Q]=-$.offset.x+this._dimensions.device.char.left,e[Q+1]=-$.offset.y+this._dimensions.device.char.top,e[Q+2]=$.size.x/this._dimensions.device.canvas.width,e[Q+3]=$.size.y/this._dimensions.device.canvas.height,e[Q+4]=$.texturePage,e[Q+5]=$.texturePositionClipSpace.x,e[Q+6]=$.texturePositionClipSpace.y,e[Q+7]=$.sizeClipSpace.x,e[Q+8]=$.sizeClipSpace.y),this._optionsService.rawOptions.rescaleOverlappingGlyphs&&Jd(r,c,$.size.x,this._dimensions.device.cell.width)&&(e[Q+2]=(this._dimensions.device.cell.width-1)/this._dimensions.device.canvas.width))}clear(){let e=this._terminal,t=e.cols*e.rows*wp;this._vertices.count===t?this._vertices.attributes.fill(0):this._vertices.attributes=new Float32Array(t);let n=0;for(;n=e.rows||c<0){this.clear();return}this.hasSelection=!0,this.columnSelectMode=r,this.viewportStartRow=a,this.viewportEndRow=o,this.viewportCappedStartRow=s,this.viewportCappedEndRow=c,this.startCol=t[0],this.endCol=n[0]}isCellSelected(e,t,n){return this.hasSelection?(n-=e.buffer.active.viewportY,this.columnSelectMode?this.startCol<=this.endCol?t>=this.startCol&&n>=this.viewportCappedStartRow&&t=this.viewportCappedStartRow&&t>=this.endCol&&n<=this.viewportCappedEndRow:n>this.viewportStartRow&&n=this.startCol&&t=this.startCol):!1}};function jp(){return new Ap}var Mp=4,Np=1,Pp=2,Fp=3,Ip=2147483648,Lp=class{constructor(){this.cells=new Uint32Array,this.lineLengths=new Uint32Array,this.selection=jp()}resize(e,t){let n=e*t*Mp;n!==this.cells.length&&(this.cells=new Uint32Array(n),this.lineLengths=new Uint32Array(t))}clear(){this.cells.fill(0,0),this.lineLengths.fill(0,0)}},Rp=`#version 300 es +layout (location = 0) in vec2 a_position; +layout (location = 1) in vec2 a_size; +layout (location = 2) in vec4 a_color; +layout (location = 3) in vec2 a_unitquad; + +uniform mat4 u_projection; + +out vec4 v_color; + +void main() { + vec2 zeroToOne = a_position + (a_unitquad * a_size); + gl_Position = u_projection * vec4(zeroToOne, 0.0, 1.0); + v_color = a_color; +}`,zp=`#version 300 es +precision lowp float; + +in vec4 v_color; + +out vec4 outColor; + +void main() { + outColor = v_color; +}`,Bp=8,Vp=Bp*Float32Array.BYTES_PER_ELEMENT,Hp=20*Bp,Up=class{constructor(){this.attributes=new Float32Array(Hp),this.count=0}},Wp=0,Gp=0,Kp=0,qp=0,Jp=0,Yp=0,Xp=0,Zp=class extends Sd{constructor(e,t,n,r){super(),this._terminal=e,this._gl=t,this._dimensions=n,this._themeService=r,this._vertices=new Up,this._verticesCursor=new Up;let i=this._gl;this._program=X(vp(i,Rp,zp)),this._register(yd(()=>i.deleteProgram(this._program))),this._projectionLocation=X(i.getUniformLocation(this._program,`u_projection`)),this._vertexArrayObject=i.createVertexArray(),i.bindVertexArray(this._vertexArrayObject);let a=new Float32Array([0,0,1,0,0,1,1,1]),o=i.createBuffer();this._register(yd(()=>i.deleteBuffer(o))),i.bindBuffer(i.ARRAY_BUFFER,o),i.bufferData(i.ARRAY_BUFFER,a,i.STATIC_DRAW),i.enableVertexAttribArray(3),i.vertexAttribPointer(3,2,this._gl.FLOAT,!1,0,0);let s=new Uint8Array([0,1,2,3]),c=i.createBuffer();this._register(yd(()=>i.deleteBuffer(c))),i.bindBuffer(i.ELEMENT_ARRAY_BUFFER,c),i.bufferData(i.ELEMENT_ARRAY_BUFFER,s,i.STATIC_DRAW),this._attributesBuffer=X(i.createBuffer()),this._register(yd(()=>i.deleteBuffer(this._attributesBuffer))),i.bindBuffer(i.ARRAY_BUFFER,this._attributesBuffer),i.enableVertexAttribArray(0),i.vertexAttribPointer(0,2,i.FLOAT,!1,Vp,0),i.vertexAttribDivisor(0,1),i.enableVertexAttribArray(1),i.vertexAttribPointer(1,2,i.FLOAT,!1,Vp,2*Float32Array.BYTES_PER_ELEMENT),i.vertexAttribDivisor(1,1),i.enableVertexAttribArray(2),i.vertexAttribPointer(2,4,i.FLOAT,!1,Vp,4*Float32Array.BYTES_PER_ELEMENT),i.vertexAttribDivisor(2,1),this._updateCachedColors(r.colors),this._register(this._themeService.onChangeColors(e=>{this._updateCachedColors(e),this._updateViewportRectangle()}))}renderBackgrounds(){this._renderVertices(this._vertices)}renderCursor(){this._renderVertices(this._verticesCursor)}_renderVertices(e){let t=this._gl;t.useProgram(this._program),t.bindVertexArray(this._vertexArrayObject),t.uniformMatrix4fv(this._projectionLocation,!1,_p),t.bindBuffer(t.ARRAY_BUFFER,this._attributesBuffer),t.bufferData(t.ARRAY_BUFFER,e.attributes,t.DYNAMIC_DRAW),t.drawElementsInstanced(this._gl.TRIANGLE_STRIP,4,t.UNSIGNED_BYTE,0,e.count)}handleResize(){this._updateViewportRectangle()}setDimensions(e){this._dimensions=e}_updateCachedColors(e){this._bgFloat=this._colorToFloat32Array(e.background),this._cursorFloat=this._colorToFloat32Array(e.cursor)}_updateViewportRectangle(){this._addRectangleFloat(this._vertices.attributes,0,0,0,this._terminal.cols*this._dimensions.device.cell.width,this._terminal.rows*this._dimensions.device.cell.height,this._bgFloat)}updateBackgrounds(e){let t=this._terminal,n=this._vertices,r=1,i,a,o,s,c,l,u,d,f,p,m;for(i=0;i>24&255)/255,Jp=(Wp>>16&255)/255,Yp=(Wp>>8&255)/255,Xp=1,this._addRectangle(e.attributes,t,Gp,Kp,(a-i)*this._dimensions.device.cell.width,this._dimensions.device.cell.height,qp,Jp,Yp,Xp)}_addRectangle(e,t,n,r,i,a,o,s,c,l){e[t]=n/this._dimensions.device.canvas.width,e[t+1]=r/this._dimensions.device.canvas.height,e[t+2]=i/this._dimensions.device.canvas.width,e[t+3]=a/this._dimensions.device.canvas.height,e[t+4]=o,e[t+5]=s,e[t+6]=c,e[t+7]=l}_addRectangleFloat(e,t,n,r,i,a,o){e[t]=n/this._dimensions.device.canvas.width,e[t+1]=r/this._dimensions.device.canvas.height,e[t+2]=i/this._dimensions.device.canvas.width,e[t+3]=a/this._dimensions.device.canvas.height,e[t+4]=o[0],e[t+5]=o[1],e[t+6]=o[2],e[t+7]=o[3]}_colorToFloat32Array(e){return new Float32Array([(e.rgba>>24&255)/255,(e.rgba>>16&255)/255,(e.rgba>>8&255)/255,(e.rgba&255)/255])}},Qp=class extends Sd{constructor(e,t,n,r,i,a,o,s){super(),this._container=t,this._alpha=i,this._coreBrowserService=a,this._optionsService=o,this._themeService=s,this._deviceCharWidth=0,this._deviceCharHeight=0,this._deviceCellWidth=0,this._deviceCellHeight=0,this._deviceCharLeft=0,this._deviceCharTop=0,this._canvas=this._coreBrowserService.mainDocument.createElement(`canvas`),this._canvas.classList.add(`xterm-${n}-layer`),this._canvas.style.zIndex=r.toString(),this._initCanvas(),this._container.appendChild(this._canvas),this._register(this._themeService.onChangeColors(t=>{this._refreshCharAtlas(e,t),this.reset(e)})),this._register(yd(()=>{this._canvas.remove()}))}_initCanvas(){this._ctx=X(this._canvas.getContext(`2d`,{alpha:this._alpha})),this._alpha||this._clearAll()}handleBlur(e){}handleFocus(e){}handleCursorMove(e){}handleGridChanged(e,t,n){}handleSelectionChanged(e,t,n,r=!1){}_setTransparency(e,t){if(t===this._alpha)return;let n=this._canvas;this._alpha=t,this._canvas=this._canvas.cloneNode(),this._initCanvas(),this._container.replaceChild(this._canvas,n),this._refreshCharAtlas(e,this._themeService.colors),this.handleGridChanged(e,0,e.rows-1)}_refreshCharAtlas(e,t){this._deviceCharWidth<=0&&this._deviceCharHeight<=0||(this._charAtlas=up(e,this._optionsService.rawOptions,t,this._deviceCellWidth,this._deviceCellHeight,this._deviceCharWidth,this._deviceCharHeight,this._coreBrowserService.dpr,2048),this._charAtlas.warmUp())}resize(e,t){this._deviceCellWidth=t.device.cell.width,this._deviceCellHeight=t.device.cell.height,this._deviceCharWidth=t.device.char.width,this._deviceCharHeight=t.device.char.height,this._deviceCharLeft=t.device.char.left,this._deviceCharTop=t.device.char.top,this._canvas.width=t.device.canvas.width,this._canvas.height=t.device.canvas.height,this._canvas.style.width=`${t.css.canvas.width}px`,this._canvas.style.height=`${t.css.canvas.height}px`,this._alpha||this._clearAll(),this._refreshCharAtlas(e,this._themeService.colors)}_fillBottomLineAtCells(e,t,n=1){this._ctx.fillRect(e*this._deviceCellWidth,(t+1)*this._deviceCellHeight-this._coreBrowserService.dpr-1,n*this._deviceCellWidth,this._coreBrowserService.dpr)}_clearAll(){this._alpha?this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(0,0,this._canvas.width,this._canvas.height))}_clearCells(e,t,n,r){this._alpha?this._ctx.clearRect(e*this._deviceCellWidth,t*this._deviceCellHeight,n*this._deviceCellWidth,r*this._deviceCellHeight):(this._ctx.fillStyle=this._themeService.colors.background.css,this._ctx.fillRect(e*this._deviceCellWidth,t*this._deviceCellHeight,n*this._deviceCellWidth,r*this._deviceCellHeight))}_fillCharTrueColor(e,t,n,r){this._ctx.font=this._getFont(e,!1,!1),this._ctx.textBaseline=lf,this._clipCell(n,r,t.getWidth()),this._ctx.fillText(t.getChars(),n*this._deviceCellWidth+this._deviceCharLeft,r*this._deviceCellHeight+this._deviceCharTop+this._deviceCharHeight)}_clipCell(e,t,n){this._ctx.beginPath(),this._ctx.rect(e*this._deviceCellWidth,t*this._deviceCellHeight,n*this._deviceCellWidth,this._deviceCellHeight),this._ctx.clip()}_getFont(e,t,n){let r=t?e.options.fontWeightBold:e.options.fontWeight;return`${n?`italic`:``} ${r} ${e.options.fontSize*this._coreBrowserService.dpr}px ${e.options.fontFamily}`}},$p=class extends Qp{constructor(e,t,n,r,i,a,o){super(n,e,`link`,t,!0,i,a,o),this._register(r.onShowLinkUnderline(e=>this._handleShowLinkUnderline(e))),this._register(r.onHideLinkUnderline(e=>this._handleHideLinkUnderline(e)))}resize(e,t){super.resize(e,t),this._state=void 0}reset(e){this._clearCurrentLink()}_clearCurrentLink(){if(this._state){this._clearCells(this._state.x1,this._state.y1,this._state.cols-this._state.x1,1);let e=this._state.y2-this._state.y1-1;e>0&&this._clearCells(0,this._state.y1+1,this._state.cols,e),this._clearCells(0,this._state.y2,this._state.x2,1),this._state=void 0}}_handleShowLinkUnderline(e){if(e.fg===257?this._ctx.fillStyle=this._themeService.colors.background.css:e.fg!==void 0&&cp(e.fg)?this._ctx.fillStyle=this._themeService.colors.ansi[e.fg].css:this._ctx.fillStyle=this._themeService.colors.foreground.css,e.y1===e.y2)this._fillBottomLineAtCells(e.x1,e.y1,e.x2-e.x1);else{this._fillBottomLineAtCells(e.x1,e.y1,e.cols-e.x1);for(let t=e.y1+1;t=0;!(im.indexOf(`Chrome`)>=0)&&im.indexOf(`Safari`),im.indexOf(`Electron/`),im.indexOf(`Android`);var om=!1;if(typeof em.matchMedia==`function`){let e=em.matchMedia(`(display-mode: standalone) or (display-mode: window-controls-overlay)`),t=em.matchMedia(`(display-mode: fullscreen)`);om=e.matches,rm(em,e,({matches:e})=>{om&&t.matches||(om=e)})}function sm(){return om}var cm=`en`,lm=!1,um=!1,dm=!1,fm=cm,pm,mm=globalThis,hm;typeof mm.vscode<`u`&&typeof mm.vscode.process<`u`?hm=mm.vscode.process:typeof process<`u`&&typeof process?.versions?.node==`string`&&(hm=process);var gm=typeof hm?.versions?.electron==`string`&&hm?.type===`renderer`;if(typeof hm==`object`){hm.platform,hm.platform,lm=hm.platform===`linux`,lm&&hm.env.SNAP&&hm.env.SNAP_REVISION,hm.env.CI||hm.env.BUILD_ARTIFACTSTAGINGDIRECTORY,fm=cm;let e=hm.env.VSCODE_NLS_CONFIG;if(e)try{let t=JSON.parse(e);t.userLocale,t.osLocale,fm=t.resolvedLanguage||cm,t.languagePack?.translationsConfigFile}catch{}um=!0}else typeof navigator==`object`&&!gm?(pm=navigator.userAgent,pm.indexOf(`Windows`),pm.indexOf(`Macintosh`),(pm.indexOf(`Macintosh`)>=0||pm.indexOf(`iPad`)>=0||pm.indexOf(`iPhone`)>=0)&&navigator.maxTouchPoints&&navigator.maxTouchPoints,lm=pm.indexOf(`Linux`)>=0,pm?.indexOf(`Mobi`),dm=!0,fm=globalThis._VSCODE_NLS_LANGUAGE||cm,navigator.language.toLowerCase()):console.error(`Unable to resolve platform.`);var _m=um;dm&&typeof mm.importScripts==`function`&&mm.origin;var vm=pm,ym=fm,bm;(e=>{function t(){return ym}e.value=t;function n(){return ym.length===2?ym===`en`:ym.length>=3?ym[0]===`e`&&ym[1]===`n`&&ym[2]===`-`:!1}e.isDefaultVariant=n;function r(){return ym===`en`}e.isDefault=r})(bm||={});var xm=typeof mm.postMessage==`function`&&!mm.importScripts;(()=>{if(xm){let e=[];mm.addEventListener(`message`,t=>{if(t.data&&t.data.vscodeScheduleAsyncWork)for(let n=0,r=e.length;n{let r=++t;e.push({id:r,callback:n}),mm.postMessage({vscodeScheduleAsyncWork:r},`*`)}}return e=>setTimeout(e)})();var Sm=!!(vm&&vm.indexOf(`Chrome`)>=0);vm&&vm.indexOf(`Firefox`),!Sm&&vm&&vm.indexOf(`Safari`),vm&&vm.indexOf(`Edg/`),vm&&vm.indexOf(`Android`);var Cm=typeof navigator==`object`?navigator:{};_m||document.queryCommandSupported&&document.queryCommandSupported(`copy`)||Cm&&Cm.clipboard&&Cm.clipboard.writeText,_m||Cm&&Cm.clipboard&&Cm.clipboard.readText,_m||sm()||Cm.keyboard,`ontouchstart`in em||Cm.maxTouchPoints,em.PointerEvent&&(`ontouchstart`in em||navigator.maxTouchPoints);var wm=class{constructor(){this._keyCodeToStr=[],this._strToKeyCode=Object.create(null)}define(e,t){this._keyCodeToStr[e]=t,this._strToKeyCode[t.toLowerCase()]=e}keyCodeToStr(e){return this._keyCodeToStr[e]}strToKeyCode(e){return this._strToKeyCode[e.toLowerCase()]||0}},Tm=new wm,Em=new wm,Dm=new wm;Array(230);var Om;(e=>{function t(e){return Tm.keyCodeToStr(e)}e.toString=t;function n(e){return Tm.strToKeyCode(e)}e.fromString=n;function r(e){return Em.keyCodeToStr(e)}e.toUserSettingsUS=r;function i(e){return Dm.keyCodeToStr(e)}e.toUserSettingsGeneral=i;function a(e){return Em.strToKeyCode(e)||Dm.strToKeyCode(e)}e.fromUserSettings=a;function o(e){if(e>=98&&e<=113)return null;switch(e){case 16:return`Up`;case 18:return`Down`;case 15:return`Left`;case 17:return`Right`}return Tm.keyCodeToStr(e)}e.toElectronAccelerator=o})(Om||={});var km=Object.freeze(function(e,t){let n=setTimeout(e.bind(t),0);return{dispose(){clearTimeout(n)}}}),Am;(e=>{function t(t){return t===e.None||t===e.Cancelled||t instanceof jm?!0:!t||typeof t!=`object`?!1:typeof t.isCancellationRequested==`boolean`&&typeof t.onCancellationRequested==`function`}e.isCancellationToken=t,e.None=Object.freeze({isCancellationRequested:!1,onCancellationRequested:Lf.None}),e.Cancelled=Object.freeze({isCancellationRequested:!0,onCancellationRequested:km})})(Am||={});var jm=class{constructor(){this._isCancelled=!1,this._emitter=null}cancel(){this._isCancelled||(this._isCancelled=!0,this._emitter&&(this._emitter.fire(void 0),this.dispose()))}get isCancellationRequested(){return this._isCancelled}get onCancellationRequested(){return this._isCancelled?km:(this._emitter||=new Z,this._emitter.event)}dispose(){this._emitter&&=(this._emitter.dispose(),null)}};(function(){typeof globalThis.requestIdleCallback!=`function`||globalThis.cancelIdleCallback})();var Mm;(e=>{async function t(e){let t,n=await Promise.all(e.map(e=>e.then(e=>e,e=>{t||=e})));if(typeof t<`u`)throw t;return n}e.settled=t;function n(e){return new Promise(async(t,n)=>{try{await e(t,n)}catch(e){n(e)}})}e.withAsyncBody=n})(Mm||={});var Nm=class e{static fromArray(t){return new e(e=>{e.emitMany(t)})}static fromPromise(t){return new e(async e=>{e.emitMany(await t)})}static fromPromises(t){return new e(async e=>{await Promise.all(t.map(async t=>e.emitOne(await t)))})}static merge(t){return new e(async e=>{await Promise.all(t.map(async t=>{for await(let n of t)e.emitOne(n)}))})}constructor(e,t){this._state=0,this._results=[],this._error=null,this._onReturn=t,this._onStateChanged=new Z,queueMicrotask(async()=>{let t={emitOne:e=>this.emitOne(e),emitMany:e=>this.emitMany(e),reject:e=>this.reject(e)};try{await Promise.resolve(e(t)),this.resolve()}catch(e){this.reject(e)}finally{t.emitOne=void 0,t.emitMany=void 0,t.reject=void 0}})}[Symbol.asyncIterator](){let e=0;return{next:async()=>{do{if(this._state===2)throw this._error;if(e(this._onReturn?.(),{done:!0,value:void 0})}}static map(t,n){return new e(async e=>{for await(let r of t)e.emitOne(n(r))})}map(t){return e.map(this,t)}static filter(t,n){return new e(async e=>{for await(let r of t)n(r)&&e.emitOne(r)})}filter(t){return e.filter(this,t)}static coalesce(t){return e.filter(t,e=>!!e)}coalesce(){return e.coalesce(this)}static async toPromise(e){let t=[];for await(let n of e)t.push(n);return t}toPromise(){return e.toPromise(this)}emitOne(e){this._state===0&&(this._results.push(e),this._onStateChanged.fire())}emitMany(e){this._state===0&&(this._results=this._results.concat(e),this._onStateChanged.fire())}resolve(){this._state===0&&(this._state=1,this._onStateChanged.fire())}reject(e){this._state===0&&(this._state=2,this._error=e,this._onStateChanged.fire())}};Nm.EMPTY=Nm.fromArray([]);function Pm(e){return 55296<=e&&e<=56319}function Fm(e){return 56320<=e&&e<=57343}function Im(e,t){return(e-55296<<10)+(t-56320)+65536}function Lm(e){return Rm(e,0)}function Rm(e,t){switch(typeof e){case`object`:return e===null?zm(349,t):Array.isArray(e)?Hm(e,t):Um(e,t);case`string`:return Vm(e,t);case`boolean`:return Bm(e,t);case`number`:return zm(e,t);case`undefined`:return zm(937,t);default:return zm(617,t)}}function zm(e,t){return(t<<5)-t+e|0}function Bm(e,t){return zm(e?433:863,t)}function Vm(e,t){t=zm(149417,t);for(let n=0,r=e.length;nRm(t,e),t)}function Um(e,t){return t=zm(181387,t),Object.keys(e).sort().reduce((t,n)=>(t=Vm(n,t),Rm(e[n],t)),t)}function Wm(e,t,n=32){let r=n-t,i=~((1<>>r)>>>0}function Gm(e,t=0,n=e.byteLength,r=0){for(let i=0;ie.toString(16).padStart(2,`0`)).join(``):Km((e>>>0).toString(16),t/4)}var Jm=class e{constructor(){this._h0=1732584193,this._h1=4023233417,this._h2=2562383102,this._h3=271733878,this._h4=3285377520,this._buff=new Uint8Array(67),this._buffDV=new DataView(this._buff.buffer),this._buffLen=0,this._totalLen=0,this._leftoverHighSurrogate=0,this._finished=!1}update(e){let t=e.length;if(t===0)return;let n=this._buff,r=this._buffLen,i=this._leftoverHighSurrogate,a,o;for(i===0?(a=e.charCodeAt(0),o=0):(a=i,o=-1,i=0);;){let s=a;if(Pm(a))if(o+1>>6,e[t++]=128|(n&63)>>>0):n<65536?(e[t++]=224|(n&61440)>>>12,e[t++]=128|(n&4032)>>>6,e[t++]=128|(n&63)>>>0):(e[t++]=240|(n&1835008)>>>18,e[t++]=128|(n&258048)>>>12,e[t++]=128|(n&4032)>>>6,e[t++]=128|(n&63)>>>0),t>=64&&(this._step(),t-=64,this._totalLen+=64,e[0]=e[64],e[1]=e[65],e[2]=e[66]),t}digest(){return this._finished||(this._finished=!0,this._leftoverHighSurrogate&&(this._leftoverHighSurrogate=0,this._buffLen=this._push(this._buff,this._buffLen,65533)),this._totalLen+=this._buffLen,this._wrapUp()),qm(this._h0)+qm(this._h1)+qm(this._h2)+qm(this._h3)+qm(this._h4)}_wrapUp(){this._buff[this._buffLen++]=128,Gm(this._buff,this._buffLen),this._buffLen>56&&(this._step(),Gm(this._buff));let e=8*this._totalLen;this._buffDV.setUint32(56,Math.floor(e/4294967296),!1),this._buffDV.setUint32(60,e%4294967296,!1),this._step()}_step(){let t=e._bigBlock32,n=this._buffDV;for(let e=0;e<64;e+=4)t.setUint32(e,n.getUint32(e,!1),!1);for(let e=64;e<320;e+=4)t.setUint32(e,Wm(t.getUint32(e-12,!1)^t.getUint32(e-32,!1)^t.getUint32(e-56,!1)^t.getUint32(e-64,!1),1),!1);let r=this._h0,i=this._h1,a=this._h2,o=this._h3,s=this._h4,c,l,u;for(let e=0;e<80;e++)e<20?(c=i&a|~i&o,l=1518500249):e<40?(c=i^a^o,l=1859775393):e<60?(c=i&a|i&o|a&o,l=2400959708):(c=i^a^o,l=3395469782),u=Wm(r,5)+c+s+l+t.getUint32(e*4,!1)&4294967295,s=o,o=a,a=Wm(i,30),i=r,r=u;this._h0=this._h0+r&4294967295,this._h1=this._h1+i&4294967295,this._h2=this._h2+a&4294967295,this._h3=this._h3+o&4294967295,this._h4=this._h4+s&4294967295}};Jm._bigBlock32=new DataView(new ArrayBuffer(320));var{registerWindow:Ym,getWindow:Xm,getDocument:Zm,getWindows:Qm,getWindowsCount:$m,getWindowId:eh,getWindowById:th,hasWindow:nh,onDidRegisterWindow:rh,onWillUnregisterWindow:ih,onDidUnregisterWindow:ah}=function(){let e=new Map,t={window:em,disposables:new xd};e.set(em.vscodeWindowId,t);let n=new Z,r=new Z,i=new Z;function a(n,r){return(typeof n==`number`?e.get(n):void 0)??(r?t:void 0)}return{onDidRegisterWindow:n.event,onWillUnregisterWindow:i.event,onDidUnregisterWindow:r.event,registerWindow(t){if(e.has(t.vscodeWindowId))return Sd.None;let a=new xd,o={window:t,disposables:a.add(new xd)};return e.set(t.vscodeWindowId,o),a.add(yd(()=>{e.delete(t.vscodeWindowId),r.fire(t)})),a.add(sh(t,lh.BEFORE_UNLOAD,()=>{i.fire(t)})),n.fire(o),a},getWindows(){return e.values()},getWindowsCount(){return e.size},getWindowId(e){return e.vscodeWindowId},hasWindow(t){return e.has(t)},getWindowById:a,getWindow(e){let t=e;if(t?.ownerDocument?.defaultView)return t.ownerDocument.defaultView.window;let n=e;return n?.view?n.view.window:em},getDocument(e){return Xm(e).document}}}(),oh=class{constructor(e,t,n,r){this._node=e,this._type=t,this._handler=n,this._options=r||!1,this._node.addEventListener(this._type,this._handler,this._options)}dispose(){this._handler&&=(this._node.removeEventListener(this._type,this._handler,this._options),this._node=null,null)}};function sh(e,t,n,r){return new oh(e,t,n,r)}var ch=class e{constructor(e,t){this.width=e,this.height=t}with(t=this.width,n=this.height){return t!==this.width||n!==this.height?new e(t,n):this}static is(e){return typeof e==`object`&&typeof e.height==`number`&&typeof e.width==`number`}static lift(t){return t instanceof e?t:new e(t.width,t.height)}static equals(e,t){return e===t?!0:!e||!t?!1:e.width===t.width&&e.height===t.height}};ch.None=new ch(0,0),new class{constructor(){this.mutationObservers=new Map}observe(e,t,n){let r=this.mutationObservers.get(e);r||(r=new Map,this.mutationObservers.set(e,r));let i=Lm(n),a=r.get(i);if(a)a.users+=1;else{let o=new Z,s=new MutationObserver(e=>o.fire(e));s.observe(e,n);let c=a={users:1,observer:s,onDidMutate:o.event};t.add(yd(()=>{--c.users,c.users===0&&(o.dispose(),s.disconnect(),r?.delete(i),r?.size===0&&this.mutationObservers.delete(e))})),r.set(i,a)}return a.onDidMutate}};var lh={CLICK:`click`,AUXCLICK:`auxclick`,DBLCLICK:`dblclick`,MOUSE_UP:`mouseup`,MOUSE_DOWN:`mousedown`,MOUSE_OVER:`mouseover`,MOUSE_MOVE:`mousemove`,MOUSE_OUT:`mouseout`,MOUSE_ENTER:`mouseenter`,MOUSE_LEAVE:`mouseleave`,MOUSE_WHEEL:`wheel`,POINTER_UP:`pointerup`,POINTER_DOWN:`pointerdown`,POINTER_MOVE:`pointermove`,POINTER_LEAVE:`pointerleave`,CONTEXT_MENU:`contextmenu`,WHEEL:`wheel`,KEY_DOWN:`keydown`,KEY_PRESS:`keypress`,KEY_UP:`keyup`,LOAD:`load`,BEFORE_UNLOAD:`beforeunload`,UNLOAD:`unload`,PAGE_SHOW:`pageshow`,PAGE_HIDE:`pagehide`,PASTE:`paste`,ABORT:`abort`,ERROR:`error`,RESIZE:`resize`,SCROLL:`scroll`,FULLSCREEN_CHANGE:`fullscreenchange`,WK_FULLSCREEN_CHANGE:`webkitfullscreenchange`,SELECT:`select`,CHANGE:`change`,SUBMIT:`submit`,RESET:`reset`,FOCUS:`focus`,FOCUS_IN:`focusin`,FOCUS_OUT:`focusout`,BLUR:`blur`,INPUT:`input`,STORAGE:`storage`,DRAG_START:`dragstart`,DRAG:`drag`,DRAG_ENTER:`dragenter`,DRAG_LEAVE:`dragleave`,DRAG_OVER:`dragover`,DROP:`drop`,DRAG_END:`dragend`,ANIMATION_START:am?`webkitAnimationStart`:`animationstart`,ANIMATION_END:am?`webkitAnimationEnd`:`animationend`,ANIMATION_ITERATION:am?`webkitAnimationIteration`:`animationiteration`},uh=/([\w\-]+)?(#([\w\-]+))?((\.([\w\-]+))*)/;function dh(e,t,n,...r){let i=uh.exec(t);if(!i)throw Error(`Bad use of emmet`);let a=i[1]||`div`,o;return o=e===`http://www.w3.org/1999/xhtml`?document.createElement(a):document.createElementNS(e,a),i[3]&&(o.id=i[3]),i[4]&&(o.className=i[4].replace(/\./g,` `).trim()),n&&Object.entries(n).forEach(([e,t])=>{typeof t>`u`||(/^on\w+$/.test(e)?o[e]=t:e===`selected`?t&&o.setAttribute(e,`true`):o.setAttribute(e,t))}),o.append(...r),o}function fh(e,t,...n){return dh(`http://www.w3.org/1999/xhtml`,e,t,...n)}fh.SVG=function(e,t,...n){return dh(`http://www.w3.org/2000/svg`,e,t,...n)};var ph=class extends Sd{constructor(e,t,n,r,i,a,o,s,c){super(),this._terminal=e,this._characterJoinerService=t,this._charSizeService=n,this._coreBrowserService=r,this._coreService=i,this._decorationService=a,this._optionsService=o,this._themeService=s,this._cursorBlinkStateManager=new Cd,this._charAtlasDisposable=this._register(new Cd),this._observerDisposable=this._register(new Cd),this._model=new Lp,this._workCell=new gp,this._workCell2=new gp,this._rectangleRenderer=this._register(new Cd),this._glyphRenderer=this._register(new Cd),this._onChangeTextureAtlas=this._register(new Z),this.onChangeTextureAtlas=this._onChangeTextureAtlas.event,this._onAddTextureAtlasCanvas=this._register(new Z),this.onAddTextureAtlasCanvas=this._onAddTextureAtlasCanvas.event,this._onRemoveTextureAtlasCanvas=this._register(new Z),this.onRemoveTextureAtlasCanvas=this._onRemoveTextureAtlasCanvas.event,this._onRequestRedraw=this._register(new Z),this.onRequestRedraw=this._onRequestRedraw.event,this._onContextLoss=this._register(new Z),this.onContextLoss=this._onContextLoss.event,this._canvas=this._coreBrowserService.mainDocument.createElement(`canvas`);let l={antialias:!1,depth:!1,preserveDrawingBuffer:c};if(this._gl=this._canvas.getContext(`webgl2`,l),!this._gl)throw Error(`WebGL2 not supported `+this._gl);this._register(this._themeService.onChangeColors(()=>this._handleColorChange())),this._cellColorResolver=new sf(this._terminal,this._optionsService,this._model.selection,this._decorationService,this._coreBrowserService,this._themeService),this._core=this._terminal._core,this._renderLayers=[new $p(this._core.screenElement,2,this._terminal,this._core.linkifier,this._coreBrowserService,o,this._themeService)],this.dimensions=Xd(),this._devicePixelRatio=this._coreBrowserService.dpr,this._updateDimensions(),this._updateCursorBlink(),this._register(o.onOptionChange(()=>this._handleOptionsChanged())),this._deviceMaxTextureSize=this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE),this._register(sh(this._canvas,`webglcontextlost`,e=>{console.log(`webglcontextlost event received`),e.preventDefault(),this._contextRestorationTimeout=setTimeout(()=>{this._contextRestorationTimeout=void 0,console.warn(`webgl context not restored; firing onContextLoss`),this._onContextLoss.fire(e)},3e3)})),this._register(sh(this._canvas,`webglcontextrestored`,e=>{console.warn(`webglcontextrestored event received`),clearTimeout(this._contextRestorationTimeout),this._contextRestorationTimeout=void 0,dp(this._terminal),this._initializeWebGLState(),this._requestRedrawViewport()})),this._observerDisposable.value=mp(this._canvas,this._coreBrowserService.window,(e,t)=>this._setCanvasDevicePixelDimensions(e,t)),this._register(this._coreBrowserService.onWindowChange(e=>{this._observerDisposable.value=mp(this._canvas,e,(e,t)=>this._setCanvasDevicePixelDimensions(e,t))})),this._core.screenElement.appendChild(this._canvas),[this._rectangleRenderer.value,this._glyphRenderer.value]=this._initializeWebGLState(),this._isAttached=this._core.screenElement.isConnected,this._register(yd(()=>{for(let e of this._renderLayers)e.dispose();this._canvas.parentElement?.removeChild(this._canvas),dp(this._terminal)}))}get textureAtlas(){return this._charAtlas?.pages[0].canvas}_handleColorChange(){this._refreshCharAtlas(),this._clearModel(!0)}handleDevicePixelRatioChange(){this._devicePixelRatio!==this._coreBrowserService.dpr&&(this._devicePixelRatio=this._coreBrowserService.dpr,this.handleResize(this._terminal.cols,this._terminal.rows))}handleResize(e,t){this._updateDimensions(),this._model.resize(this._terminal.cols,this._terminal.rows);for(let e of this._renderLayers)e.resize(this._terminal,this.dimensions);this._canvas.width=this.dimensions.device.canvas.width,this._canvas.height=this.dimensions.device.canvas.height,this._canvas.style.width=`${this.dimensions.css.canvas.width}px`,this._canvas.style.height=`${this.dimensions.css.canvas.height}px`,this._core.screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._core.screenElement.style.height=`${this.dimensions.css.canvas.height}px`,this._rectangleRenderer.value?.setDimensions(this.dimensions),this._rectangleRenderer.value?.handleResize(),this._glyphRenderer.value?.setDimensions(this.dimensions),this._glyphRenderer.value?.handleResize(),this._refreshCharAtlas(),this._clearModel(!1)}handleCharSizeChanged(){this.handleResize(this._terminal.cols,this._terminal.rows)}handleBlur(){for(let e of this._renderLayers)e.handleBlur(this._terminal);this._cursorBlinkStateManager.value?.pause(),this._requestRedrawViewport()}handleFocus(){for(let e of this._renderLayers)e.handleFocus(this._terminal);this._cursorBlinkStateManager.value?.resume(),this._requestRedrawViewport()}handleSelectionChanged(e,t,n){for(let r of this._renderLayers)r.handleSelectionChanged(this._terminal,e,t,n);this._model.selection.update(this._core,e,t,n),this._requestRedrawViewport()}handleCursorMove(){for(let e of this._renderLayers)e.handleCursorMove(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation()}_handleOptionsChanged(){this._updateDimensions(),this._refreshCharAtlas(),this._updateCursorBlink()}_initializeWebGLState(){return this._rectangleRenderer.value=new Zp(this._terminal,this._gl,this.dimensions,this._themeService),this._glyphRenderer.value=new kp(this._terminal,this._gl,this.dimensions,this._optionsService),this.handleCharSizeChanged(),[this._rectangleRenderer.value,this._glyphRenderer.value]}_refreshCharAtlas(){if(this.dimensions.device.char.width<=0&&this.dimensions.device.char.height<=0){this._isAttached=!1;return}let e=up(this._terminal,this._optionsService.rawOptions,this._themeService.colors,this.dimensions.device.cell.width,this.dimensions.device.cell.height,this.dimensions.device.char.width,this.dimensions.device.char.height,this._coreBrowserService.dpr,this._deviceMaxTextureSize);this._charAtlas!==e&&(this._onChangeTextureAtlas.fire(e.pages[0].canvas),this._charAtlasDisposable.value=vd(Lf.forward(e.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas),Lf.forward(e.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas))),this._charAtlas=e,this._charAtlas.warmUp(),this._glyphRenderer.value?.setAtlas(this._charAtlas)}_clearModel(e){this._model.clear(),e&&this._glyphRenderer.value?.clear()}clearTextureAtlas(){this._charAtlas?.clearTexture(),this._clearModel(!0),this._requestRedrawViewport()}clear(){this._clearModel(!0);for(let e of this._renderLayers)e.reset(this._terminal);this._cursorBlinkStateManager.value?.restartBlinkAnimation(),this._updateCursorBlink()}renderRows(e,t){if(!this._isAttached)if(this._core.screenElement?.isConnected&&this._charSizeService.width&&this._charSizeService.height)this._updateDimensions(),this._refreshCharAtlas(),this._isAttached=!0;else return;for(let n of this._renderLayers)n.handleGridChanged(this._terminal,e,t);!this._glyphRenderer.value||!this._rectangleRenderer.value||(this._glyphRenderer.value.beginFrame()?(this._clearModel(!0),this._updateModel(0,this._terminal.rows-1)):this._updateModel(e,t),this._rectangleRenderer.value.renderBackgrounds(),this._glyphRenderer.value.render(this._model),(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible)&&this._rectangleRenderer.value.renderCursor())}_updateCursorBlink(){this._coreService.decPrivateModes.cursorBlink??this._terminal.options.cursorBlink?this._cursorBlinkStateManager.value=new pp(()=>{this._requestRedrawCursor()},this._coreBrowserService):this._cursorBlinkStateManager.clear(),this._requestRedrawCursor()}_updateModel(e,t){let n=this._core,r=this._workCell,i,a,o,s,c,l,u=0,d=!0,f,p,m,h,g,_,v,y,b;e=hh(e,n.rows-1,0),t=hh(t,n.rows-1,0);let x=this._coreService.decPrivateModes.cursorStyle??n.options.cursorStyle??`block`,S=this._terminal.buffer.active.baseY+this._terminal.buffer.active.cursorY,ee=S-n.buffer.ydisp,te=Math.min(this._terminal.buffer.active.cursorX,n.cols-1),C=-1,w=this._coreService.isCursorInitialized&&!this._coreService.isCursorHidden&&(!this._cursorBlinkStateManager.value||this._cursorBlinkStateManager.value.isCursorVisible);this._model.cursor=void 0;let T=!1;for(a=e;a<=t;a++)for(o=a+n.buffer.ydisp,s=n.buffer.lines.get(o),this._model.lineLengths[a]=0,m=S===o,u=0,c=this._characterJoinerService.getJoinedCharacters(o),y=0;y=u,f=y,c.length>0&&y===c[0][0]&&d){p=c.shift();let e=this._model.selection.isCellSelected(this._terminal,p[0],o);for(v=p[0]+1;v=p[1],d?(l=!0,r=new mh(r,s.translateToString(!0,p[0],p[1]),p[1]-p[0]),f=p[1]-1):u=p[1]}if(h=r.getChars(),g=r.getCode(),v=(a*n.cols+y)*Mp,this._cellColorResolver.resolve(r,y,o,this.dimensions.device.cell.width),w&&o===S&&(y===te&&(this._model.cursor={x:te,y:ee,width:r.getWidth(),style:this._coreBrowserService.isFocused?x:n.options.cursorInactiveStyle,cursorWidth:n.options.cursorWidth,dpr:this._devicePixelRatio},C=te+r.getWidth()-1),y>=te&&y<=C&&(this._coreBrowserService.isFocused&&x===`block`||this._coreBrowserService.isFocused===!1&&n.options.cursorInactiveStyle===`block`)&&(this._cellColorResolver.result.fg=50331648|this._themeService.colors.cursorAccent.rgba>>8&16777215,this._cellColorResolver.result.bg=50331648|this._themeService.colors.cursor.rgba>>8&16777215)),g!==0&&(this._model.lineLengths[a]=y+1),!(this._model.cells[v]===g&&this._model.cells[v+Np]===this._cellColorResolver.result.bg&&this._model.cells[v+Pp]===this._cellColorResolver.result.fg&&this._model.cells[v+Fp]===this._cellColorResolver.result.ext)&&(T=!0,h.length>1&&(g|=Ip),this._model.cells[v]=g,this._model.cells[v+Np]=this._cellColorResolver.result.bg,this._model.cells[v+Pp]=this._cellColorResolver.result.fg,this._model.cells[v+Fp]=this._cellColorResolver.result.ext,_=r.getWidth(),this._glyphRenderer.value.updateCell(y,a,g,this._cellColorResolver.result.bg,this._cellColorResolver.result.fg,this._cellColorResolver.result.ext,h,_,i),l)){for(r=this._workCell,y++;y<=f;y++)b=(a*n.cols+y)*Mp,this._glyphRenderer.value.updateCell(y,a,0,0,0,0,jd,0,0),this._model.cells[b]=0,this._model.cells[b+Np]=this._cellColorResolver.result.bg,this._model.cells[b+Pp]=this._cellColorResolver.result.fg,this._model.cells[b+Fp]=this._cellColorResolver.result.ext;y--}}T&&this._rectangleRenderer.value.updateBackgrounds(this._model),this._rectangleRenderer.value.updateCursor(this._model)}_updateDimensions(){!this._charSizeService.width||!this._charSizeService.height||(this.dimensions.device.char.width=Math.floor(this._charSizeService.width*this._devicePixelRatio),this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*this._devicePixelRatio),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.top=this._optionsService.rawOptions.lineHeight===1?0:Math.round((this.dimensions.device.cell.height-this.dimensions.device.char.height)/2),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.char.left=Math.floor(this._optionsService.rawOptions.letterSpacing/2),this.dimensions.device.canvas.height=this._terminal.rows*this.dimensions.device.cell.height,this.dimensions.device.canvas.width=this._terminal.cols*this.dimensions.device.cell.width,this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/this._devicePixelRatio),this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/this._devicePixelRatio),this.dimensions.css.cell.height=this.dimensions.device.cell.height/this._devicePixelRatio,this.dimensions.css.cell.width=this.dimensions.device.cell.width/this._devicePixelRatio)}_setCanvasDevicePixelDimensions(e,t){this._canvas.width===e&&this._canvas.height===t||(this._canvas.width=e,this._canvas.height=t,this._requestRedrawViewport())}_requestRedrawViewport(){this._onRequestRedraw.fire({start:0,end:this._terminal.rows-1})}_requestRedrawCursor(){let e=this._terminal.buffer.active.cursorY;this._onRequestRedraw.fire({start:e,end:e})}},mh=class extends kf{constructor(e,t,n){super(),this.content=0,this.combinedData=``,this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=n}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw Error(`not implemented`)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}};function hh(e,t,n=0){return Math.max(Math.min(e,t),n)}var gh=`di$target`,_h=`di$dependencies`,vh=new Map;function yh(e){if(vh.has(e))return vh.get(e);let t=function(e,n,r){if(arguments.length!==3)throw Error(`@IServiceName-decorator can only be used to decorate a parameter`);bh(t,e,r)};return t._id=e,vh.set(e,t),t}function bh(e,t,n){t[gh]===t?t[_h].push({id:e,index:n}):(t[_h]=[{id:e,index:n}],t[gh]=t)}yh(`BufferService`),yh(`CoreMouseService`),yh(`CoreService`),yh(`CharsetService`),yh(`InstantiationService`),yh(`LogService`);var xh=yh(`OptionsService`);yh(`OscLinkService`),yh(`UnicodeService`),yh(`DecorationService`);var Sh={trace:0,debug:1,info:2,warn:3,error:4,off:5},Ch=`xterm.js: `,wh=class extends Sd{constructor(e){super(),this._optionsService=e,this._logLevel=5,this._updateLogLevel(),this._register(this._optionsService.onSpecificOptionChange(`logLevel`,()=>this._updateLogLevel())),Th=this}get logLevel(){return this._logLevel}_updateLogLevel(){this._logLevel=Sh[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tthis.activate(e)));return}this._terminal=e;let n=t.coreService,r=t.optionsService,i=t,a=i._renderService,o=i._characterJoinerService,s=i._charSizeService,c=i._coreBrowserService,l=i._decorationService;i._logService;let u=i._themeService;this._renderer=this._register(new ph(e,o,s,c,n,l,r,u,this._preserveDrawingBuffer)),this._register(Lf.forward(this._renderer.onContextLoss,this._onContextLoss)),this._register(Lf.forward(this._renderer.onChangeTextureAtlas,this._onChangeTextureAtlas)),this._register(Lf.forward(this._renderer.onAddTextureAtlasCanvas,this._onAddTextureAtlasCanvas)),this._register(Lf.forward(this._renderer.onRemoveTextureAtlasCanvas,this._onRemoveTextureAtlasCanvas)),a.setRenderer(this._renderer),this._register(yd(()=>{if(this._terminal._core._store._isDisposed)return;let t=this._terminal._core._renderService;t.setRenderer(this._terminal._core._createRenderer()),t.handleResize(e.cols,e.rows)}))}get textureAtlas(){return this._renderer?.textureAtlas}clearTextureAtlas(){this._renderer?.clearTextureAtlas()}},Dh=class{aliases;usage;matches(e){let t=e.toLowerCase();return t===this.name.toLowerCase()||(this.aliases?.some(e=>t===e.toLowerCase())??!1)}writeLine(e,t,n){n?e.writeln(`${n}${t}\x1b[0m`):e.writeln(t)}writeSuccess(e,t){e.writeln(`\x1b[1;32m✓\x1b[0m ${t}`)}writeError(e,t){e.writeln(`\x1b[1;31m✗ Error:\x1b[0m ${t}`)}writeInfo(e,t){e.writeln(`\x1b[90m${t}\x1b[0m`)}startLoading(e,t){let n=[`⠋`,`⠙`,`⠹`,`⠸`,`⠼`,`⠴`,`⠦`,`⠧`,`⠇`,`⠏`],r=0,i=!0,a=setInterval(()=>{if(!i){clearInterval(a);return}e.write(`\r\x1b[36m${n[r]}\x1b[0m ${t}`),r=(r+1)%n.length},80);return()=>{i=!1,clearInterval(a),e.write(`\r\x1B[K`)}}},Oh=class extends Dh{name=`help`;description=`Show available commands`;aliases=[`?`,`h`];constructor(e){super(),this.commands=e}execute({term:e,writePrompt:t}){e.writeln(``),e.writeln(`\x1B[1;33mAvailable Commands:\x1B[0m`),e.writeln(``),this.commands.forEach(t=>{let n=t.aliases?.length?` (${t.aliases.join(`, `)})`:``;e.writeln(` \x1b[1;36m${t.name.padEnd(15)}\x1b[0m ${t.description}${n}`)}),e.writeln(``),e.writeln(`\x1B[90mTip: Use Tab for autocomplete, ↑↓ for history, Ctrl+F to search\x1B[0m`),t()}},kh=class extends Dh{name=`clear`;description=`Clear terminal screen`;aliases=[`cls`];execute({term:e,writePrompt:t}){e.clear(),t()}},Ah=class extends Dh{name=`status`;description=`Show repeater status`;aliases=[`st`];async execute({term:e,writePrompt:t}){let n=this.startLoading(e,`Fetching status...`);try{let t=await f.get(`/stats`);n();let r=t.success&&t.data?t.data:t;if(r&&typeof r==`object`){this.writeSuccess(e,`Repeater Status:`),e.writeln(``);for(let[t,n]of Object.entries(r))e.writeln(` \x1b[36m${t.padEnd(20)}\x1b[0m ${n}`)}else this.writeError(e,`No status data available`)}catch(t){n(),this.writeError(e,t instanceof Error?t.message:`Failed to fetch status`)}t()}},jh=class extends Dh{name=`uptime`;description=`Show system uptime`;async execute({term:e,writePrompt:t}){let n=this.startLoading(e,`Fetching uptime...`);try{let t=await f.get(`/stats`);n();let r=(t.data||t).uptime_seconds||0,i=this.formatUptime(r);this.writeSuccess(e,i)}catch(t){n(),this.writeError(e,`Failed to get uptime: ${t}`)}t()}formatUptime(e){let t=Math.floor(e/86400),n=Math.floor(e%86400/3600),r=Math.floor(e%3600/60);return t>0?`${t}d ${n}h ${r}m`:n>0?`${n}h ${r}m`:`${r}m`}},Mh=class extends Dh{name=`packets`;description=`Show packet statistics`;isMobile(){return/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)||window.innerWidth<768}async execute({term:e,writePrompt:t}){let n=this.startLoading(e,`Fetching packet stats...`);try{let t=await f.get(`/stats`);n();let r=t.data||t;this.writeLine(e,``),this.isMobile()?(this.writeLine(e,` \x1B[1;36mPacket Statistics\x1B[0m`),this.writeLine(e,` \x1B[90mRX:\x1B[0m `+(r.rx_count||0)),this.writeLine(e,` \x1B[90mTX:\x1B[0m `+(r.tx_count||0)),this.writeLine(e,` \x1B[90mForward:\x1B[0m `+(r.forwarded_count||0)),this.writeLine(e,` \x1B[90mDropped:\x1B[0m `+(r.dropped_count||0))):(this.writeLine(e,` \x1B[36m┌──────────┬──────────┐\x1B[0m`),this.writeLine(e,` \x1B[36m│\x1B[0m \x1B[1mMetric\x1B[0m \x1B[36m│\x1B[0m \x1B[1mCount\x1B[0m \x1B[36m│\x1B[0m`),this.writeLine(e,` \x1B[36m├──────────┼──────────┤\x1B[0m`),this.writeLine(e,` \x1b[36m│\x1b[0m RX \x1b[36m│\x1b[0m ${String(r.rx_count||0).padStart(8)} \x1b[36m│\x1b[0m`),this.writeLine(e,` \x1b[36m│\x1b[0m TX \x1b[36m│\x1b[0m ${String(r.tx_count||0).padStart(8)} \x1b[36m│\x1b[0m`),this.writeLine(e,` \x1b[36m│\x1b[0m Forward \x1b[36m│\x1b[0m ${String(r.forwarded_count||0).padStart(8)} \x1b[36m│\x1b[0m`),this.writeLine(e,` \x1b[36m│\x1b[0m Dropped \x1b[36m│\x1b[0m ${String(r.dropped_count||0).padStart(8)} \x1b[36m│\x1b[0m`),this.writeLine(e,` \x1B[36m└──────────┴──────────┘\x1B[0m`)),this.writeLine(e,``)}catch(t){n(),this.writeError(e,`Failed to get packet stats: ${t}`)}t()}},Nh=class extends Dh{name=`board`;description=`Show board information`;async execute({term:e,writePrompt:t}){let n=this.startLoading(e,`Fetching board info...`);try{let t=await f.get(`/stats`);n();let r=(t.data||t).board_info||`pyMC_Repeater (Linux/RPi)`;this.writeSuccess(e,r)}catch{n(),this.writeSuccess(e,`pyMC_Repeater (Linux/RPi)`)}t()}},Ph=class extends Dh{name=`advert`;description=`Send neighbor advert immediately`;async execute({term:e,writePrompt:t}){let n=this.startLoading(e,`Sending advert...`);try{let t=await f.post(`/send_advert`,{},{timeout:1e4});n(),t.success?this.writeSuccess(e,t.data||`Advert sent successfully`):this.writeError(e,t.error||`Failed to send advert`)}catch(t){n(),this.writeError(e,`Failed to send advert: ${t}`)}t()}},Fh=class extends Dh{name=`get`;description=`Get configuration values (name, freq, tx, mode, duty, etc.)`;matches(e){let t=e.toLowerCase();return t===`get`||t.startsWith(`get `)}async execute({term:e,args:t,writePrompt:n}){let r=t[0]?.toLowerCase();if(!r){this.writeError(e,`Usage: get `),this.writeLine(e,``),this.writeInfo(e,`Available parameters:`),this.writeLine(e,``),this.writeLine(e,` \x1B[36mname\x1B[0m Node name`),this.writeLine(e,` \x1B[36mrole\x1B[0m Node role`),this.writeLine(e,` \x1B[36mlat\x1B[0m Latitude`),this.writeLine(e,` \x1B[36mlon\x1B[0m Longitude`),this.writeLine(e,` \x1B[36mfreq\x1B[0m Frequency (MHz)`),this.writeLine(e,` \x1B[36mtx\x1B[0m TX power (dBm)`),this.writeLine(e,` \x1B[36mbw\x1B[0m Bandwidth (kHz)`),this.writeLine(e,` \x1B[36msf\x1B[0m Spreading factor`),this.writeLine(e,` \x1B[36mcr\x1B[0m Coding rate`),this.writeLine(e,` \x1B[36mradio\x1B[0m All radio settings`),this.writeLine(e,` \x1B[36mtxdelay\x1B[0m TX delay factor`),this.writeLine(e,` \x1B[36mdirect.txdelay\x1B[0m Direct TX delay`),this.writeLine(e,` \x1B[36mrxdelay\x1B[0m RX delay base`),this.writeLine(e,` \x1B[36maf\x1B[0m Airtime factor`),this.writeLine(e,` \x1B[36mmode\x1B[0m Repeater mode`),this.writeLine(e,` \x1B[36mrepeat\x1B[0m Repeat on/off`),this.writeLine(e,` \x1B[36mflood.max\x1B[0m Max flood hops`),this.writeLine(e,` \x1B[36madvert.interval\x1B[0m Advert interval`),this.writeLine(e,` \x1B[36mduty\x1B[0m Duty cycle enabled`),this.writeLine(e,` \x1B[36mduty.max\x1B[0m Max airtime %`),this.writeLine(e,` \x1B[36mpublic.key\x1B[0m Public key`),this.writeLine(e,``),n();return}let i=this.startLoading(e,`Fetching configuration...`);try{let t=await f.get(`/stats`);i();let a=t.data||t,o=a.config||{},s=o.radio||{},c=o.repeater||{},l=o.delays||{},u=o.duty_cycle||{},d=``;switch(r){case`name`:d=o.node_name||`Unknown`;break;case`role`:d=`repeater`;break;case`lat`:d=c.latitude==null?`not set`:String(c.latitude);break;case`lon`:d=c.longitude==null?`not set`:String(c.longitude);break;case`freq`:d=s.frequency?`${(s.frequency/1e6).toFixed(3)} MHz`:`?`;break;case`tx`:d=s.tx_power==null?`?`:`${s.tx_power}dBm`;break;case`bw`:d=s.bandwidth?`${s.bandwidth/1e3} kHz`:`?`;break;case`sf`:d=s.spreading_factor==null?`?`:String(s.spreading_factor);break;case`cr`:d=s.coding_rate==null?`?`:`4/${s.coding_rate}`;break;case`radio`:if(s.frequency){this.writeSuccess(e,`Radio Configuration:`),this.writeLine(e,``),this.writeLine(e,` \x1b[36mFrequency:\x1b[0m ${(s.frequency/1e6).toFixed(3)} MHz`),this.writeLine(e,` \x1b[36mBandwidth:\x1b[0m ${s.bandwidth/1e3} kHz`),this.writeLine(e,` \x1b[36mSpreading Factor:\x1b[0m ${s.spreading_factor}`),this.writeLine(e,` \x1b[36mCoding Rate:\x1b[0m 4/${s.coding_rate}`),this.writeLine(e,` \x1b[36mTX Power:\x1b[0m ${s.tx_power}dBm`),this.writeLine(e,``),n();return}else d=`Radio configuration not available`;break;case`af`:case`txdelay`:d=l.tx_delay_factor==null?`\x1B[90mnot set (default: 1.0)\x1B[0m`:String(l.tx_delay_factor);break;case`direct.txdelay`:d=l.direct_tx_delay_factor==null?`\x1B[90mnot set (default: 0.5)\x1B[0m`:String(l.direct_tx_delay_factor);break;case`rxdelay`:d=l.rx_delay_base==null?`\x1B[90mnot set (default: 0.0s)\x1B[0m`:`${l.rx_delay_base}s`;break;case`mode`:d=c.mode==null?`\x1B[90mnot set (default: forward)\x1B[0m`:c.mode;break;case`repeat`:d=c.mode==null?`\x1B[90mnot set (default: on)\x1B[0m`:c.mode===`forward`?`on`:`off`;break;case`flood.max`:d=c.max_flood_hops==null?`\x1B[90mnot set (default: 3)\x1B[0m`:String(c.max_flood_hops);break;case`flood.advert.interval`:d=c.send_advert_interval_hours==null?`\x1B[90mnot set\x1B[0m`:`${c.send_advert_interval_hours}h`;break;case`advert.interval`:d=c.advert_interval_minutes==null?`\x1B[90mnot set (default: 120m)\x1B[0m`:`${c.advert_interval_minutes}m`;break;case`duty`:case`duty.enabled`:d=u.enforcement_enabled==null?`\x1B[90mnot set (default: off)\x1B[0m`:u.enforcement_enabled?`on`:`off`;break;case`duty.max`:d=u.max_airtime_percent==null?`\x1B[90mnot set\x1B[0m`:`${u.max_airtime_percent}%`;break;case`public.key`:d=a.public_key||`\x1B[90mnot available\x1B[0m`;break;case`prv.key`:this.writeWarning(e,`Private key not exposed via API for security`),this.writeInfo(e,`Check /etc/pymc_repeater/config.yaml`),n();return;case`guest.password`:case`allow.read.only`:this.writeWarning(e,`Security settings not exposed via API`),this.writeInfo(e,`Check /etc/pymc_repeater/config.yaml`),n();return;default:this.writeError(e,`Unknown parameter: ${r}`),this.writeLine(e,``),this.writeInfo(e,`Available parameters:`),this.writeInfo(e,` Identity: name, role, lat, lon`),this.writeInfo(e,` Radio: freq, tx, bw, sf, cr, radio`),this.writeInfo(e,` Timing: txdelay, direct.txdelay, rxdelay, af`),this.writeInfo(e,` Repeater: mode, repeat, flood.max, advert.interval`),this.writeInfo(e,` Duty: duty, duty.max`),this.writeInfo(e,` Security: public.key`),n();return}this.writeSuccess(e,d)}catch(t){i(),this.writeError(e,`Failed to get ${r}: ${t}`)}n()}writeWarning(e,t){e.writeln(`\x1b[1;33m⚠ Warning:\x1b[0m ${t}`)}},Ih=class extends Dh{name=`set`;description=`Set configuration values (tx, txdelay, mode, duty, etc.)`;matches(e){let t=e.toLowerCase();return t===`set`||t.startsWith(`set `)}async execute({term:e,args:t,writePrompt:n}){let r=t[0]?.toLowerCase(),i=t.slice(1).join(` `).trim();if(!r){this.writeError(e,`Usage: set `),this.writeLine(e,``),this.writeInfo(e,`Available parameters:`),this.writeLine(e,``),this.writeLine(e,` \x1B[33mRadio:\x1B[0m`),this.writeLine(e,` \x1B[36mtx <2-30>\x1B[0m TX power in dBm`),this.writeLine(e,` \x1B[36mfreq \x1B[0m Frequency (100-1000 MHz) *restart required*`),this.writeLine(e,` \x1B[36mbw \x1B[0m Bandwidth (7.8-500 kHz) *restart required*`),this.writeLine(e,` \x1B[36msf <5-12>\x1B[0m Spreading factor *restart required*`),this.writeLine(e,` \x1B[36mcr <5-8>\x1B[0m Coding rate (for 4/5 to 4/8) *restart required*`),this.writeLine(e,``),this.writeLine(e,` \x1B[33mTiming:\x1B[0m`),this.writeLine(e,` \x1B[36mtxdelay <0.0-5.0>\x1B[0m TX delay factor`),this.writeLine(e,` \x1B[36mdirect.txdelay <0.0-5.0>\x1B[0m Direct TX delay factor`),this.writeLine(e,` \x1B[36mrxdelay \x1B[0m RX delay base (>= 0)`),this.writeLine(e,``),this.writeLine(e,` \x1B[33mIdentity:\x1B[0m`),this.writeLine(e,` \x1B[36mname \x1B[0m Node name`),this.writeLine(e,` \x1B[36mlat <-90 to 90>\x1B[0m Latitude`),this.writeLine(e,` \x1B[36mlon <-180 to 180>\x1B[0m Longitude`),this.writeLine(e,``),this.writeLine(e,` \x1B[33mRepeater:\x1B[0m`),this.writeLine(e,` \x1B[36mmode \x1B[0m Repeater mode`),this.writeLine(e,` \x1B[36mduty \x1B[0m Duty cycle enforcement`),this.writeLine(e,` \x1B[36mflood.max <0-64>\x1B[0m Max flood hops`),this.writeLine(e,` \x1B[36madvert.interval \x1B[0m Local advert interval`),this.writeLine(e,``),n();return}let a=this.startLoading(e,`Updating configuration...`);try{let t;switch(r){case`tx`:{let r=parseInt(i);if(isNaN(r)||r<2||r>30){a(),this.writeError(e,`TX power must be 2-30 dBm`),n();return}t=await f.post(`/update_radio_config`,{tx_power:r},{timeout:3e4});break}case`freq`:{let r=parseFloat(i);if(isNaN(r)||r<100||r>1e3){a(),this.writeError(e,`Frequency must be 100-1000 MHz`),n();return}t=await f.post(`/update_radio_config`,{frequency:r*1e6},{timeout:3e4});break}case`bw`:{let r=parseFloat(i),o=[7.8,10.4,15.6,20.8,31.25,41.7,62.5,125,250,500];if(isNaN(r)||!o.includes(r)){a(),this.writeError(e,`Bandwidth must be one of: ${o.join(`, `)} kHz`),n();return}t=await f.post(`/update_radio_config`,{bandwidth:r*1e3},{timeout:3e4});break}case`sf`:{let r=parseInt(i);if(isNaN(r)||r<5||r>12){a(),this.writeError(e,`Spreading factor must be 5-12`),n();return}t=await f.post(`/update_radio_config`,{spreading_factor:r},{timeout:3e4});break}case`cr`:{let r=parseInt(i);if(isNaN(r)||r<5||r>8){a(),this.writeError(e,`Coding rate must be 5-8 (for 4/5 to 4/8)`),n();return}t=await f.post(`/update_radio_config`,{coding_rate:r},{timeout:3e4});break}case`af`:case`txdelay`:{let r=parseFloat(i);if(isNaN(r)||r<0||r>5){a(),this.writeError(e,`TX delay factor must be 0.0-5.0`),n();return}t=await f.post(`/update_radio_config`,{tx_delay_factor:r},{timeout:3e4});break}case`direct.txdelay`:{let r=parseFloat(i);if(isNaN(r)||r<0||r>5){a(),this.writeError(e,`Direct TX delay factor must be 0.0-5.0`),n();return}t=await f.post(`/update_radio_config`,{direct_tx_delay_factor:r},{timeout:3e4});break}case`rxdelay`:{let r=parseFloat(i);if(isNaN(r)||r<0){a(),this.writeError(e,`RX delay must be >= 0`),n();return}t=await f.post(`/update_radio_config`,{rx_delay_base:r},{timeout:3e4});break}case`name`:if(!i.trim()){a(),this.writeError(e,`Node name cannot be empty`),n();return}t=await f.post(`/update_radio_config`,{node_name:i.trim()},{timeout:3e4});break;case`lat`:{let r=parseFloat(i);if(isNaN(r)||r<-90||r>90){a(),this.writeError(e,`Latitude must be -90 to 90`),n();return}t=await f.post(`/update_radio_config`,{latitude:r},{timeout:3e4});break}case`lon`:{let r=parseFloat(i);if(isNaN(r)||r<-180||r>180){a(),this.writeError(e,`Longitude must be -180 to 180`),n();return}t=await f.post(`/update_radio_config`,{longitude:r},{timeout:3e4});break}case`mode`:{let r=i.toLowerCase();if(r!==`forward`&&r!==`monitor`&&r!==`no_tx`){a(),this.writeError(e,`Mode must be "forward", "monitor", or "no_tx"`),this.writeLine(e,``),this.writeInfo(e,`Valid values:`),this.writeLine(e,` \x1B[36mforward\x1B[0m - Forward packets`),this.writeLine(e,` \x1B[36mmonitor\x1B[0m - Monitor only (no forwarding)`),this.writeLine(e,` \x1B[36mno_tx\x1B[0m - No repeat, no local TX; adverts skipped`),n();return}t=await f.post(`/set_mode`,{mode:r},{timeout:3e4}),t.data&&(t.data.applied=[`mode=${r}`],t.data.persisted=!0,t.data.live_update=!0);break}case`duty`:{let r=i.toLowerCase();if(r!==`on`&&r!==`off`){a(),this.writeError(e,`Duty cycle must be "on" or "off"`),this.writeLine(e,``),this.writeInfo(e,`Valid values:`),this.writeLine(e,` \x1B[36mon\x1B[0m - Enable duty cycle enforcement`),this.writeLine(e,` \x1B[36moff\x1B[0m - Disable duty cycle enforcement`),n();return}let o=r===`on`;t=await f.post(`/set_duty_cycle`,{enabled:o},{timeout:3e4}),t.data&&(t.data.applied=[`duty=${r}`],t.data.persisted=!0,t.data.live_update=!0);break}case`flood.max`:{let r=parseInt(i);if(isNaN(r)||r<0||r>64){a(),this.writeError(e,`Max flood hops must be 0-64`),n();return}t=await f.post(`/update_radio_config`,{max_flood_hops:r},{timeout:3e4});break}case`flood.advert.interval`:{let r=parseInt(i);if(isNaN(r)||r!==0&&(r<3||r>48)){a(),this.writeError(e,`Flood advert interval must be 0 (off) or 3-48 hours`),n();return}t=await f.post(`/update_radio_config`,{flood_advert_interval_hours:r},{timeout:3e4});break}case`advert.interval`:{let r=parseInt(i);if(isNaN(r)||r!==0&&(r<1||r>10080)){a(),this.writeError(e,`Advert interval must be 0 (off) or 1-10080 minutes`),n();return}t=await f.post(`/update_radio_config`,{advert_interval_minutes:r},{timeout:3e4});break}case`log`:a(),this.writeWarning(e,`Log level configuration not yet implemented`),this.writeInfo(e,`Backend endpoint /set_log_level does not exist`),n();return;default:a(),this.writeError(e,`Unknown parameter: ${r}`),this.writeLine(e,``),this.writeInfo(e,`Type "set" without arguments to see available parameters`),n();return}a();let o=t.data||t;t.success?(o.applied&&o.applied.length>0?this.writeSuccess(e,`Configuration updated: ${o.applied.join(`, `)}`):this.writeSuccess(e,`Configuration updated`),o.restart_required?(this.writeLine(e,``),this.writeWarning(e,`⚠ Service restart required for changes to take effect`),this.writeInfo(e,`Run: sudo systemctl restart pymc_repeater`)):o.message&&!o.live_update&&(this.writeLine(e,``),this.writeInfo(e,o.message))):this.writeError(e,t.error||`Failed to update configuration`)}catch(t){a(),this.writeError(e,`Failed to update ${r}: ${t}`)}this.writeLine(e,``),n()}writeWarning(e,t){e.writeln(`\x1b[1;33m⚠ Warning:\x1b[0m ${t}`)}},Lh=class extends Dh{name=`identities`;description=`List all identities`;aliases=[`id`,`ids`];isMobile(){return/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)||window.innerWidth<768}async execute({term:e,writePrompt:t}){let n=this.startLoading(e,`Fetching identities...`);try{let t=await f.getIdentities();n();let r=[];if(t.success&&t.data){let e=t.data,n=e.registered||[],i=e.configured||[];r=i.length>0?i:n}else Array.isArray(t)&&(r=t);r.length===0?this.writeInfo(e,`No identities found`):(this.writeSuccess(e,`Found \x1b[1m${r.length}\x1b[0m identit${r.length===1?`y`:`ies`}`),e.writeln(``),this.isMobile()?r.forEach((t,n)=>{e.writeln(`\x1b[1;36m[${n+1}] ${t.name||`Unnamed`}\x1b[0m`),e.writeln(` \x1b[90mType:\x1b[0m ${t.type||`-`}`),e.writeln(` \x1b[90mHash:\x1b[0m ${t.hash||`-`}`),e.writeln(` \x1b[90mAddress:\x1b[0m ${t.address||`-`}`),e.writeln(` \x1b[90mRegistered:\x1b[0m ${t.registered?`\x1B[32myes\x1B[0m`:`\x1B[31mno\x1B[0m`}`),n{let r=(n+1).toString().padEnd(2),i=(t.name||`Unnamed`).padEnd(27),a=(t.type||`-`).padEnd(13),o=(t.hash||`-`).padEnd(4),s=(t.address||`-`).padEnd(7),c=(t.registered?`yes`:`no`).padEnd(10);e.writeln(`\x1b[36m│\x1b[0m ${r} \x1b[36m│\x1b[0m \x1b[1m${i}\x1b[0m \x1b[36m│\x1b[0m ${a} \x1b[36m│\x1b[0m ${o} \x1b[36m│\x1b[0m ${s} \x1b[36m│\x1b[0m ${c} \x1b[36m│\x1b[0m`)}),e.writeln(`\x1B[36m└────┴─────────────────────────────┴───────────────┴──────┴─────────┴────────────┘\x1B[0m`)))}catch(t){n(),this.writeError(e,t instanceof Error?t.message:`Failed to fetch identities`)}t()}},Rh=class extends Dh{name=`keys`;description=`List transport keys`;async execute({term:e,writePrompt:t}){let n=this.startLoading(e,`Fetching transport keys...`);try{let t=await f.getTransportKeys();n();let r=t.success&&t.data?t.data:t,i=Array.isArray(r)?r:[];i.length===0?this.writeInfo(e,`No transport keys found`):(this.writeSuccess(e,`Found \x1b[1m${i.length}\x1b[0m transport key${i.length===1?``:`s`}`),e.writeln(``),i.forEach((t,n)=>{e.writeln(`\x1b[36m${(n+1).toString().padStart(2)}.\x1b[0m \x1b[1m${t.name||`Unnamed`}\x1b[0m`),t.flood_policy&&e.writeln(` Policy: \x1b[90m${t.flood_policy}\x1b[0m`),t.parent_id&&e.writeln(` Parent: \x1b[90m${t.parent_id}\x1b[0m`),n{if(e.writeln(`\x1b[1;36m[${n+1}] ${t.node_name||`Unknown`}\x1b[0m`),e.writeln(` \x1b[90mPubKey:\x1b[0m ${t.pubkey?.substring(0,8)||`----`}`),e.writeln(` \x1b[90mType:\x1b[0m ${t.contact_type||`-`}`),t.last_seen){let n=new Date(t.last_seen*1e3).toLocaleString(`en-US`,{month:`2-digit`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`,hour12:!1});e.writeln(` \x1b[90mLast Seen:\x1b[0m ${n}`)}t.rssi&&e.writeln(` \x1b[90mRSSI:\x1b[0m ${t.rssi}`),t.snr&&e.writeln(` \x1b[90mSNR:\x1b[0m ${t.snr}`),e.writeln(` \x1b[90mAdverts:\x1b[0m ${t.advert_count||0}`),e.writeln(` \x1b[90mDirect:\x1b[0m ${t.zero_hop?`\x1B[32myes\x1B[0m`:`\x1B[31mno\x1B[0m`}`),n{let r=(n+1).toString().padEnd(2),i=(t.node_name||`Unknown`).padEnd(20),a=(t.pubkey?.substring(0,4)||`----`).padEnd(6),o=(t.contact_type||`-`).padEnd(12),s=t.last_seen?new Date(t.last_seen*1e3).toLocaleString(`en-US`,{month:`2-digit`,day:`2-digit`,hour:`2-digit`,minute:`2-digit`,hour12:!1}).padEnd(20):`-`.padEnd(20),c=(t.rssi?`${t.rssi}`:`-`).padEnd(8),l=(t.snr?`${t.snr}`:`-`).padEnd(4),u=(t.advert_count?.toString()||`0`).padEnd(6),d=(t.zero_hop?`yes`:`no`).padEnd(6);e.writeln(`\x1b[36m│\x1b[0m ${r} \x1b[36m│\x1b[0m \x1b[1m${i}\x1b[0m \x1b[36m│\x1b[0m ${a} \x1b[36m│\x1b[0m ${o} \x1b[36m│\x1b[0m ${s} \x1b[36m│\x1b[0m ${c} \x1b[36m│\x1b[0m ${l} \x1b[36m│\x1b[0m ${u} \x1b[36m│\x1b[0m ${d} \x1b[36m│\x1b[0m`)}),e.writeln(`\x1B[36m└────┴──────────────────────┴────────┴──────────────┴──────────────────────┴──────────┴──────┴────────┴────────┘\x1B[0m`)),r.length>10&&(e.writeln(``),e.writeln(`\x1b[90m... and ${r.length-10} more neighbors\x1b[0m`)))}catch(t){n(),this.writeError(e,t instanceof Error?t.message:`Failed to fetch neighbors`)}t()}},Bh=class extends Dh{name=`acl`;description=`Show ACL statistics`;async execute({term:e,writePrompt:t}){let n=this.startLoading(e,`Fetching ACL stats...`);try{let t=await f.getACLStats();n();let r=t.success&&t.data?t.data:t;if(r&&typeof r==`object`){this.writeSuccess(e,`ACL Statistics:`),e.writeln(``);let t=(n,r=` `)=>{if(typeof n==`object`&&n&&!Array.isArray(n))for(let[i,a]of Object.entries(n))typeof a==`object`&&a?(e.writeln(`${r}\x1b[90m${i}:\x1b[0m`),t(a,r+` `)):e.writeln(`${r}\x1b[90m${i.padEnd(18)}\x1b[0m ${a}`);else e.writeln(`${r}${n}`)};for(let[n,i]of Object.entries(r))typeof i==`object`&&i?(e.writeln(` \x1b[36m${n}\x1b[0m`),t(i,` `)):e.writeln(` \x1b[36m${n.padEnd(20)}\x1b[0m ${i}`)}else this.writeError(e,`No ACL data available`)}catch(t){n(),this.writeError(e,t instanceof Error?t.message:`Failed to fetch ACL stats`)}t()}},Vh=class extends Dh{name=`rooms`;description=`List room servers`;isMobile(){return/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)||window.innerWidth<768}async execute({term:e,writePrompt:t}){let n=this.startLoading(e,`Fetching room stats...`);try{let t=await f.getRoomStats();n();let r=[];t.success&&t.data?r=t.data.rooms||(Array.isArray(t.data)?t.data:[]):Array.isArray(t)&&(r=t),r.length===0?this.writeInfo(e,`No room servers found`):(this.writeSuccess(e,`Found \x1b[1m${r.length}\x1b[0m room server${r.length===1?``:`s`}`),e.writeln(``),this.isMobile()?r.forEach((t,n)=>{e.writeln(`\x1b[1;36m[${n+1}] ${t.room_name||`Unnamed`}\x1b[0m`),e.writeln(` \x1b[90mMessages:\x1b[0m ${t.total_messages||0}`),e.writeln(` \x1b[90mTotal Clients:\x1b[0m ${t.total_clients||0}`),e.writeln(` \x1b[90mActive Clients:\x1b[0m ${t.active_clients||0}`),e.writeln(` \x1b[90mSync:\x1b[0m ${t.sync_running?`\x1B[32mrunning\x1B[0m`:`\x1B[31mstopped\x1B[0m`}`),n{let r=(n+1).toString().padEnd(2),i=(t.room_name||`Unnamed`).padEnd(27),a=(t.total_messages?.toString()||`0`).padEnd(8),o=(t.total_clients?.toString()||`0`).padEnd(12),s=(t.active_clients?.toString()||`0`).padEnd(14),c=(t.sync_running?`running`:`stopped`).padEnd(8);e.writeln(`\x1b[36m│\x1b[0m ${r} \x1b[36m│\x1b[0m \x1b[1m${i}\x1b[0m \x1b[36m│\x1b[0m ${a} \x1b[36m│\x1b[0m ${o} \x1b[36m│\x1b[0m ${s} \x1b[36m│\x1b[0m ${c} \x1b[36m│\x1b[0m`)}),e.writeln(`\x1B[36m└────┴─────────────────────────────┴──────────┴──────────────┴────────────────┴──────────┘\x1B[0m`)))}catch(t){n(),this.writeError(e,t instanceof Error?t.message:`Failed to fetch room stats`)}t()}},Hh=class extends Dh{name=`restart`;description=`Restart the pymc-repeater service`;aliases=[`reboot`];matches(e){let t=e.toLowerCase();return t===`restart`||t===`reboot`}async execute({term:e,writePrompt:t}){this.writeLine(e,``),this.writeLine(e,`\x1B[33m⚠️ This will restart the repeater service!\x1B[0m`),this.writeLine(e,``),this.writeInfo(e,`Attempting to restart service...`);let n=this.startLoading(e,`Restarting...`);try{let t=await f.post(`/restart_service`,{},{timeout:1e4});n(),t.success?(this.writeLine(e,``),this.writeSuccess(e,t.message||`Service restart initiated`),this.writeLine(e,``),this.writeInfo(e,`The service will restart momentarily. You may need to refresh this page.`)):(this.writeLine(e,``),this.writeError(e,`Restart failed: `+(t.error||t.message||`Unknown error`)),this.writeLine(e,``),this.writeInfo(e,`You may need to manually restart: sudo systemctl restart pymc-repeater`))}catch(r){n(),this.writeLine(e,``);let i=r;if(i.code===`ERR_NETWORK`||i.message?.includes(`Network error`)||i.message?.includes(`ECONNRESET`)||i.code===`ECONNRESET`){this.writeSuccess(e,`Service restart initiated successfully`),this.writeLine(e,``),await this.waitForServiceRestart(e,t);return}else i.code===`ECONNABORTED`||i.message?.includes(`timeout`)?(this.writeLine(e,`\x1B[33m⚠️ Request timed out - service may be restarting\x1B[0m`),this.writeLine(e,``),this.writeInfo(e,`Refresh the page in a few seconds to reconnect.`)):i.response?.status===403||i.response?.status===401?(this.writeError(e,`Permission denied. Polkit rules may need configuration.`),this.writeLine(e,``),this.writeInfo(e,`Run: sudo bash -c 'mkdir -p /etc/polkit-1/rules.d && cat > /etc/polkit-1/rules.d/10-pymc-repeater.rules <0;t--)e.write(`\r\x1b[36m⏳\x1b[0m Restarting service... ${t}s`),await new Promise(e=>setTimeout(e,1e3));e.write(`\r\x1B[K`);let n=4,r=0;for(;n<20;){r++,e.write(` ⏳ Verifying restart (attempt ${r})... `);try{if((await fetch(`${window.location.protocol}//${window.location.host}/api/stats`,{signal:AbortSignal.timeout(3e3)})).ok){e.write(`\r\x1B[K`),this.writeLine(e,``),this.writeSuccess(e,`Service is back online! (took ~${n}s)`),this.writeLine(e,``),t();return}}catch(t){let n=t;n.code&&![`ERR_NETWORK`,`ECONNREFUSED`,`ECONNRESET`].includes(n.code)&&e.write(`[${n.code}] `)}await new Promise(e=>setTimeout(e,1*1e3)),n+=1}e.write(`\r\x1B[K`),this.writeLine(e,``),this.writeLine(e,`\x1B[33m⚠️ Service did not respond within 20 seconds\x1B[0m`),this.writeLine(e,``),this.writeInfo(e,`The service may still be starting. Try: status`),this.writeLine(e,``),t()}},Uh=class extends Dh{name=`ping`;description=`Ping a neighbor node to measure latency and signal quality`;usage=`ping [timeout_seconds]`;async execute({term:e,args:t,writePrompt:n}){if(t.length===0){this.writeError(e,`Missing target node`),e.writeln(``),this.writeInfo(e,`Usage: ${this.usage}`),e.writeln(``),this.writeInfo(e,`Examples:`),this.writeInfo(e,` ping MyNeighbor - Ping node by name`),this.writeInfo(e,` ping 0xb5 - Ping node by pubkey hash`),this.writeInfo(e,` ping MyNeighbor 20 - Ping with 20s timeout`),n();return}let r=t[0],i=t.length>1?parseInt(t[1]):10;if(isNaN(i)||i<1||i>60){this.writeError(e,`Invalid timeout. Must be between 1-60 seconds`),n();return}let a=null,o=r.match(/^(0x)?([0-9a-fA-F]{1,2})$/);if(o)a=`0x${o[2].padStart(2,`0`)}`;else{let t=this.startLoading(e,`Resolving target...`);try{let i=[`Chat Node`,`Repeater`,`Room Server`,`Hybrid Node`,`Unknown`],o=!1;for(let e of i)try{let t=await f.get(`/adverts_by_contact_type`,{contact_type:e,hours:168}),n=t.success&&t.data?t.data:t,i=(Array.isArray(n)?n:[]).find(e=>e.node_name&&e.node_name.toLowerCase()===r.toLowerCase());if(i&&i.pubkey){a=`0x${i.pubkey.substring(0,2)}`,o=!0;break}}catch{continue}if(t(),!o){this.writeError(e,`Node '${r}' not found in neighbors`),e.writeln(``),this.writeInfo(e,`Try: neighbors - to list available nodes`),n();return}}catch(r){t(),this.writeError(e,`Failed to resolve target: ${r}`),n();return}}this.writeLine(e,`\x1b[36mPinging ${r} (${a}) with ${i}s timeout...\x1b[0m`),e.writeln(``);let s=this.startLoading(e,`Waiting for response...`);try{let t=await f.pingNeighbor(a,i);if(s(),t.success&&t.data){let n=t.data;this.writeSuccess(e,`Reply from ${r} (${n.target_id})`),e.writeln(``);let i=`\x1B[32m`;if(n.rtt_ms>500?i=`\x1B[31m`:n.rtt_ms>250&&(i=`\x1B[33m`),e.writeln(` \x1b[1mRound-Trip Time:\x1b[0m ${i}${n.rtt_ms.toFixed(2)} ms\x1b[0m`),e.writeln(` \x1b[1mRSSI:\x1b[0m ${n.rssi} dBm`),e.writeln(` \x1b[1mSNR:\x1b[0m ${n.snr_db} dB`),n.path&&n.path.length>0){let t=n.path.join(` → `),r=n.path.length;e.writeln(` \x1b[1mPath:\x1b[0m ${t}`),e.writeln(` \x1b[1mHops:\x1b[0m ${r}`)}e.writeln(``);let a=`Excellent`,o=`\x1B[32m`;n.rtt_ms>500||n.rssi<-120?(a=`Poor`,o=`\x1B[31m`):n.rtt_ms>250||n.rssi<-100?(a=`Fair`,o=`\x1B[33m`):(n.rtt_ms>100||n.rssi<-80)&&(a=`Good`,o=`\x1B[36m`),e.writeln(` \x1b[1mLink Quality:\x1b[0m ${o}${a}\x1b[0m`)}else this.writeError(e,t.error||`Ping failed`)}catch(t){s(),this.writeError(e,`Ping failed: ${t.message||t}`)}e.writeln(``),n()}};async function Wh(){try{let e=[`Chat Node`,`Repeater`,`Room Server`,`Hybrid Node`,`Unknown`],t=[];for(let n of e)try{let e=await f.get(`/adverts_by_contact_type`,{contact_type:n,hours:168}),r=e.success&&e.data?e.data:e;(Array.isArray(r)?r:[]).forEach(e=>{e.node_name&&!t.includes(e.node_name)&&t.push(e.node_name)})}catch{continue}return t.sort()}catch{return[]}}var Gh=class{commands=[];constructor(){let e=new kh,t=new Ah,n=new jh,r=new Mh,i=new Nh,a=new Ph,o=new Fh,s=new Ih,c=new Lh,l=new Rh,u=new zh,d=new Bh,f=new Vh,p=new Hh,m=new Uh;this.commands=[new Oh([e,t,n,r,i,a,o,s,c,l,u,d,f,p,m]),e,t,n,r,i,a,o,s,c,l,u,d,f,p,m]}findCommand(e){return this.commands.find(t=>t.matches(e))}getAllCommands(){return this.commands}getCommandNames(){return this.commands.map(e=>e.name)}},Kh={class:`space-y-4 md:space-y-6`},qh={class:`glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-3 md:p-4`},Jh={class:`flex items-center justify-between`},Yh={class:`flex items-center gap-2 md:gap-3`},Xh=[`title`],Zh={key:0,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},Qh={key:1,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},$h={class:`hidden sm:inline`},eg=[`title`],tg={class:`hidden sm:inline`},ng=[`title`],rg={key:0,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},ig={key:1,class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},ag={class:`hidden sm:inline`},og={key:0,class:`glass-card backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] p-4`},sg={class:`flex items-center gap-3`},cg=[`onKeydown`],lg={key:1,class:`absolute top-4 right-4 bg-black/80 backdrop-blur-sm px-3 py-2 rounded-lg border border-primary/30 flex items-center gap-2`},ug=p(r({name:`TerminalView`,__name:`Terminal`,setup(r){let{theme:f}=m(),p={background:`#1A1E1F`,foreground:`#e0e0e0`,cursor:`#00d9ff`,cursorAccent:`#000000`,selectionBackground:`#00d9ff40`,selectionForeground:`#ffffff`,black:`#000000`,red:`#ff6b6b`,green:`#51cf66`,yellow:`#ffd93d`,blue:`#00d9ff`,magenta:`#e599f7`,cyan:`#00d9ff`,white:`#e0e0e0`,brightBlack:`#6c757d`,brightRed:`#ff8787`,brightGreen:`#69db7c`,brightYellow:`#ffe066`,brightBlue:`#74c0fc`,brightMagenta:`#f3a6ff`,brightCyan:`#3bc9db`,brightWhite:`#ffffff`},v={background:`#F3F4F6`,foreground:`#1f2937`,cursor:`#0D7377`,cursorAccent:`#ffffff`,selectionBackground:`#0D737740`,selectionForeground:`#000000`,black:`#1f2937`,red:`#dc2626`,green:`#15803d`,yellow:`#a16207`,blue:`#0D7377`,magenta:`#7c3aed`,cyan:`#0e7490`,white:`#f3f4f6`,brightBlack:`#6b7280`,brightRed:`#ef4444`,brightGreen:`#22c55e`,brightYellow:`#eab308`,brightBlue:`#0891b2`,brightMagenta:`#a855f7`,brightCyan:`#06b6d4`,brightWhite:`#ffffff`},y=d(null),b=d(null),x=d(null),S=d(``),ee=d(!1),te=d(!1),C=d(!1),w=d(!1),T=d(!1);d(0);let E=null,D=null,ne=null,O=``,re=[],k=-1,A=``,j=new Gh,ie=j.getCommandNames(),ae=[],oe=0,se={get:[`name`,`role`,`lat`,`lon`,`freq`,`tx`,`bw`,`sf`,`cr`,`radio`,`txdelay`,`direct.txdelay`,`rxdelay`,`af`,`mode`,`repeat`,`flood.max`,`advert.interval`,`duty`,`duty.max`,`public.key`],set:[`tx`,`freq`,`bw`,`sf`,`cr`,`txdelay`,`direct.txdelay`,`rxdelay`,`name`,`lat`,`lon`,`mode`,`duty`,`flood.max`,`advert.interval`,`flood.advert.interval`],ping:[]},ce={set:{mode:[`forward`,`monitor`],duty:[`on`,`off`]}},le={get:{name:`Node name`,role:`Node role`,lat:`Latitude`,lon:`Longitude`,freq:`Frequency (MHz)`,tx:`TX power (dBm)`,bw:`Bandwidth (kHz)`,sf:`Spreading factor`,cr:`Coding rate`,radio:`All radio settings`,txdelay:`TX delay factor`,"direct.txdelay":`Direct TX delay`,rxdelay:`RX delay base`,af:`Airtime factor`,mode:`Repeater mode`,repeat:`Repeat on/off`,"flood.max":`Max flood hops`,"advert.interval":`Advert interval`,duty:`Duty cycle enabled`,"duty.max":`Max airtime %`,"public.key":`Public key`},set:{tx:`TX power (2-30 dBm)`,freq:`Frequency (100-1000 MHz) *restart required*`,bw:`Bandwidth (7.8-500 kHz) *restart required*`,sf:`Spreading factor (5-12) *restart required*`,cr:`Coding rate (5-8) *restart required*`,txdelay:`TX delay factor (0.0-5.0)`,"direct.txdelay":`Direct TX delay (0.0-5.0)`,rxdelay:`RX delay base (>= 0)`,name:`Node name`,lat:`Latitude (-90 to 90)`,lon:`Longitude (-180 to 180)`,mode:`Repeater mode (forward/monitor/no_tx)`,duty:`Duty cycle (on/off)`,"flood.max":`Max flood hops (0-64)`,"advert.interval":`Advert interval (0 or 1-10080 mins)`,"flood.advert.interval":`Flood advert (0 or 3-48 hrs)`},ping:{}};t(()=>{if(!y.value)return;C.value=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent),E=new Zs({cursorBlink:!1,cursorStyle:`underline`,cursorWidth:3,fontFamily:`"JetBrains Mono", "Fira Code", Menlo, Monaco, "Courier New", monospace`,fontSize:window.innerWidth<768?11:13,fontWeight:`400`,fontWeightBold:`700`,lineHeight:1.3,letterSpacing:.5,smoothScrollDuration:50,scrollSensitivity:3,fastScrollSensitivity:5,allowProposedApi:!0,screenReaderMode:C.value,theme:f.value===`dark`?p:v,scrollback:1e4,tabStopWidth:4,macOptionIsMeta:!0}),D=new ec,E.loadAddon(D);try{let e=new Eh;E.loadAddon(e)}catch{console.warn(`WebGL addon failed to load, falling back to canvas renderer`)}let t=new oc((e,t)=>{window.open(t,`_blank`)});E.loadAddon(t);let n=new Hu;if(E.loadAddon(n),E.unicode.activeVersion=`11`,ne=new Al,E.loadAddon(ne),E.open(y.value),D.fit(),E.focus(),C.value&&b.value){let t=b.value,n=()=>{t.focus({preventScroll:!1})};y.value?.addEventListener(`click`,n),y.value?.addEventListener(`touchstart`,n),t.addEventListener(`input`,()=>{setTimeout(()=>{E?.scrollToBottom()},10)}),e(()=>{y.value?.removeEventListener(`click`,n),y.value?.removeEventListener(`touchstart`,n)})}let r=f.value===`dark`?`\x1B[1;37m`:`\x1B[1;90m`;f.value;let i=(f.value,`\x1B[90m`),a=`\x1B[0m`;E.writeln(``),E.writeln(`${r} ██████ ██ ██ ███ ███ ██████${a}`),E.writeln(`${r} ██ ██ ██ ██ ████ ████ ██ ${a}`),E.writeln(`${r} ██████ ████ ██ ████ ██ ██ ${a}`),E.writeln(`${r} ██ ██ ██ ██ ██ ██ ${a}`),E.writeln(`${r} ██ ██ ██ ██ ██████${a}`),E.writeln(``),E.writeln(` Repeater Terminal${a}`),E.writeln(``),E.writeln(`${i} Type help${i} for available commands${a}`),E.writeln(``),M(),E.onData(e=>{fe(e)});let o=new ResizeObserver(()=>{D?.fit()});o.observe(y.value),e(()=>{o.disconnect(),E?.dispose()})});let M=()=>{E?.write(`\r +\x1B[1;36m❯\x1B[0m `)},ue=e=>{if(!(!E||!e)){E.write(`\x1b[90m${e}\x1b[0m`);for(let t=0;t{if(!(!E||!A)){for(let e=0;e{if(!E)return;let t=e.charCodeAt(0);if(t===13){de(),E.write(`\r +`),O.trim()?(N(O.trim()),re.push(O.trim()),k=re.length):M(),O=``;return}if(t===127){O.length>0&&(de(),O=O.slice(0,-1),E.write(`\b \b`),pe());return}if(t===3){de(),E.write(`^C\r +`),O=``,M();return}if(t===12){E.clear(),O=``,M();return}if(t===6){ee.value=!ee.value;return}if(e===`\x1B[A`){re.length>0&&k>0&&(de(),k--,E.write(`\r\x1B[K`),M(),O=re[k],E.write(O));return}if(e===`\x1B[B`){de(),k2&&ce[n]){let e=t[1]?.toLowerCase(),r=t.slice(2).join(` `).toLowerCase(),i=ce[n][e];if(i){let e=i.filter(e=>e.toLowerCase().startsWith(r));if(e.length===1){let n=t.slice(2).join(` `),r=e[0].slice(n.length);O+=r,E.write(r)}else e.length>1&&(E.write(`\r +\r +\x1B[33mAvailable values:\x1B[0m\r +\r +`),e.forEach(e=>{E.writeln(` \x1b[36m${e}\x1b[0m`)}),M(),E.write(O));return}}if(t.length>1&&se[n]){if(n===`ping`){let e=t.slice(1).join(` `).toLowerCase(),n=Date.now();n-oe>3e4&&Wh().then(e=>{ae=e,oe=n,se.ping=e});let r=ae.filter(t=>t.toLowerCase().startsWith(e));if(r.length===1){let e=t.slice(1).join(` `),n=r[0].slice(e.length)+` `;O+=n,E.write(n)}else r.length>1?(E.write(`\r +\r +\x1B[33mAvailable neighbors:\x1B[0m\r +\r +`),r.forEach(e=>{E.writeln(` \x1b[36m${e}\x1b[0m`)}),M(),E.write(O)):ae.length===0&&e===``&&(E.write(`\r +\r +\x1B[33mFetching neighbors...\x1B[0m\r +`),Wh().then(e=>{ae=e,oe=n,se.ping=e,E.write(`\r +\x1B[33mAvailable neighbors:\x1B[0m\r +\r +`),e.forEach(e=>{E.writeln(` \x1b[36m${e}\x1b[0m`)}),M(),E.write(O)}).catch(()=>{E.write(`\r +\x1B[31mFailed to fetch neighbors\x1B[0m\r +`),M(),E.write(O)}));return}let e=t.slice(1).join(` `).toLowerCase(),r=se[n].filter(t=>t.toLowerCase().startsWith(e));if(r.length===1){let e=t.slice(1).join(` `),n=r[0].slice(e.length)+` `;O+=n,E.write(n)}else if(r.length>1){E.write(`\r +\r +\x1B[33mAvailable parameters:\x1B[0m\r +\r +`);let e=le[n]||{};r.forEach(t=>{let n=e[t]||``,r=t.padEnd(20);E.writeln(` \x1b[36m${r}\x1b[0m\x1b[90m${n}\x1b[0m`)}),M(),E.write(O)}return}let r=j.getAllCommands().filter(t=>!!(t.name.toLowerCase().startsWith(e)||t.aliases?.some(t=>t.toLowerCase().startsWith(e))));if(r.length===1){let e=r[0].name.slice(O.length)+` `;O+=e,E.write(e)}else r.length>1&&(E.write(`\r +\r +\x1B[33mAvailable commands:\x1B[0m\r +\r +`),r.forEach(e=>{let t=e.aliases&&e.aliases.length>0?` (${e.aliases.join(`, `)})`:``;E.writeln(` \x1b[36m${e.name.padEnd(15)}\x1b[0m ${e.description}${t}`)}),M(),E.write(O));return}t>=32&&t<127&&(de(),O+=e,E.write(e),C.value||pe())},pe=()=>{if(O.length===0){A=``;return}let e=ie.filter(e=>e.startsWith(O.toLowerCase()));e.length===1&&e[0]!==O?(A=e[0].slice(O.length),ue(A)):A=``},N=async e=>{if(!E)return;let[t,...n]=e.trim().split(/\s+/),r=j.findCommand(t);if(r)try{await r.execute({term:E,args:n,writePrompt:M})}catch(e){console.error(`Command execution error:`,e),E.writeln(`\x1b[1;31m✗ Error:\x1b[0m ${e instanceof Error?e.message:`Command failed`}`),M()}else E.writeln(`\x1b[1;31m✗ Unknown command:\x1b[0m ${t}`),E.writeln(`\x1B[90mType \x1B[36mhelp\x1B[90m for available commands\x1B[0m`),M()},me=()=>{!ne||!S.value||ne.findNext(S.value,{caseSensitive:!1})},P=()=>{!ne||!S.value||ne.findPrevious(S.value,{caseSensitive:!1})},he=()=>{ee.value=!1,S.value=``,E?.focus()},ge=async()=>{if(x.value){if(w.value)try{document.exitFullscreen&&await document.exitFullscreen(),w.value=!1}catch(e){console.error(`Failed to exit fullscreen:`,e)}else try{x.value.requestFullscreen&&await x.value.requestFullscreen(),w.value=!0,setTimeout(()=>{C.value&&b.value?b.value.focus():E&&E.focus()},100)}catch(e){console.error(`Failed to enter fullscreen:`,e)}setTimeout(()=>{D?.fit()},100)}},_e=()=>{T.value=!T.value,T.value&&C.value&&setTimeout(()=>{window.scrollTo(0,1)},100),setTimeout(()=>{C.value&&b.value?b.value.focus():E?.focus(),D?.fit()},150)},ve=()=>{T.value=!1,setTimeout(()=>{D?.fit()},100)};a(f,e=>{E&&(E.options.theme=e===`dark`?p:v)}),typeof document<`u`&&(document.addEventListener(`fullscreenchange`,()=>{w.value=!!document.fullscreenElement,setTimeout(()=>D?.fit(),100)}),document.addEventListener(`keydown`,e=>{e.key===`Escape`&&T.value&&!w.value&&ve()}),document.addEventListener(`keydown`,e=>{e.key===`Escape`&&T.value&&!w.value&&ve()}));let ye=()=>{C.value&&b.value&&b.value.focus()},be=e=>{let t=e.target,n=t.value;n&&E&&fe(n.slice(-1)),t.value=``},xe=()=>{E&&fe(`\r`),b.value&&(b.value.value=``)},Se=()=>{E&&fe(``),b.value&&(b.value.value=``)};return(e,t)=>(u(),l(`div`,Kh,[c(`div`,qh,[c(`div`,Jh,[t[8]||=c(`div`,null,[c(`h1`,{class:`text-content-primary dark:text-content-primary text-lg md:text-xl font-semibold`},` Terminal `),c(`p`,{class:`text-content-secondary dark:text-content-muted text-sm hidden md:block`},` Interactive command-line interface `)],-1),c(`div`,Yh,[C.value?(u(),l(`button`,{key:0,onClick:_e,class:`flex items-center gap-2 px-3 py-2 bg-accent-purple/20 hover:bg-accent-purple/30 text-accent-purple border border-accent-purple/50 rounded-lg transition-colors`,title:T.value?`Exit fullscreen`:`Enter fullscreen`},[T.value?(u(),l(`svg`,Qh,[...t[3]||=[c(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(u(),l(`svg`,Zh,[...t[2]||=[c(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4`},null,-1)]])),c(`span`,$h,s(T.value?`Exit`:`Fullscreen`),1)],8,Xh)):o(``,!0),C.value?o(``,!0):(u(),l(`button`,{key:1,onClick:_e,class:`flex items-center gap-2 px-3 py-2 md:px-4 bg-accent-purple/20 hover:bg-accent-purple/30 text-accent-purple border border-accent-purple/50 rounded-lg transition-colors`,title:T.value?`Exit full window`:`Full window`},[t[4]||=c(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[c(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z`})],-1),c(`span`,tg,s(T.value?`Exit Window`:`Full Window`),1)],8,eg)),C.value?o(``,!0):(u(),l(`button`,{key:2,onClick:ge,class:`flex items-center gap-2 px-3 py-2 md:px-4 bg-accent-purple/20 hover:bg-accent-purple/30 text-accent-purple border border-accent-purple/50 rounded-lg transition-colors`,title:w.value?`Exit fullscreen`:`Fullscreen`},[w.value?(u(),l(`svg`,ig,[...t[6]||=[c(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(u(),l(`svg`,rg,[...t[5]||=[c(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4`},null,-1)]])),c(`span`,ag,s(w.value?`Exit Full`:`Fullscreen`),1)],8,ng)),c(`button`,{onClick:t[0]||=e=>ee.value=!ee.value,class:`flex items-center gap-2 px-3 py-2 md:px-4 bg-primary/20 hover:bg-primary/30 text-primary border border-primary/50 rounded-lg transition-colors`},[...t[7]||=[c(`svg`,{class:`w-4 h-4`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[c(`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),c(`span`,{class:`hidden sm:inline`},`Search`,-1)]])])])]),ee.value?(u(),l(`div`,og,[c(`div`,sg,[i(c(`input`,{"onUpdate:modelValue":t[1]||=e=>S.value=e,onKeydown:[_(me,[`enter`]),_(he,[`esc`])],type:`text`,placeholder:`Search terminal output...`,class:`flex-1 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 outline-none focus:border-primary/50 transition-colors`},null,544),[[h,S.value]]),c(`button`,{onClick:P,class:`px-3 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary transition-colors`,title:`Previous (Shift+Enter)`},` ↑ `),c(`button`,{onClick:me,class:`px-3 py-2 bg-primary/20 hover:bg-primary/30 border border-primary/50 rounded-lg text-primary transition-colors`,title:`Next (Enter)`},` ↓ `),c(`button`,{onClick:he,class:`px-3 py-2 bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 border border-stroke-subtle dark:border-stroke/10 rounded-lg text-content-primary dark:text-content-primary transition-colors`},` ✕ `)])])):o(``,!0),c(`div`,{ref_key:`terminalContainerRef`,ref:x,class:n([`bg-surface dark:bg-surface-elevated/80 backdrop-blur-xl border border-stroke-subtle dark:border-white/10 rounded-[15px] overflow-hidden relative`,{"fullscreen-terminal":w.value,"full-window-terminal":T.value}])},[T.value&&!w.value?(u(),l(`button`,{key:0,onClick:ve,class:`absolute top-4 right-4 z-50 p-2 bg-black/80 backdrop-blur-sm hover:bg-black/90 text-white border border-white/20 rounded-lg transition-colors`,title:`Exit full window (ESC)`},[...t[9]||=[c(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[c(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])):o(``,!0),c(`div`,{ref_key:`terminalRef`,ref:y,class:n([`terminal-container`,{"fullscreen-content":w.value}]),onClick:ye,onTouchstart:ye},[C.value?(u(),l(`input`,{key:0,ref_key:`mobileInputRef`,ref:b,type:`text`,class:`mobile-keyboard-input`,onInput:be,onKeydown:[_(g(xe,[`prevent`]),[`enter`]),_(Se,[`delete`])],inputmode:`text`,autocomplete:`off`,autocorrect:`off`,autocapitalize:`off`,spellcheck:`false`},null,40,cg)):o(``,!0)],34),te.value?(u(),l(`div`,lg,[...t[10]||=[c(`div`,{class:`w-2 h-2 bg-primary rounded-full animate-pulse`},null,-1),c(`span`,{class:`text-primary text-sm font-medium`},`Processing...`,-1)]])):o(``,!0)],2)]))}}),[[`__scopeId`,`data-v-e270e599`]]);export{ug as default}; \ No newline at end of file diff --git a/repeater/web/html/assets/Terminal-tmed9q5z.css b/repeater/web/html/assets/Terminal-tmed9q5z.css new file mode 100644 index 0000000..abb5b36 --- /dev/null +++ b/repeater/web/html/assets/Terminal-tmed9q5z.css @@ -0,0 +1 @@ +.xterm{cursor:text;-webkit-user-select:none;user-select:none;position:relative}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{z-index:5;position:absolute;top:0}.xterm .xterm-helper-textarea{opacity:0;z-index:-5;white-space:nowrap;resize:none;border:0;width:0;height:0;margin:0;padding:0;position:absolute;top:0;left:-9999em;overflow:hidden}.xterm .composition-view{color:#fff;white-space:nowrap;z-index:1;background:#000;display:none;position:absolute}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{cursor:default;background-color:#000;position:absolute;inset:0;overflow-y:scroll}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;top:0;left:0}.xterm-char-measure-element{visibility:hidden;line-height:normal;display:inline-block;position:absolute;top:0;left:-9999em}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{z-index:10;color:#0000;pointer-events:none;position:absolute;inset:0}.xterm .xterm-accessibility-tree:not(.debug) ::selection{color:#0000}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre;font-family:monospace}.xterm .xterm-accessibility-tree>div{transform-origin:0;width:fit-content}.xterm .live-region{width:1px;height:1px;position:absolute;left:-9999px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{-webkit-text-decoration:underline double;text-decoration:underline double}.xterm-underline-3{-webkit-text-decoration:underline wavy;text-decoration:underline wavy}.xterm-underline-4{-webkit-text-decoration:underline dotted;text-decoration:underline dotted}.xterm-underline-5{-webkit-text-decoration:underline dashed;text-decoration:underline dashed}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:underline overline}.xterm-overline.xterm-underline-2{-webkit-text-decoration:overline double underline;text-decoration:overline double underline}.xterm-overline.xterm-underline-3{-webkit-text-decoration:overline wavy underline;text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{-webkit-text-decoration:overline dotted underline;text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{-webkit-text-decoration:overline dashed underline;text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;pointer-events:none;position:absolute;top:0;right:0}.xterm-decoration-top{z-index:2;position:relative}.xterm .xterm-scrollable-element>.scrollbar{cursor:default}.xterm .xterm-scrollable-element>.scrollbar>.scra{cursor:pointer;font-size:11px!important}.xterm .xterm-scrollable-element>.visible{opacity:1;z-index:11;background:0 0;transition:opacity .1s linear}.xterm .xterm-scrollable-element>.invisible{opacity:0;pointer-events:none}.xterm .xterm-scrollable-element>.invisible.fade{transition:opacity .8s linear}.xterm .xterm-scrollable-element>.shadow{display:none;position:absolute}.xterm .xterm-scrollable-element>.shadow.top{width:100%;height:3px;box-shadow:var(--vscode-scrollbar-shadow,#000) 0 6px 6px -6px inset;display:block;top:0;left:3px}.xterm .xterm-scrollable-element>.shadow.left{width:3px;height:100%;box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset;display:block;top:3px;left:0}.xterm .xterm-scrollable-element>.shadow.top-left-corner{width:3px;height:3px;display:block;top:0;left:0}.xterm .xterm-scrollable-element>.shadow.top.left{box-shadow:var(--vscode-scrollbar-shadow,#000) 6px 0 6px -6px inset}.terminal-container[data-v-e270e599]{background-color:var(--color-surface);height:calc(100vh - 220px);min-height:calc(100dvh - 220px)}@media (width<=768px){.terminal-container[data-v-e270e599]{height:calc(100vh - 140px);min-height:calc(100dvh - 140px)}}@media (width<=640px){.terminal-container[data-v-e270e599]{height:calc(100vh - 120px);min-height:calc(100dvh - 120px)}}[data-v-e270e599] .xterm{padding:1.5rem;height:100%!important}@media (width<=768px){[data-v-e270e599] .xterm{padding:1rem}}@media (width<=640px){[data-v-e270e599] .xterm{padding:.75rem}}[data-v-e270e599] .xterm-viewport,[data-v-e270e599] .xterm-screen{background-color:#0000!important}[data-v-e270e599] .xterm-selection{background-color:#00d9ff4d!important}kbd[data-v-e270e599]{font-family:Menlo,Monaco,Courier New,monospace;box-shadow:0 2px 4px #0003}.mobile-keyboard-input[data-v-e270e599]{opacity:.01;pointer-events:none;z-index:9999;border:none;width:1px;height:1px;margin:0;padding:0;position:absolute;bottom:0;left:0}.fullscreen-terminal[data-v-e270e599]{z-index:9999!important;background-color:var(--color-surface)!important;border-radius:0!important;width:100vw!important;height:100dvh!important;margin:0!important;position:fixed!important;inset:0!important}.fullscreen-content[data-v-e270e599]{height:100%!important;min-height:100%!important}.fullscreen-terminal[data-v-e270e599] .xterm{padding:2rem}@media (width<=768px){.fullscreen-terminal[data-v-e270e599] .xterm{padding:1rem}}.full-window-terminal[data-v-e270e599]{z-index:9998;inset:0;overflow:hidden;background-color:var(--color-surface)!important;border-radius:0!important;width:100vw!important;max-width:100vw!important;height:100dvh!important;max-height:100dvh!important;margin:0!important;position:fixed!important}.full-window-terminal .terminal-container[data-v-e270e599]{overflow:auto;width:100vw!important;height:100dvh!important}.full-window-terminal[data-v-e270e599] .xterm{padding:1rem;height:100%!important}@media (width<=768px){.full-window-terminal[data-v-e270e599]{touch-action:none}.full-window-terminal .terminal-container[data-v-e270e599]{overscroll-behavior:none}.full-window-terminal[data-v-e270e599] .xterm{padding:.75rem}} diff --git a/repeater/web/html/assets/_plugin-vue_export-helper-B7aGp3iI.js b/repeater/web/html/assets/_plugin-vue_export-helper-B7aGp3iI.js new file mode 100644 index 0000000..4374bdd --- /dev/null +++ b/repeater/web/html/assets/_plugin-vue_export-helper-B7aGp3iI.js @@ -0,0 +1 @@ +var e=(e,t)=>{let n=e.__vccOpts||e;for(let[e,r]of t)n[e]=r;return n};export{e as t}; \ No newline at end of file diff --git a/repeater/web/html/assets/api-CbM6k1ZB.js b/repeater/web/html/assets/api-CbM6k1ZB.js new file mode 100644 index 0000000..0b84a7c --- /dev/null +++ b/repeater/web/html/assets/api-CbM6k1ZB.js @@ -0,0 +1,7 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Setup-DvdSE7ue.js","assets/_plugin-vue_export-helper-B7aGp3iI.js","assets/index-BFltqMtv.js","assets/runtime-core.esm-bundler-HnidnMFy.js","assets/vue-router-Cr0wB7EX.js","assets/useTheme-DMOVV09x.js","assets/packets-C-dzvp0W.js","assets/system-BH4r-ii6.js","assets/websocket-nXR7EYbj.js","assets/index-Crl6CjFg.css","assets/Setup-DiRq9fgD.css","assets/Login-Yx7HUvzW.js","assets/Login-CRioMgum.css","assets/Dashboard-ClL05x7j.js","assets/chart-B1uYMRrx.js","assets/useSignalQuality-BfZWbBxN.js","assets/preferences-Bv8i60GL.js","assets/Dashboard-BLK8l9Tc.css","assets/Neighbors-CQcUQfDG.js","assets/chunk-DECur_0Z.js","assets/leaflet-src-PYB8oVmQ.js","assets/Neighbors-Cfo189NY.css","assets/leaflet-vh-t_kPv.css","assets/Statistics-S4HgWYku.js","assets/chartjs-adapter-date-fns.esm-DnBoPdP1.js","assets/chartjs-adapter-date-fns-BqJ94ASW.css","assets/plotly.min-Dl7ekyci.js","assets/Statistics-CsAO5q_U.css","assets/SystemStats-4wDqjB6x.js","assets/SystemStats-Dnc1_s5j.css","assets/Configuration-BoG9PyTQ.js","assets/ConfirmDialog-PLW-eI8u.js","assets/Configuration-zQuuYGWe.css","assets/CADCalibration-CK9zSc8M.js","assets/CADCalibration-gZQwotT3.css","assets/Sessions-DhR0b50N.js","assets/RoomServers-Cngso7KV.js","assets/MessageDialog-CEzYMZ-3.js","assets/Companions-Cm95T8nb.js","assets/Logs-DiVYCMnG.js","assets/Terminal-Dpu_GlNL.js","assets/Terminal-tmed9q5z.css","assets/Help-CaIFoQMt.js","assets/websocket-NnYyxr--.js","assets/packets-B_GG5R7y.js","assets/system-Cl32lKH8.js"])))=>i.map(i=>d[i]); +import{n as e}from"./chunk-DECur_0Z.js";import{o as t,z as n}from"./runtime-core.esm-bundler-HnidnMFy.js";import{n as r,o as i,t as a}from"./vue-router-Cr0wB7EX.js";function o(e,t){return function(){return e.apply(t,arguments)}}var{toString:s}=Object.prototype,{getPrototypeOf:c}=Object,{iterator:l,toStringTag:u}=Symbol,d=(e=>t=>{let n=s.call(t);return e[n]||(e[n]=n.slice(8,-1).toLowerCase())})(Object.create(null)),f=e=>(e=e.toLowerCase(),t=>d(t)===e),p=e=>t=>typeof t===e,{isArray:m}=Array,h=p(`undefined`);function g(e){return e!==null&&!h(e)&&e.constructor!==null&&!h(e.constructor)&&b(e.constructor.isBuffer)&&e.constructor.isBuffer(e)}var _=f(`ArrayBuffer`);function v(e){let t;return t=typeof ArrayBuffer<`u`&&ArrayBuffer.isView?ArrayBuffer.isView(e):e&&e.buffer&&_(e.buffer),t}var y=p(`string`),b=p(`function`),x=p(`number`),S=e=>typeof e==`object`&&!!e,C=e=>e===!0||e===!1,w=e=>{if(d(e)!==`object`)return!1;let t=c(e);return(t===null||t===Object.prototype||Object.getPrototypeOf(t)===null)&&!(u in e)&&!(l in e)},ee=e=>{if(!S(e)||g(e))return!1;try{return Object.keys(e).length===0&&Object.getPrototypeOf(e)===Object.prototype}catch{return!1}},te=f(`Date`),ne=f(`File`),re=e=>!!(e&&e.uri!==void 0),ie=e=>e&&e.getParts!==void 0,ae=f(`Blob`),oe=f(`FileList`),se=e=>S(e)&&b(e.pipe);function ce(){return typeof globalThis<`u`?globalThis:typeof self<`u`?self:typeof window<`u`?window:typeof global<`u`?global:{}}var le=ce(),ue=le.FormData===void 0?void 0:le.FormData,de=e=>{let t;return e&&(ue&&e instanceof ue||b(e.append)&&((t=d(e))===`formdata`||t===`object`&&b(e.toString)&&e.toString()===`[object FormData]`))},fe=f(`URLSearchParams`),[pe,me,he,ge]=[`ReadableStream`,`Request`,`Response`,`Headers`].map(f),_e=e=>e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,``);function T(e,t,{allOwnKeys:n=!1}={}){if(e==null)return;let r,i;if(typeof e!=`object`&&(e=[e]),m(e))for(r=0,i=e.length;r0;)if(i=n[r],t===i.toLowerCase())return i;return null}var E=typeof globalThis<`u`?globalThis:typeof self<`u`?self:typeof window<`u`?window:global,ye=e=>!h(e)&&e!==E;function be(){let{caseless:e,skipUndefined:t}=ye(this)&&this||{},n={},r=(r,i)=>{if(i===`__proto__`||i===`constructor`||i===`prototype`)return;let a=e&&ve(n,i)||i;w(n[a])&&w(r)?n[a]=be(n[a],r):w(r)?n[a]=be({},r):m(r)?n[a]=r.slice():(!t||!h(r))&&(n[a]=r)};for(let e=0,t=arguments.length;e(T(t,(t,r)=>{n&&b(t)?Object.defineProperty(e,r,{value:o(t,n),writable:!0,enumerable:!0,configurable:!0}):Object.defineProperty(e,r,{value:t,writable:!0,enumerable:!0,configurable:!0})},{allOwnKeys:r}),e),Se=e=>(e.charCodeAt(0)===65279&&(e=e.slice(1)),e),Ce=(e,t,n,r)=>{e.prototype=Object.create(t.prototype,r),Object.defineProperty(e.prototype,`constructor`,{value:e,writable:!0,enumerable:!1,configurable:!0}),Object.defineProperty(e,`super`,{value:t.prototype}),n&&Object.assign(e.prototype,n)},we=(e,t,n,r)=>{let i,a,o,s={};if(t||={},e==null)return t;do{for(i=Object.getOwnPropertyNames(e),a=i.length;a-- >0;)o=i[a],(!r||r(o,e,t))&&!s[o]&&(t[o]=e[o],s[o]=!0);e=n!==!1&&c(e)}while(e&&(!n||n(e,t))&&e!==Object.prototype);return t},Te=(e,t,n)=>{e=String(e),(n===void 0||n>e.length)&&(n=e.length),n-=t.length;let r=e.indexOf(t,n);return r!==-1&&r===n},Ee=e=>{if(!e)return null;if(m(e))return e;let t=e.length;if(!x(t))return null;let n=Array(t);for(;t-- >0;)n[t]=e[t];return n},De=(e=>t=>e&&t instanceof e)(typeof Uint8Array<`u`&&c(Uint8Array)),Oe=(e,t)=>{let n=(e&&e[l]).call(e),r;for(;(r=n.next())&&!r.done;){let n=r.value;t.call(e,n[0],n[1])}},ke=(e,t)=>{let n,r=[];for(;(n=e.exec(t))!==null;)r.push(n);return r},Ae=f(`HTMLFormElement`),je=e=>e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(e,t,n){return t.toUpperCase()+n}),Me=(({hasOwnProperty:e})=>(t,n)=>e.call(t,n))(Object.prototype),Ne=f(`RegExp`),Pe=(e,t)=>{let n=Object.getOwnPropertyDescriptors(e),r={};T(n,(n,i)=>{let a;(a=t(n,i,e))!==!1&&(r[i]=a||n)}),Object.defineProperties(e,r)},Fe=e=>{Pe(e,(t,n)=>{if(b(e)&&[`arguments`,`caller`,`callee`].indexOf(n)!==-1)return!1;let r=e[n];if(b(r)){if(t.enumerable=!1,`writable`in t){t.writable=!1;return}t.set||=()=>{throw Error(`Can not rewrite read-only method '`+n+`'`)}}})},Ie=(e,t)=>{let n={},r=e=>{e.forEach(e=>{n[e]=!0})};return m(e)?r(e):r(String(e).split(t)),n},Le=()=>{},Re=(e,t)=>e!=null&&Number.isFinite(e=+e)?e:t;function ze(e){return!!(e&&b(e.append)&&e[u]===`FormData`&&e[l])}var Be=e=>{let t=Array(10),n=(e,r)=>{if(S(e)){if(t.indexOf(e)>=0)return;if(g(e))return e;if(!(`toJSON`in e)){t[r]=e;let i=m(e)?[]:{};return T(e,(e,t)=>{let a=n(e,r+1);!h(a)&&(i[t]=a)}),t[r]=void 0,i}}return e};return n(e,0)},Ve=f(`AsyncFunction`),He=e=>e&&(S(e)||b(e))&&b(e.then)&&b(e.catch),Ue=((e,t)=>e?setImmediate:t?((e,t)=>(E.addEventListener(`message`,({source:n,data:r})=>{n===E&&r===e&&t.length&&t.shift()()},!1),n=>{t.push(n),E.postMessage(e,`*`)}))(`axios@${Math.random()}`,[]):e=>setTimeout(e))(typeof setImmediate==`function`,b(E.postMessage)),D={isArray:m,isArrayBuffer:_,isBuffer:g,isFormData:de,isArrayBufferView:v,isString:y,isNumber:x,isBoolean:C,isObject:S,isPlainObject:w,isEmptyObject:ee,isReadableStream:pe,isRequest:me,isResponse:he,isHeaders:ge,isUndefined:h,isDate:te,isFile:ne,isReactNativeBlob:re,isReactNative:ie,isBlob:ae,isRegExp:Ne,isFunction:b,isStream:se,isURLSearchParams:fe,isTypedArray:De,isFileList:oe,forEach:T,merge:be,extend:xe,trim:_e,stripBOM:Se,inherits:Ce,toFlatObject:we,kindOf:d,kindOfTest:f,endsWith:Te,toArray:Ee,forEachEntry:Oe,matchAll:ke,isHTMLForm:Ae,hasOwnProperty:Me,hasOwnProp:Me,reduceDescriptors:Pe,freezeMethods:Fe,toObjectSet:Ie,toCamelCase:je,noop:Le,toFiniteNumber:Re,findKey:ve,global:E,isContextDefined:ye,isSpecCompliantForm:ze,toJSONObject:Be,isAsyncFn:Ve,isThenable:He,setImmediate:Ue,asap:typeof queueMicrotask<`u`?queueMicrotask.bind(E):typeof process<`u`&&process.nextTick||Ue,isIterable:e=>e!=null&&b(e[l])},O=class e extends Error{static from(t,n,r,i,a,o){let s=new e(t.message,n||t.code,r,i,a);return s.cause=t,s.name=t.name,t.status!=null&&s.status==null&&(s.status=t.status),o&&Object.assign(s,o),s}constructor(e,t,n,r,i){super(e),Object.defineProperty(this,`message`,{value:e,enumerable:!0,writable:!0,configurable:!0}),this.name=`AxiosError`,this.isAxiosError=!0,t&&(this.code=t),n&&(this.config=n),r&&(this.request=r),i&&(this.response=i,this.status=i.status)}toJSON(){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:D.toJSONObject(this.config),code:this.code,status:this.status}}};O.ERR_BAD_OPTION_VALUE=`ERR_BAD_OPTION_VALUE`,O.ERR_BAD_OPTION=`ERR_BAD_OPTION`,O.ECONNABORTED=`ECONNABORTED`,O.ETIMEDOUT=`ETIMEDOUT`,O.ERR_NETWORK=`ERR_NETWORK`,O.ERR_FR_TOO_MANY_REDIRECTS=`ERR_FR_TOO_MANY_REDIRECTS`,O.ERR_DEPRECATED=`ERR_DEPRECATED`,O.ERR_BAD_RESPONSE=`ERR_BAD_RESPONSE`,O.ERR_BAD_REQUEST=`ERR_BAD_REQUEST`,O.ERR_CANCELED=`ERR_CANCELED`,O.ERR_NOT_SUPPORT=`ERR_NOT_SUPPORT`,O.ERR_INVALID_URL=`ERR_INVALID_URL`;function k(e){return D.isPlainObject(e)||D.isArray(e)}function We(e){return D.endsWith(e,`[]`)?e.slice(0,-2):e}function A(e,t,n){return e?e.concat(t).map(function(e,t){return e=We(e),!n&&t?`[`+e+`]`:e}).join(n?`.`:``):t}function Ge(e){return D.isArray(e)&&!e.some(k)}var Ke=D.toFlatObject(D,{},null,function(e){return/^is[A-Z]/.test(e)});function j(e,t,n){if(!D.isObject(e))throw TypeError(`target must be an object`);t||=new FormData,n=D.toFlatObject(n,{metaTokens:!0,dots:!1,indexes:!1},!1,function(e,t){return!D.isUndefined(t[e])});let r=n.metaTokens,i=n.visitor||l,a=n.dots,o=n.indexes,s=(n.Blob||typeof Blob<`u`&&Blob)&&D.isSpecCompliantForm(t);if(!D.isFunction(i))throw TypeError(`visitor must be a function`);function c(e){if(e===null)return``;if(D.isDate(e))return e.toISOString();if(D.isBoolean(e))return e.toString();if(!s&&D.isBlob(e))throw new O(`Blob is not supported. Use a Buffer instead.`);return D.isArrayBuffer(e)||D.isTypedArray(e)?s&&typeof Blob==`function`?new Blob([e]):Buffer.from(e):e}function l(e,n,i){let s=e;if(D.isReactNative(t)&&D.isReactNativeBlob(e))return t.append(A(i,n,a),c(e)),!1;if(e&&!i&&typeof e==`object`){if(D.endsWith(n,`{}`))n=r?n:n.slice(0,-2),e=JSON.stringify(e);else if(D.isArray(e)&&Ge(e)||(D.isFileList(e)||D.endsWith(n,`[]`))&&(s=D.toArray(e)))return n=We(n),s.forEach(function(e,r){!(D.isUndefined(e)||e===null)&&t.append(o===!0?A([n],r,a):o===null?n:n+`[]`,c(e))}),!1}return k(e)?!0:(t.append(A(i,n,a),c(e)),!1)}let u=[],d=Object.assign(Ke,{defaultVisitor:l,convertValue:c,isVisitable:k});function f(e,n){if(!D.isUndefined(e)){if(u.indexOf(e)!==-1)throw Error(`Circular reference detected in `+n.join(`.`));u.push(e),D.forEach(e,function(e,r){(!(D.isUndefined(e)||e===null)&&i.call(t,e,D.isString(r)?r.trim():r,n,d))===!0&&f(e,n?n.concat(r):[r])}),u.pop()}}if(!D.isObject(e))throw TypeError(`data must be an object`);return f(e),t}function qe(e){let t={"!":`%21`,"'":`%27`,"(":`%28`,")":`%29`,"~":`%7E`,"%20":`+`,"%00":`\0`};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,function(e){return t[e]})}function Je(e,t){this._pairs=[],e&&j(e,this,t)}var Ye=Je.prototype;Ye.append=function(e,t){this._pairs.push([e,t])},Ye.toString=function(e){let t=e?function(t){return e.call(this,t,qe)}:qe;return this._pairs.map(function(e){return t(e[0])+`=`+t(e[1])},``).join(`&`)};function Xe(e){return encodeURIComponent(e).replace(/%3A/gi,`:`).replace(/%24/g,`$`).replace(/%2C/gi,`,`).replace(/%20/g,`+`)}function Ze(e,t,n){if(!t)return e;let r=n&&n.encode||Xe,i=D.isFunction(n)?{serialize:n}:n,a=i&&i.serialize,o;if(o=a?a(t,i):D.isURLSearchParams(t)?t.toString():new Je(t,i).toString(r),o){let t=e.indexOf(`#`);t!==-1&&(e=e.slice(0,t)),e+=(e.indexOf(`?`)===-1?`?`:`&`)+o}return e}var Qe=class{constructor(){this.handlers=[]}use(e,t,n){return this.handlers.push({fulfilled:e,rejected:t,synchronous:n?n.synchronous:!1,runWhen:n?n.runWhen:null}),this.handlers.length-1}eject(e){this.handlers[e]&&(this.handlers[e]=null)}clear(){this.handlers&&=[]}forEach(e){D.forEach(this.handlers,function(t){t!==null&&e(t)})}},$e={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1,legacyInterceptorReqResOrdering:!0},et={isBrowser:!0,classes:{URLSearchParams:typeof URLSearchParams<`u`?URLSearchParams:Je,FormData:typeof FormData<`u`?FormData:null,Blob:typeof Blob<`u`?Blob:null},protocols:[`http`,`https`,`file`,`blob`,`url`,`data`]},tt=e({hasBrowserEnv:()=>nt,hasStandardBrowserEnv:()=>it,hasStandardBrowserWebWorkerEnv:()=>at,navigator:()=>rt,origin:()=>ot}),nt=typeof window<`u`&&typeof document<`u`,rt=typeof navigator==`object`&&navigator||void 0,it=nt&&(!rt||[`ReactNative`,`NativeScript`,`NS`].indexOf(rt.product)<0),at=typeof WorkerGlobalScope<`u`&&self instanceof WorkerGlobalScope&&typeof self.importScripts==`function`,ot=nt&&window.location.href||`http://localhost`,M={...tt,...et};function st(e,t){return j(e,new M.classes.URLSearchParams,{visitor:function(e,t,n,r){return M.isNode&&D.isBuffer(e)?(this.append(t,e.toString(`base64`)),!1):r.defaultVisitor.apply(this,arguments)},...t})}function ct(e){return D.matchAll(/\w+|\[(\w*)]/g,e).map(e=>e[0]===`[]`?``:e[1]||e[0])}function lt(e){let t={},n=Object.keys(e),r,i=n.length,a;for(r=0;r=e.length;return a=!a&&D.isArray(r)?r.length:a,s?(D.hasOwnProp(r,a)?r[a]=[r[a],n]:r[a]=n,!o):((!r[a]||!D.isObject(r[a]))&&(r[a]=[]),t(e,n,r[a],i)&&D.isArray(r[a])&&(r[a]=lt(r[a])),!o)}if(D.isFormData(e)&&D.isFunction(e.entries)){let n={};return D.forEachEntry(e,(e,r)=>{t(ct(e),r,n,0)}),n}return null}function dt(e,t,n){if(D.isString(e))try{return(t||JSON.parse)(e),D.trim(e)}catch(e){if(e.name!==`SyntaxError`)throw e}return(n||JSON.stringify)(e)}var N={transitional:$e,adapter:[`xhr`,`http`,`fetch`],transformRequest:[function(e,t){let n=t.getContentType()||``,r=n.indexOf(`application/json`)>-1,i=D.isObject(e);if(i&&D.isHTMLForm(e)&&(e=new FormData(e)),D.isFormData(e))return r?JSON.stringify(ut(e)):e;if(D.isArrayBuffer(e)||D.isBuffer(e)||D.isStream(e)||D.isFile(e)||D.isBlob(e)||D.isReadableStream(e))return e;if(D.isArrayBufferView(e))return e.buffer;if(D.isURLSearchParams(e))return t.setContentType(`application/x-www-form-urlencoded;charset=utf-8`,!1),e.toString();let a;if(i){if(n.indexOf(`application/x-www-form-urlencoded`)>-1)return st(e,this.formSerializer).toString();if((a=D.isFileList(e))||n.indexOf(`multipart/form-data`)>-1){let t=this.env&&this.env.FormData;return j(a?{"files[]":e}:e,t&&new t,this.formSerializer)}}return i||r?(t.setContentType(`application/json`,!1),dt(e)):e}],transformResponse:[function(e){let t=this.transitional||N.transitional,n=t&&t.forcedJSONParsing,r=this.responseType===`json`;if(D.isResponse(e)||D.isReadableStream(e))return e;if(e&&D.isString(e)&&(n&&!this.responseType||r)){let n=!(t&&t.silentJSONParsing)&&r;try{return JSON.parse(e,this.parseReviver)}catch(e){if(n)throw e.name===`SyntaxError`?O.from(e,O.ERR_BAD_RESPONSE,this,null,this.response):e}}return e}],timeout:0,xsrfCookieName:`XSRF-TOKEN`,xsrfHeaderName:`X-XSRF-TOKEN`,maxContentLength:-1,maxBodyLength:-1,env:{FormData:M.classes.FormData,Blob:M.classes.Blob},validateStatus:function(e){return e>=200&&e<300},headers:{common:{Accept:`application/json, text/plain, */*`,"Content-Type":void 0}}};D.forEach([`delete`,`get`,`head`,`post`,`put`,`patch`],e=>{N.headers[e]={}});var ft=D.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`]),pt=e=>{let t={},n,r,i;return e&&e.split(` +`).forEach(function(e){i=e.indexOf(`:`),n=e.substring(0,i).trim().toLowerCase(),r=e.substring(i+1).trim(),!(!n||t[n]&&ft[n])&&(n===`set-cookie`?t[n]?t[n].push(r):t[n]=[r]:t[n]=t[n]?t[n]+`, `+r:r)}),t},mt=Symbol(`internals`);function P(e){return e&&String(e).trim().toLowerCase()}function F(e){return e===!1||e==null?e:D.isArray(e)?e.map(F):String(e).replace(/[\r\n]+$/,``)}function ht(e){let t=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g,r;for(;r=n.exec(e);)t[r[1]]=r[2];return t}var gt=e=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim());function _t(e,t,n,r,i){if(D.isFunction(r))return r.call(this,t,n);if(i&&(t=n),D.isString(t)){if(D.isString(r))return t.indexOf(r)!==-1;if(D.isRegExp(r))return r.test(t)}}function vt(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(e,t,n)=>t.toUpperCase()+n)}function yt(e,t){let n=D.toCamelCase(` `+t);[`get`,`set`,`has`].forEach(r=>{Object.defineProperty(e,r+n,{value:function(e,n,i){return this[r].call(this,t,e,n,i)},configurable:!0})})}var I=class{constructor(e){e&&this.set(e)}set(e,t,n){let r=this;function i(e,t,n){let i=P(t);if(!i)throw Error(`header name must be a non-empty string`);let a=D.findKey(r,i);(!a||r[a]===void 0||n===!0||n===void 0&&r[a]!==!1)&&(r[a||t]=F(e))}let a=(e,t)=>D.forEach(e,(e,n)=>i(e,n,t));if(D.isPlainObject(e)||e instanceof this.constructor)a(e,t);else if(D.isString(e)&&(e=e.trim())&&!gt(e))a(pt(e),t);else if(D.isObject(e)&&D.isIterable(e)){let n={},r,i;for(let t of e){if(!D.isArray(t))throw TypeError(`Object iterator must return a key-value pair`);n[i=t[0]]=(r=n[i])?D.isArray(r)?[...r,t[1]]:[r,t[1]]:t[1]}a(n,t)}else e!=null&&i(t,e,n);return this}get(e,t){if(e=P(e),e){let n=D.findKey(this,e);if(n){let e=this[n];if(!t)return e;if(t===!0)return ht(e);if(D.isFunction(t))return t.call(this,e,n);if(D.isRegExp(t))return t.exec(e);throw TypeError(`parser must be boolean|regexp|function`)}}}has(e,t){if(e=P(e),e){let n=D.findKey(this,e);return!!(n&&this[n]!==void 0&&(!t||_t(this,this[n],n,t)))}return!1}delete(e,t){let n=this,r=!1;function i(e){if(e=P(e),e){let i=D.findKey(n,e);i&&(!t||_t(n,n[i],i,t))&&(delete n[i],r=!0)}}return D.isArray(e)?e.forEach(i):i(e),r}clear(e){let t=Object.keys(this),n=t.length,r=!1;for(;n--;){let i=t[n];(!e||_t(this,this[i],i,e,!0))&&(delete this[i],r=!0)}return r}normalize(e){let t=this,n={};return D.forEach(this,(r,i)=>{let a=D.findKey(n,i);if(a){t[a]=F(r),delete t[i];return}let o=e?vt(i):String(i).trim();o!==i&&delete t[i],t[o]=F(r),n[o]=!0}),this}concat(...e){return this.constructor.concat(this,...e)}toJSON(e){let t=Object.create(null);return D.forEach(this,(n,r)=>{n!=null&&n!==!1&&(t[r]=e&&D.isArray(n)?n.join(`, `):n)}),t}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([e,t])=>e+`: `+t).join(` +`)}getSetCookie(){return this.get(`set-cookie`)||[]}get[Symbol.toStringTag](){return`AxiosHeaders`}static from(e){return e instanceof this?e:new this(e)}static concat(e,...t){let n=new this(e);return t.forEach(e=>n.set(e)),n}static accessor(e){let t=(this[mt]=this[mt]={accessors:{}}).accessors,n=this.prototype;function r(e){let r=P(e);t[r]||(yt(n,e),t[r]=!0)}return D.isArray(e)?e.forEach(r):r(e),this}};I.accessor([`Content-Type`,`Content-Length`,`Accept`,`Accept-Encoding`,`User-Agent`,`Authorization`]),D.reduceDescriptors(I.prototype,({value:e},t)=>{let n=t[0].toUpperCase()+t.slice(1);return{get:()=>e,set(e){this[n]=e}}}),D.freezeMethods(I);function L(e,t){let n=this||N,r=t||n,i=I.from(r.headers),a=r.data;return D.forEach(e,function(e){a=e.call(n,a,i.normalize(),t?t.status:void 0)}),i.normalize(),a}function bt(e){return!!(e&&e.__CANCEL__)}var R=class extends O{constructor(e,t,n){super(e??`canceled`,O.ERR_CANCELED,t,n),this.name=`CanceledError`,this.__CANCEL__=!0}};function xt(e,t,n){let r=n.config.validateStatus;!n.status||!r||r(n.status)?e(n):t(new O(`Request failed with status code `+n.status,[O.ERR_BAD_REQUEST,O.ERR_BAD_RESPONSE][Math.floor(n.status/100)-4],n.config,n.request,n))}function St(e){let t=/^([-+\w]{1,25})(:?\/\/|:)/.exec(e);return t&&t[1]||``}function Ct(e,t){e||=10;let n=Array(e),r=Array(e),i=0,a=0,o;return t=t===void 0?1e3:t,function(s){let c=Date.now(),l=r[a];o||=c,n[i]=s,r[i]=c;let u=a,d=0;for(;u!==i;)d+=n[u++],u%=e;if(i=(i+1)%e,i===a&&(a=(a+1)%e),c-o{n=r,i=null,a&&=(clearTimeout(a),null),e(...t)};return[(...e)=>{let t=Date.now(),s=t-n;s>=r?o(e,t):(i=e,a||=setTimeout(()=>{a=null,o(i)},r-s))},()=>i&&o(i)]}var z=(e,t,n=3)=>{let r=0,i=Ct(50,250);return wt(n=>{let a=n.loaded,o=n.lengthComputable?n.total:void 0,s=a-r,c=i(s),l=a<=o;r=a,e({loaded:a,total:o,progress:o?a/o:void 0,bytes:s,rate:c||void 0,estimated:c&&o&&l?(o-a)/c:void 0,event:n,lengthComputable:o!=null,[t?`download`:`upload`]:!0})},n)},Tt=(e,t)=>{let n=e!=null;return[r=>t[0]({lengthComputable:n,total:e,loaded:r}),t[1]]},Et=e=>(...t)=>D.asap(()=>e(...t)),Dt=M.hasStandardBrowserEnv?((e,t)=>n=>(n=new URL(n,M.origin),e.protocol===n.protocol&&e.host===n.host&&(t||e.port===n.port)))(new URL(M.origin),M.navigator&&/(msie|trident)/i.test(M.navigator.userAgent)):()=>!0,Ot=M.hasStandardBrowserEnv?{write(e,t,n,r,i,a,o){if(typeof document>`u`)return;let s=[`${e}=${encodeURIComponent(t)}`];D.isNumber(n)&&s.push(`expires=${new Date(n).toUTCString()}`),D.isString(r)&&s.push(`path=${r}`),D.isString(i)&&s.push(`domain=${i}`),a===!0&&s.push(`secure`),D.isString(o)&&s.push(`SameSite=${o}`),document.cookie=s.join(`; `)},read(e){if(typeof document>`u`)return null;let t=document.cookie.match(RegExp(`(?:^|; )`+e+`=([^;]*)`));return t?decodeURIComponent(t[1]):null},remove(e){this.write(e,``,Date.now()-864e5,`/`)}}:{write(){},read(){return null},remove(){}};function kt(e){return typeof e==`string`?/^([a-z][a-z\d+\-.]*:)?\/\//i.test(e):!1}function At(e,t){return t?e.replace(/\/?\/$/,``)+`/`+t.replace(/^\/+/,``):e}function jt(e,t,n){let r=!kt(t);return e&&(r||n==0)?At(e,t):t}var Mt=e=>e instanceof I?{...e}:e;function B(e,t){t||={};let n={};function r(e,t,n,r){return D.isPlainObject(e)&&D.isPlainObject(t)?D.merge.call({caseless:r},e,t):D.isPlainObject(t)?D.merge({},t):D.isArray(t)?t.slice():t}function i(e,t,n,i){if(!D.isUndefined(t))return r(e,t,n,i);if(!D.isUndefined(e))return r(void 0,e,n,i)}function a(e,t){if(!D.isUndefined(t))return r(void 0,t)}function o(e,t){if(!D.isUndefined(t))return r(void 0,t);if(!D.isUndefined(e))return r(void 0,e)}function s(n,i,a){if(a in t)return r(n,i);if(a in e)return r(void 0,n)}let c={url:a,method:a,data:a,baseURL:o,transformRequest:o,transformResponse:o,paramsSerializer:o,timeout:o,timeoutMessage:o,withCredentials:o,withXSRFToken:o,adapter:o,responseType:o,xsrfCookieName:o,xsrfHeaderName:o,onUploadProgress:o,onDownloadProgress:o,decompress:o,maxContentLength:o,maxBodyLength:o,beforeRedirect:o,transport:o,httpAgent:o,httpsAgent:o,cancelToken:o,socketPath:o,responseEncoding:o,validateStatus:s,headers:(e,t,n)=>i(Mt(e),Mt(t),n,!0)};return D.forEach(Object.keys({...e,...t}),function(r){if(r===`__proto__`||r===`constructor`||r===`prototype`)return;let a=D.hasOwnProp(c,r)?c[r]:i,o=a(e[r],t[r],r);D.isUndefined(o)&&a!==s||(n[r]=o)}),n}var Nt=e=>{let t=B({},e),{data:n,withXSRFToken:r,xsrfHeaderName:i,xsrfCookieName:a,headers:o,auth:s}=t;if(t.headers=o=I.from(o),t.url=Ze(jt(t.baseURL,t.url,t.allowAbsoluteUrls),e.params,e.paramsSerializer),s&&o.set(`Authorization`,`Basic `+btoa((s.username||``)+`:`+(s.password?unescape(encodeURIComponent(s.password)):``))),D.isFormData(n)){if(M.hasStandardBrowserEnv||M.hasStandardBrowserWebWorkerEnv)o.setContentType(void 0);else if(D.isFunction(n.getHeaders)){let e=n.getHeaders(),t=[`content-type`,`content-length`];Object.entries(e).forEach(([e,n])=>{t.includes(e.toLowerCase())&&o.set(e,n)})}}if(M.hasStandardBrowserEnv&&(r&&D.isFunction(r)&&(r=r(t)),r||r!==!1&&Dt(t.url))){let e=i&&a&&Ot.read(a);e&&o.set(i,e)}return t},Pt=typeof XMLHttpRequest<`u`&&function(e){return new Promise(function(t,n){let r=Nt(e),i=r.data,a=I.from(r.headers).normalize(),{responseType:o,onUploadProgress:s,onDownloadProgress:c}=r,l,u,d,f,p;function m(){f&&f(),p&&p(),r.cancelToken&&r.cancelToken.unsubscribe(l),r.signal&&r.signal.removeEventListener(`abort`,l)}let h=new XMLHttpRequest;h.open(r.method.toUpperCase(),r.url,!0),h.timeout=r.timeout;function g(){if(!h)return;let r=I.from(`getAllResponseHeaders`in h&&h.getAllResponseHeaders());xt(function(e){t(e),m()},function(e){n(e),m()},{data:!o||o===`text`||o===`json`?h.responseText:h.response,status:h.status,statusText:h.statusText,headers:r,config:e,request:h}),h=null}`onloadend`in h?h.onloadend=g:h.onreadystatechange=function(){!h||h.readyState!==4||h.status===0&&!(h.responseURL&&h.responseURL.indexOf(`file:`)===0)||setTimeout(g)},h.onabort=function(){h&&=(n(new O(`Request aborted`,O.ECONNABORTED,e,h)),null)},h.onerror=function(t){let r=new O(t&&t.message?t.message:`Network Error`,O.ERR_NETWORK,e,h);r.event=t||null,n(r),h=null},h.ontimeout=function(){let t=r.timeout?`timeout of `+r.timeout+`ms exceeded`:`timeout exceeded`,i=r.transitional||$e;r.timeoutErrorMessage&&(t=r.timeoutErrorMessage),n(new O(t,i.clarifyTimeoutError?O.ETIMEDOUT:O.ECONNABORTED,e,h)),h=null},i===void 0&&a.setContentType(null),`setRequestHeader`in h&&D.forEach(a.toJSON(),function(e,t){h.setRequestHeader(t,e)}),D.isUndefined(r.withCredentials)||(h.withCredentials=!!r.withCredentials),o&&o!==`json`&&(h.responseType=r.responseType),c&&([d,p]=z(c,!0),h.addEventListener(`progress`,d)),s&&h.upload&&([u,f]=z(s),h.upload.addEventListener(`progress`,u),h.upload.addEventListener(`loadend`,f)),(r.cancelToken||r.signal)&&(l=t=>{h&&=(n(!t||t.type?new R(null,e,h):t),h.abort(),null)},r.cancelToken&&r.cancelToken.subscribe(l),r.signal&&(r.signal.aborted?l():r.signal.addEventListener(`abort`,l)));let _=St(r.url);if(_&&M.protocols.indexOf(_)===-1){n(new O(`Unsupported protocol `+_+`:`,O.ERR_BAD_REQUEST,e));return}h.send(i||null)})},Ft=(e,t)=>{let{length:n}=e=e?e.filter(Boolean):[];if(t||n){let n=new AbortController,r,i=function(e){if(!r){r=!0,o();let t=e instanceof Error?e:this.reason;n.abort(t instanceof O?t:new R(t instanceof Error?t.message:t))}},a=t&&setTimeout(()=>{a=null,i(new O(`timeout of ${t}ms exceeded`,O.ETIMEDOUT))},t),o=()=>{e&&=(a&&clearTimeout(a),a=null,e.forEach(e=>{e.unsubscribe?e.unsubscribe(i):e.removeEventListener(`abort`,i)}),null)};e.forEach(e=>e.addEventListener(`abort`,i));let{signal:s}=n;return s.unsubscribe=()=>D.asap(o),s}},It=function*(e,t){let n=e.byteLength;if(!t||n{let i=Lt(e,t),a=0,o,s=e=>{o||(o=!0,r&&r(e))};return new ReadableStream({async pull(e){try{let{done:t,value:r}=await i.next();if(t){s(),e.close();return}let o=r.byteLength;n&&n(a+=o),e.enqueue(new Uint8Array(r))}catch(e){throw s(e),e}},cancel(e){return s(e),i.return()}},{highWaterMark:2})},Bt=64*1024,{isFunction:V}=D,Vt=(({Request:e,Response:t})=>({Request:e,Response:t}))(D.global),{ReadableStream:Ht,TextEncoder:Ut}=D.global,Wt=(e,...t)=>{try{return!!e(...t)}catch{return!1}},Gt=e=>{e=D.merge.call({skipUndefined:!0},Vt,e);let{fetch:t,Request:n,Response:r}=e,i=t?V(t):typeof fetch==`function`,a=V(n),o=V(r);if(!i)return!1;let s=i&&V(Ht),c=i&&(typeof Ut==`function`?(e=>t=>e.encode(t))(new Ut):async e=>new Uint8Array(await new n(e).arrayBuffer())),l=a&&s&&Wt(()=>{let e=!1,t=new Ht,r=new n(M.origin,{body:t,method:`POST`,get duplex(){return e=!0,`half`}}).headers.has(`Content-Type`);return t.cancel(),e&&!r}),u=o&&s&&Wt(()=>D.isReadableStream(new r(``).body)),d={stream:u&&(e=>e.body)};i&&[`text`,`arrayBuffer`,`blob`,`formData`,`stream`].forEach(e=>{!d[e]&&(d[e]=(t,n)=>{let r=t&&t[e];if(r)return r.call(t);throw new O(`Response type '${e}' is not supported`,O.ERR_NOT_SUPPORT,n)})});let f=async e=>{if(e==null)return 0;if(D.isBlob(e))return e.size;if(D.isSpecCompliantForm(e))return(await new n(M.origin,{method:`POST`,body:e}).arrayBuffer()).byteLength;if(D.isArrayBufferView(e)||D.isArrayBuffer(e))return e.byteLength;if(D.isURLSearchParams(e)&&(e+=``),D.isString(e))return(await c(e)).byteLength},p=async(e,t)=>D.toFiniteNumber(e.getContentLength())??f(t);return async e=>{let{url:i,method:o,data:s,signal:c,cancelToken:f,timeout:m,onDownloadProgress:h,onUploadProgress:g,responseType:_,headers:v,withCredentials:y=`same-origin`,fetchOptions:b}=Nt(e),x=t||fetch;_=_?(_+``).toLowerCase():`text`;let S=Ft([c,f&&f.toAbortSignal()],m),C=null,w=S&&S.unsubscribe&&(()=>{S.unsubscribe()}),ee;try{if(g&&l&&o!==`get`&&o!==`head`&&(ee=await p(v,s))!==0){let e=new n(i,{method:`POST`,body:s,duplex:`half`}),t;if(D.isFormData(s)&&(t=e.headers.get(`content-type`))&&v.setContentType(t),e.body){let[t,n]=Tt(ee,z(Et(g)));s=zt(e.body,Bt,t,n)}}D.isString(y)||(y=y?`include`:`omit`);let t=a&&`credentials`in n.prototype,c={...b,signal:S,method:o.toUpperCase(),headers:v.normalize().toJSON(),body:s,duplex:`half`,credentials:t?y:void 0};C=a&&new n(i,c);let f=await(a?x(C,b):x(i,c)),m=u&&(_===`stream`||_===`response`);if(u&&(h||m&&w)){let e={};[`status`,`statusText`,`headers`].forEach(t=>{e[t]=f[t]});let t=D.toFiniteNumber(f.headers.get(`content-length`)),[n,i]=h&&Tt(t,z(Et(h),!0))||[];f=new r(zt(f.body,Bt,n,()=>{i&&i(),w&&w()}),e)}_||=`text`;let te=await d[D.findKey(d,_)||`text`](f,e);return!m&&w&&w(),await new Promise((t,n)=>{xt(t,n,{data:te,headers:I.from(f.headers),status:f.status,statusText:f.statusText,config:e,request:C})})}catch(t){throw w&&w(),t&&t.name===`TypeError`&&/Load failed|fetch/i.test(t.message)?Object.assign(new O(`Network Error`,O.ERR_NETWORK,e,C,t&&t.response),{cause:t.cause||t}):O.from(t,t&&t.code,e,C,t&&t.response)}}},Kt=new Map,qt=e=>{let t=e&&e.env||{},{fetch:n,Request:r,Response:i}=t,a=[r,i,n],o=a.length,s,c,l=Kt;for(;o--;)s=a[o],c=l.get(s),c===void 0&&l.set(s,c=o?new Map:Gt(t)),l=c;return c};qt();var Jt={http:null,xhr:Pt,fetch:{get:qt}};D.forEach(Jt,(e,t)=>{if(e){try{Object.defineProperty(e,`name`,{value:t})}catch{}Object.defineProperty(e,`adapterName`,{value:t})}});var Yt=e=>`- ${e}`,Xt=e=>D.isFunction(e)||e===null||e===!1;function Zt(e,t){e=D.isArray(e)?e:[e];let{length:n}=e,r,i,a={};for(let o=0;o`adapter ${e} `+(t===!1?`is not supported by the environment`:`is not available in the build`));throw new O(`There is no suitable adapter to dispatch the request `+(n?e.length>1?`since : +`+e.map(Yt).join(` +`):` `+Yt(e[0]):`as no adapter specified`),`ERR_NOT_SUPPORT`)}return i}var Qt={getAdapter:Zt,adapters:Jt};function $t(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new R(null,e)}function en(e){return $t(e),e.headers=I.from(e.headers),e.data=L.call(e,e.transformRequest),[`post`,`put`,`patch`].indexOf(e.method)!==-1&&e.headers.setContentType(`application/x-www-form-urlencoded`,!1),Qt.getAdapter(e.adapter||N.adapter,e)(e).then(function(t){return $t(e),t.data=L.call(e,e.transformResponse,t),t.headers=I.from(t.headers),t},function(t){return bt(t)||($t(e),t&&t.response&&(t.response.data=L.call(e,e.transformResponse,t.response),t.response.headers=I.from(t.response.headers))),Promise.reject(t)})}var tn=`1.14.0`,H={};[`object`,`boolean`,`number`,`function`,`string`,`symbol`].forEach((e,t)=>{H[e]=function(n){return typeof n===e||`a`+(t<1?`n `:` `)+e}});var nn={};H.transitional=function(e,t,n){function r(e,t){return`[Axios v`+tn+`] Transitional option '`+e+`'`+t+(n?`. `+n:``)}return(n,i,a)=>{if(e===!1)throw new O(r(i,` has been removed`+(t?` in `+t:``)),O.ERR_DEPRECATED);return t&&!nn[i]&&(nn[i]=!0,console.warn(r(i,` has been deprecated since v`+t+` and will be removed in the near future`))),e?e(n,i,a):!0}},H.spelling=function(e){return(t,n)=>(console.warn(`${n} is likely a misspelling of ${e}`),!0)};function rn(e,t,n){if(typeof e!=`object`)throw new O(`options must be an object`,O.ERR_BAD_OPTION_VALUE);let r=Object.keys(e),i=r.length;for(;i-- >0;){let a=r[i],o=t[a];if(o){let t=e[a],n=t===void 0||o(t,a,e);if(n!==!0)throw new O(`option `+a+` must be `+n,O.ERR_BAD_OPTION_VALUE);continue}if(n!==!0)throw new O(`Unknown option `+a,O.ERR_BAD_OPTION)}}var U={assertOptions:rn,validators:H},W=U.validators,G=class{constructor(e){this.defaults=e||{},this.interceptors={request:new Qe,response:new Qe}}async request(e,t){try{return await this._request(e,t)}catch(e){if(e instanceof Error){let t={};Error.captureStackTrace?Error.captureStackTrace(t):t=Error();let n=t.stack?t.stack.replace(/^.+\n/,``):``;try{e.stack?n&&!String(e.stack).endsWith(n.replace(/^.+\n.+\n/,``))&&(e.stack+=` +`+n):e.stack=n}catch{}}throw e}}_request(e,t){typeof e==`string`?(t||={},t.url=e):t=e||{},t=B(this.defaults,t);let{transitional:n,paramsSerializer:r,headers:i}=t;n!==void 0&&U.assertOptions(n,{silentJSONParsing:W.transitional(W.boolean),forcedJSONParsing:W.transitional(W.boolean),clarifyTimeoutError:W.transitional(W.boolean),legacyInterceptorReqResOrdering:W.transitional(W.boolean)},!1),r!=null&&(D.isFunction(r)?t.paramsSerializer={serialize:r}:U.assertOptions(r,{encode:W.function,serialize:W.function},!0)),t.allowAbsoluteUrls!==void 0||(this.defaults.allowAbsoluteUrls===void 0?t.allowAbsoluteUrls=!0:t.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls),U.assertOptions(t,{baseUrl:W.spelling(`baseURL`),withXsrfToken:W.spelling(`withXSRFToken`)},!0),t.method=(t.method||this.defaults.method||`get`).toLowerCase();let a=i&&D.merge(i.common,i[t.method]);i&&D.forEach([`delete`,`get`,`head`,`post`,`put`,`patch`,`common`],e=>{delete i[e]}),t.headers=I.concat(a,i);let o=[],s=!0;this.interceptors.request.forEach(function(e){if(typeof e.runWhen==`function`&&e.runWhen(t)===!1)return;s&&=e.synchronous;let n=t.transitional||$e;n&&n.legacyInterceptorReqResOrdering?o.unshift(e.fulfilled,e.rejected):o.push(e.fulfilled,e.rejected)});let c=[];this.interceptors.response.forEach(function(e){c.push(e.fulfilled,e.rejected)});let l,u=0,d;if(!s){let e=[en.bind(this),void 0];for(e.unshift(...o),e.push(...c),d=e.length,l=Promise.resolve(t);u{if(!n._listeners)return;let t=n._listeners.length;for(;t-- >0;)n._listeners[t](e);n._listeners=null}),this.promise.then=e=>{let t,r=new Promise(e=>{n.subscribe(e),t=e}).then(e);return r.cancel=function(){n.unsubscribe(t)},r},e(function(e,r,i){n.reason||(n.reason=new R(e,r,i),t(n.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(e){if(this.reason){e(this.reason);return}this._listeners?this._listeners.push(e):this._listeners=[e]}unsubscribe(e){if(!this._listeners)return;let t=this._listeners.indexOf(e);t!==-1&&this._listeners.splice(t,1)}toAbortSignal(){let e=new AbortController,t=t=>{e.abort(t)};return this.subscribe(t),e.signal.unsubscribe=()=>this.unsubscribe(t),e.signal}static source(){let t;return{token:new e(function(e){t=e}),cancel:t}}};function on(e){return function(t){return e.apply(null,t)}}function sn(e){return D.isObject(e)&&e.isAxiosError===!0}var cn={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(cn).forEach(([e,t])=>{cn[t]=e});function ln(e){let t=new G(e),n=o(G.prototype.request,t);return D.extend(n,G.prototype,t,{allOwnKeys:!0}),D.extend(n,t,null,{allOwnKeys:!0}),n.create=function(t){return ln(B(e,t))},n}var K=ln(N);K.Axios=G,K.CanceledError=R,K.CancelToken=an,K.isCancel=bt,K.VERSION=tn,K.toFormData=j,K.AxiosError=O,K.Cancel=K.CanceledError,K.all=function(e){return Promise.all(e)},K.spread=on,K.isAxiosError=sn,K.mergeConfig=B,K.AxiosHeaders=I,K.formToJSON=e=>ut(D.isHTMLForm(e)?new FormData(e):e),K.getAdapter=Qt.getAdapter,K.HttpStatusCode=cn,K.default=K;var un=`pymc_jwt_token`,dn=`pymc_client_id`;function fn(){let e=localStorage.getItem(dn);return e||(e=`${Date.now()}-${Math.random().toString(36).substring(2,15)}`,localStorage.setItem(dn,e)),e}function q(){return localStorage.getItem(un)}function pn(e){localStorage.setItem(un,e)}function mn(){localStorage.removeItem(un)}function hn(){return q()!==null}function gn(e){try{let t=e.split(`.`)[1].replace(/-/g,`+`).replace(/_/g,`/`),n=decodeURIComponent(atob(t).split(``).map(e=>`%`+(`00`+e.charCodeAt(0).toString(16)).slice(-2)).join(``));return JSON.parse(n)}catch{return null}}function J(){let e=q();if(!e)return!0;let t=gn(e);return!t||!t.exp?!0:Date.now()>=t.exp*1e3-3e4}function _n(){let e=q();if(!e)return!1;let t=gn(e);if(!t||!t.exp)return!1;let n=t.exp*1e3-Date.now();return n>0&&n<3e5}function vn(){let e=q();if(!e)return null;let t=gn(e);return!t||!t.sub?null:t.sub}var yn=`modulepreload`,bn=function(e){return`/`+e},xn={},Y=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=bn(t,n),t in xn)return;xn[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:yn,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},X=a({history:r(`/`),routes:[{path:`/setup`,name:`setup`,component:()=>Y(()=>import(`./Setup-DvdSE7ue.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10])),meta:{requiresAuth:!1,requiresSetup:!1}},{path:`/login`,name:`login`,component:()=>Y(()=>import(`./Login-Yx7HUvzW.js`),__vite__mapDeps([11,1,2,3,4,5,6,7,8,9,12])),meta:{requiresAuth:!1}},{path:`/`,name:`dashboard`,component:()=>Y(()=>import(`./Dashboard-ClL05x7j.js`),__vite__mapDeps([13,1,2,3,4,5,6,7,8,9,14,15,16,17])),meta:{requiresAuth:!0}},{path:`/neighbors`,name:`neighbors`,component:()=>Y(()=>import(`./Neighbors-CQcUQfDG.js`),__vite__mapDeps([18,1,19,2,3,4,5,6,7,8,9,20,15,16,21,22])),meta:{requiresAuth:!0}},{path:`/statistics`,name:`statistics`,component:()=>Y(()=>import(`./Statistics-S4HgWYku.js`),__vite__mapDeps([23,1,19,2,3,4,5,6,7,8,9,14,24,25,26,16,27])),meta:{requiresAuth:!0}},{path:`/system-stats`,name:`system-stats`,component:()=>Y(()=>import(`./SystemStats-4wDqjB6x.js`),__vite__mapDeps([28,1,19,14,3,24,25,29])),meta:{requiresAuth:!0}},{path:`/configuration`,name:`configuration`,component:()=>Y(()=>import(`./Configuration-BoG9PyTQ.js`),__vite__mapDeps([30,1,19,2,3,4,5,6,7,8,9,31,16,32,22])),meta:{requiresAuth:!0}},{path:`/cad-calibration`,name:`cad-calibration`,component:()=>Y(()=>import(`./CADCalibration-CK9zSc8M.js`),__vite__mapDeps([33,1,19,3,26,7,4,34])),meta:{requiresAuth:!0}},{path:`/sessions`,name:`sessions`,component:()=>Y(()=>import(`./Sessions-DhR0b50N.js`),__vite__mapDeps([35,2,1,3,4,5,6,7,8,9])),meta:{requiresAuth:!0}},{path:`/room-servers`,name:`room-servers`,component:()=>Y(()=>import(`./RoomServers-Cngso7KV.js`),__vite__mapDeps([36,2,1,3,4,5,6,7,8,9,31,37,16])),meta:{requiresAuth:!0}},{path:`/companions`,name:`companions`,component:()=>Y(()=>import(`./Companions-Cm95T8nb.js`),__vite__mapDeps([38,2,1,3,4,5,6,7,8,9,31,37])),meta:{requiresAuth:!0}},{path:`/logs`,name:`logs`,component:()=>Y(()=>import(`./Logs-DiVYCMnG.js`),__vite__mapDeps([39,3])),meta:{requiresAuth:!0}},{path:`/terminal`,name:`terminal`,component:()=>Y(()=>import(`./Terminal-Dpu_GlNL.js`),__vite__mapDeps([40,1,2,3,4,5,6,7,8,9,41])),meta:{requiresAuth:!0}},{path:`/help`,name:`help`,component:()=>Y(()=>import(`./Help-CaIFoQMt.js`),__vite__mapDeps([42,3])),meta:{requiresAuth:!0}}]});async function Sn(){try{let e=await fetch(`/api/needs_setup`,{headers:{Accept:`application/json`}});return e.ok?(await e.json()).needs_setup===!0:(console.error(`Setup check failed:`,e.status),!1)}catch(e){return console.error(`Error checking setup status:`,e),!1}}X.beforeEach(async(e,t,n)=>{let r=e.meta.requiresAuth!==!1,i=hn();if(e.path!==`/setup`&&await Sn()){n(`/setup`);return}if(e.path===`/setup`&&!await Sn()){n(`/login`);return}r&&!i?n(`/login`):e.path===`/login`&&i?n(`/`):n()});var Z=i(`appRuntime`,()=>{let e=n(typeof navigator>`u`?!0:navigator.onLine),r=n(typeof document>`u`?!0:document.visibilityState===`visible`),i=n(!1),a=n(null),o=n(!1),s=t(()=>e.value&&r.value&&i.value&&!o.value);function c(){i.value=!!q()&&!J(),i.value||(a.value=a.value??`expired`)}function l(){i.value=!0,a.value=null,o.value=!1}function u(t){e.value=t}function d(e){r.value=e}async function f(e){if(o.value)return;o.value=!0,a.value=e,i.value=!1;let{useWebSocketStore:t}=await Y(async()=>{let{useWebSocketStore:e}=await import(`./websocket-NnYyxr--.js`);return{useWebSocketStore:e}},__vite__mapDeps([43,8,3,4,6,7])),{usePacketStore:n}=await Y(async()=>{let{usePacketStore:e}=await import(`./packets-B_GG5R7y.js`);return{usePacketStore:e}},__vite__mapDeps([44,6,3,4])),{useSystemStore:r}=await Y(async()=>{let{useSystemStore:e}=await import(`./system-Cl32lKH8.js`);return{useSystemStore:e}},__vite__mapDeps([45,7,3,4])),s=t(),c=n(),l=r();s.disconnect({preventReconnect:!0,silent:e!==`logout`}),c.reset(),l.reset(),mn(),X.currentRoute.value.path!==`/login`&&await X.push(`/login`),o.value=!1}async function p(e){await f(e)}return{isOnline:e,isDocumentVisible:r,isAuthenticated:i,authFailureReason:a,canMaintainConnections:s,syncAuthState:c,markAuthenticated:l,setOnline:u,setDocumentVisible:d,stopSession:f,handleAuthFailure:p}}),Cn=`/api`,wn=!1,Q=null;async function Tn(){return wn&&Q?Q:(wn=!0,Q=(async()=>{try{let e=q();if(!e)throw Error(`No token to refresh`);let t=fn(),n=await K.post(`/auth/refresh`,{client_id:t},{headers:{Authorization:`Bearer ${e}`,"Content-Type":`application/json`}});if(n.data.success&&n.data.token){let e=n.data.token;return pn(e),e}else throw Error(`Token refresh failed`)}catch(e){throw console.error(`Token refresh error:`,e),await Z().handleAuthFailure(`expired`),e}finally{wn=!1,Q=null}})(),Q)}var $=K.create({baseURL:Cn,timeout:5e3,headers:{"Content-Type":`application/json`}}),En=K.create({baseURL:``,timeout:5e3,headers:{"Content-Type":`application/json`}});En.interceptors.request.use(async e=>{if(e.url?.includes(`/auth/login`)||e.url?.includes(`/auth/refresh`))return e;let t=q();if(t){if(_n())try{let t=await Tn();return e.headers.Authorization=`Bearer ${t}`,e}catch(e){return Promise.reject(e)}if(J())return Z().handleAuthFailure(`expired`),Promise.reject(Error(`Token expired`));e.headers.Authorization=`Bearer ${t}`}return e},e=>(console.error(`Auth API Request Error:`,e),Promise.reject(e))),En.interceptors.response.use(e=>e,e=>((e.response?.status===401||e.response?.status===403)&&Z().handleAuthFailure(e.response?.status===403?`forbidden`:`unauthorized`),console.error(`Auth API Response Error:`,e.response?.data||e.message),Promise.reject(e))),$.interceptors.request.use(async e=>{if(e.url?.includes(`/auth/login`))return e;let t=q();if(t){if(_n())try{let t=await Tn();return e.headers.Authorization=`Bearer ${t}`,e}catch(e){return Promise.reject(e)}if(J())return Z().handleAuthFailure(`expired`),Promise.reject(Error(`Token expired`));e.headers.Authorization=`Bearer ${t}`}return e},e=>(console.error(`API Request Error:`,e),Promise.reject(e))),$.interceptors.response.use(e=>e,e=>((e.response?.status===401||e.response?.status===403)&&Z().handleAuthFailure(e.response?.status===403?`forbidden`:`unauthorized`),console.error(`API Response Error:`,e.response?.data||e.message),Promise.reject(e)));var Dn=class{static async get(e,t){try{return(await $.get(e,{params:t})).data}catch(e){throw this.handleError(e)}}static async post(e,t,n){try{return(await $.post(e,t,n)).data}catch(e){throw this.handleError(e)}}static async put(e,t,n){try{return(await $.put(e,t,n)).data}catch(e){throw this.handleError(e)}}static async delete(e,t){try{return(await $.delete(e,t)).data}catch(e){throw this.handleError(e)}}static async getTransportKeys(){return this.get(`transport_keys`)}static async sendAdvert(){return this.post(`send_advert`,{},{headers:{"Content-Type":`application/json`}})}static async createTransportKey(e,t,n,r,i){let a={name:e,flood_policy:t,parent_id:r,last_used:i};return n!==void 0&&(a.transport_key=n),this.post(`transport_keys`,a)}static async getTransportKey(e){return this.get(`transport_key/${e}`)}static async updateTransportKey(e,t,n,r,i,a){return this.put(`transport_key/${e}`,{name:t,flood_policy:n,transport_key:r,parent_id:i,last_used:a})}static async deleteTransportKey(e){return this.delete(`transport_key/${e}`)}static async updateUnscopedFloodPolicy(e){return this.post(`unscoped_flood_policy`,{unscoped_flood_allow:e})}static async getLogs(){try{return(await $.get(`logs`)).data}catch(e){throw this.handleError(e)}}static async deleteAdvert(e){return this.delete(`advert/${e}`)}static async pingNeighbor(e,t=10){return this.post(`ping_neighbor`,{target_id:e,timeout:t})}static async getIdentities(){return this.get(`identities`)}static async getIdentity(e){return this.get(`identity`,{name:e})}static async createIdentity(e){return this.post(`create_identity`,e)}static async updateIdentity(e){return this.put(`update_identity`,e)}static async deleteIdentity(e,t=`room_server`){let n=new URLSearchParams({name:e});return t===`companion`&&n.set(`type`,`companion`),this.delete(`delete_identity?${n.toString()}`)}static async sendRoomServerAdvert(e){return this.post(`send_room_server_advert`,{name:e})}static async importRepeaterContacts(e){return this.post(`companion/import_repeater_contacts`,e)}static async getACLInfo(){return this.get(`acl_info`)}static async getACLClients(e){return this.get(`acl_clients`,e)}static async removeACLClient(e){return this.post(`acl_remove_client`,e)}static async getACLStats(){return this.get(`acl_stats`)}static async getRoomMessages(e){return this.get(`room_messages`,e)}static async postRoomMessage(e){return this.post(`room_post_message`,e)}static async deleteRoomMessage(e){return this.delete(`room_message?room_name=${encodeURIComponent(e.room_name)}&message_id=${e.message_id}`)}static async clearRoomMessages(e){return this.delete(`room_messages?room_name=${encodeURIComponent(e)}`)}static async getRoomStats(e){return this.get(`room_stats`,e?{room_name:e}:void 0)}static async getRoomClients(e){return this.get(`room_clients`,{room_name:e})}static async exportConfig(e=!1){let t=e?`config_export?include_secrets=true`:`config_export`;return this.get(t)}static async importConfig(e){return this.post(`config_import`,{config:e})}static async exportIdentityKey(){return this.get(`identity_export`)}static async generateVanityKey(e,t=!1){return this.post(`generate_vanity_key`,{prefix:e,apply:t})}static async getDbStats(){return this.get(`db_stats`)}static async purgeTable(e){return this.post(`db_purge`,{tables:e})}static async vacuumDb(){return this.post(`db_vacuum`,{})}static handleError(e){if(K.isAxiosError(e)){if(e.response){let t=e.response.data?.error||e.response.data?.message||`HTTP ${e.response.status}`;return Error(t)}else if(e.request)return Error(`Network error - no response received`)}return Error(e instanceof Error?e.message:`Unknown error occurred`)}};export{Y as a,q as c,J as d,pn as f,X as i,vn as l,En as n,mn as o,Z as r,fn as s,Dn as t,hn as u}; \ No newline at end of file diff --git a/repeater/web/html/assets/chart-B1uYMRrx.js b/repeater/web/html/assets/chart-B1uYMRrx.js new file mode 100644 index 0000000..bc5f155 --- /dev/null +++ b/repeater/web/html/assets/chart-B1uYMRrx.js @@ -0,0 +1,3 @@ +function e(e){return e+.5|0}var t=(e,t,n)=>Math.max(Math.min(e,n),t);function n(n){return t(e(n*2.55),0,255)}function r(n){return t(e(n*255),0,255)}function i(n){return t(e(n/2.55)/100,0,1)}function a(n){return t(e(n*100),0,100)}var o={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},s=[...`0123456789ABCDEF`],c=e=>s[e&15],l=e=>s[(e&240)>>4]+s[e&15],u=e=>(e&240)>>4==(e&15),d=e=>u(e.r)&&u(e.g)&&u(e.b)&&u(e.a);function f(e){var t=e.length,n;return e[0]===`#`&&(t===4||t===5?n={r:255&o[e[1]]*17,g:255&o[e[2]]*17,b:255&o[e[3]]*17,a:t===5?o[e[4]]*17:255}:(t===7||t===9)&&(n={r:o[e[1]]<<4|o[e[2]],g:o[e[3]]<<4|o[e[4]],b:o[e[5]]<<4|o[e[6]],a:t===9?o[e[7]]<<4|o[e[8]]:255})),n}var p=(e,t)=>e<255?t(e):``;function m(e){var t=d(e)?c:l;return e?`#`+t(e.r)+t(e.g)+t(e.b)+p(e.a,t):void 0}var h=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function g(e,t,n){let r=t*Math.min(n,1-n),i=(t,i=(t+e/30)%12)=>n-r*Math.max(Math.min(i-3,9-i,1),-1);return[i(0),i(8),i(4)]}function _(e,t,n){let r=(r,i=(r+e/60)%6)=>n-n*t*Math.max(Math.min(i,4-i,1),0);return[r(5),r(3),r(1)]}function v(e,t,n){let r=g(e,1,.5),i;for(t+n>1&&(i=1/(t+n),t*=i,n*=i),i=0;i<3;i++)r[i]*=1-t-n,r[i]+=t;return r}function y(e,t,n,r,i){return e===i?(t-n)/r+(t.5?l/(2-i-a):l/(i+a),s=y(t,n,r,l,i),s=s*60+.5),[s|0,c||0,o]}function x(e,t,n,i){return(Array.isArray(t)?e(t[0],t[1],t[2]):e(t,n,i)).map(r)}function S(e,t,n){return x(g,e,t,n)}function C(e,t,n){return x(v,e,t,n)}function w(e,t,n){return x(_,e,t,n)}function T(e){return(e%360+360)%360}function E(e){let t=h.exec(e),i=255,a;if(!t)return;t[5]!==a&&(i=t[6]?n(+t[5]):r(+t[5]));let o=T(+t[2]),s=t[3]/100,c=t[4]/100;return a=t[1]===`hwb`?C(o,s,c):t[1]===`hsv`?w(o,s,c):S(o,s,c),{r:a[0],g:a[1],b:a[2],a:i}}function D(e,t){var n=b(e);n[0]=T(n[0]+t),n=S(n),e.r=n[0],e.g=n[1],e.b=n[2]}function ee(e){if(!e)return;let t=b(e),n=t[0],r=a(t[1]),o=a(t[2]);return e.a<255?`hsla(${n}, ${r}%, ${o}%, ${i(e.a)})`:`hsl(${n}, ${r}%, ${o}%)`}var O={x:`dark`,Z:`light`,Y:`re`,X:`blu`,W:`gr`,V:`medium`,U:`slate`,A:`ee`,T:`ol`,S:`or`,B:`ra`,C:`lateg`,D:`ights`,R:`in`,Q:`turquois`,E:`hi`,P:`ro`,O:`al`,N:`le`,M:`de`,L:`yello`,F:`en`,K:`ch`,G:`arks`,H:`ea`,I:`ightg`,J:`wh`},te={OiceXe:`f0f8ff`,antiquewEte:`faebd7`,aqua:`ffff`,aquamarRe:`7fffd4`,azuY:`f0ffff`,beige:`f5f5dc`,bisque:`ffe4c4`,black:`0`,blanKedOmond:`ffebcd`,Xe:`ff`,XeviTet:`8a2be2`,bPwn:`a52a2a`,burlywood:`deb887`,caMtXe:`5f9ea0`,KartYuse:`7fff00`,KocTate:`d2691e`,cSO:`ff7f50`,cSnflowerXe:`6495ed`,cSnsilk:`fff8dc`,crimson:`dc143c`,cyan:`ffff`,xXe:`8b`,xcyan:`8b8b`,xgTMnPd:`b8860b`,xWay:`a9a9a9`,xgYF:`6400`,xgYy:`a9a9a9`,xkhaki:`bdb76b`,xmagFta:`8b008b`,xTivegYF:`556b2f`,xSange:`ff8c00`,xScEd:`9932cc`,xYd:`8b0000`,xsOmon:`e9967a`,xsHgYF:`8fbc8f`,xUXe:`483d8b`,xUWay:`2f4f4f`,xUgYy:`2f4f4f`,xQe:`ced1`,xviTet:`9400d3`,dAppRk:`ff1493`,dApskyXe:`bfff`,dimWay:`696969`,dimgYy:`696969`,dodgerXe:`1e90ff`,fiYbrick:`b22222`,flSOwEte:`fffaf0`,foYstWAn:`228b22`,fuKsia:`ff00ff`,gaRsbSo:`dcdcdc`,ghostwEte:`f8f8ff`,gTd:`ffd700`,gTMnPd:`daa520`,Way:`808080`,gYF:`8000`,gYFLw:`adff2f`,gYy:`808080`,honeyMw:`f0fff0`,hotpRk:`ff69b4`,RdianYd:`cd5c5c`,Rdigo:`4b0082`,ivSy:`fffff0`,khaki:`f0e68c`,lavFMr:`e6e6fa`,lavFMrXsh:`fff0f5`,lawngYF:`7cfc00`,NmoncEffon:`fffacd`,ZXe:`add8e6`,ZcSO:`f08080`,Zcyan:`e0ffff`,ZgTMnPdLw:`fafad2`,ZWay:`d3d3d3`,ZgYF:`90ee90`,ZgYy:`d3d3d3`,ZpRk:`ffb6c1`,ZsOmon:`ffa07a`,ZsHgYF:`20b2aa`,ZskyXe:`87cefa`,ZUWay:`778899`,ZUgYy:`778899`,ZstAlXe:`b0c4de`,ZLw:`ffffe0`,lime:`ff00`,limegYF:`32cd32`,lRF:`faf0e6`,magFta:`ff00ff`,maPon:`800000`,VaquamarRe:`66cdaa`,VXe:`cd`,VScEd:`ba55d3`,VpurpN:`9370db`,VsHgYF:`3cb371`,VUXe:`7b68ee`,VsprRggYF:`fa9a`,VQe:`48d1cc`,VviTetYd:`c71585`,midnightXe:`191970`,mRtcYam:`f5fffa`,mistyPse:`ffe4e1`,moccasR:`ffe4b5`,navajowEte:`ffdead`,navy:`80`,Tdlace:`fdf5e6`,Tive:`808000`,TivedBb:`6b8e23`,Sange:`ffa500`,SangeYd:`ff4500`,ScEd:`da70d6`,pOegTMnPd:`eee8aa`,pOegYF:`98fb98`,pOeQe:`afeeee`,pOeviTetYd:`db7093`,papayawEp:`ffefd5`,pHKpuff:`ffdab9`,peru:`cd853f`,pRk:`ffc0cb`,plum:`dda0dd`,powMrXe:`b0e0e6`,purpN:`800080`,YbeccapurpN:`663399`,Yd:`ff0000`,Psybrown:`bc8f8f`,PyOXe:`4169e1`,saddNbPwn:`8b4513`,sOmon:`fa8072`,sandybPwn:`f4a460`,sHgYF:`2e8b57`,sHshell:`fff5ee`,siFna:`a0522d`,silver:`c0c0c0`,skyXe:`87ceeb`,UXe:`6a5acd`,UWay:`708090`,UgYy:`708090`,snow:`fffafa`,sprRggYF:`ff7f`,stAlXe:`4682b4`,tan:`d2b48c`,teO:`8080`,tEstN:`d8bfd8`,tomato:`ff6347`,Qe:`40e0d0`,viTet:`ee82ee`,JHt:`f5deb3`,wEte:`ffffff`,wEtesmoke:`f5f5f5`,Lw:`ffff00`,LwgYF:`9acd32`};function ne(){let e={},t=Object.keys(te),n=Object.keys(O),r,i,a,o,s;for(r=0;r>16&255,a>>8&255,a&255]}return e}var re;function ie(e){re||(re=ne(),re.transparent=[0,0,0,0]);let t=re[e.toLowerCase()];return t&&{r:t[0],g:t[1],b:t[2],a:t.length===4?t[3]:255}}var ae=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;function oe(e){let r=ae.exec(e),i=255,a,o,s;if(r){if(r[7]!==a){let e=+r[7];i=r[8]?n(e):t(e*255,0,255)}return a=+r[1],o=+r[3],s=+r[5],a=255&(r[2]?n(a):t(a,0,255)),o=255&(r[4]?n(o):t(o,0,255)),s=255&(r[6]?n(s):t(s,0,255)),{r:a,g:o,b:s,a:i}}}function se(e){return e&&(e.a<255?`rgba(${e.r}, ${e.g}, ${e.b}, ${i(e.a)})`:`rgb(${e.r}, ${e.g}, ${e.b})`)}var ce=e=>e<=.0031308?e*12.92:e**(1/2.4)*1.055-.055,le=e=>e<=.04045?e/12.92:((e+.055)/1.055)**2.4;function ue(e,t,n){let a=le(i(e.r)),o=le(i(e.g)),s=le(i(e.b));return{r:r(ce(a+n*(le(i(t.r))-a))),g:r(ce(o+n*(le(i(t.g))-o))),b:r(ce(s+n*(le(i(t.b))-s))),a:e.a+n*(t.a-e.a)}}function de(e,t,n){if(e){let r=b(e);r[t]=Math.max(0,Math.min(r[t]+r[t]*n,t===0?360:1)),r=S(r),e.r=r[0],e.g=r[1],e.b=r[2]}}function fe(e,t){return e&&Object.assign(t||{},e)}function pe(e){var t={r:0,g:0,b:0,a:255};return Array.isArray(e)?e.length>=3&&(t={r:e[0],g:e[1],b:e[2],a:255},e.length>3&&(t.a=r(e[3]))):(t=fe(e,{r:0,g:0,b:0,a:1}),t.a=r(t.a)),t}function me(e){return e.charAt(0)===`r`?oe(e):E(e)}var he=class t{constructor(e){if(e instanceof t)return e;let n=typeof e,r;n===`object`?r=pe(e):n===`string`&&(r=f(e)||ie(e)||me(e)),this._rgb=r,this._valid=!!r}get valid(){return this._valid}get rgb(){var e=fe(this._rgb);return e&&(e.a=i(e.a)),e}set rgb(e){this._rgb=pe(e)}rgbString(){return this._valid?se(this._rgb):void 0}hexString(){return this._valid?m(this._rgb):void 0}hslString(){return this._valid?ee(this._rgb):void 0}mix(e,t){if(e){let n=this.rgb,r=e.rgb,i,a=t===i?.5:t,o=2*a-1,s=n.a-r.a,c=((o*s===-1?o:(o+s)/(1+o*s))+1)/2;i=1-c,n.r=255&c*n.r+i*r.r+.5,n.g=255&c*n.g+i*r.g+.5,n.b=255&c*n.b+i*r.b+.5,n.a=a*n.a+(1-a)*r.a,this.rgb=n}return this}interpolate(e,t){return e&&(this._rgb=ue(this._rgb,e._rgb,t)),this}clone(){return new t(this.rgb)}alpha(e){return this._rgb.a=r(e),this}clearer(e){let t=this._rgb;return t.a*=1-e,this}greyscale(){let t=this._rgb;return t.r=t.g=t.b=e(t.r*.3+t.g*.59+t.b*.11),this}opaquer(e){let t=this._rgb;return t.a*=1+e,this}negate(){let e=this._rgb;return e.r=255-e.r,e.g=255-e.g,e.b=255-e.b,this}lighten(e){return de(this._rgb,2,e),this}darken(e){return de(this._rgb,2,-e),this}saturate(e){return de(this._rgb,1,e),this}desaturate(e){return de(this._rgb,1,-e),this}rotate(e){return D(this._rgb,e),this}};function ge(){}var _e=(()=>{let e=0;return()=>e++})();function k(e){return e==null}function A(e){if(Array.isArray&&Array.isArray(e))return!0;let t=Object.prototype.toString.call(e);return t.slice(0,7)===`[object`&&t.slice(-6)===`Array]`}function j(e){return e!==null&&Object.prototype.toString.call(e)===`[object Object]`}function M(e){return(typeof e==`number`||e instanceof Number)&&isFinite(+e)}function N(e,t){return M(e)?e:t}function P(e,t){return e===void 0?t:e}var ve=(e,t)=>typeof e==`string`&&e.endsWith(`%`)?parseFloat(e)/100:+e/t,ye=(e,t)=>typeof e==`string`&&e.endsWith(`%`)?parseFloat(e)/100*t:+e;function F(e,t,n){if(e&&typeof e.call==`function`)return e.apply(n,t)}function I(e,t,n,r){let i,a,o;if(A(e))if(a=e.length,r)for(i=a-1;i>=0;i--)t.call(n,e[i],i);else for(i=0;ie,x:e=>e.x,y:e=>e.y};function Oe(e){let t=e.split(`.`),n=[],r=``;for(let e of t)r+=e,r.endsWith(`\\`)?r=r.slice(0,-1)+`.`:(n.push(r),r=``);return n}function ke(e){let t=Oe(e);return e=>{for(let n of t){if(n===``)break;e&&=e[n]}return e}}function Ae(e,t){return(De[t]||(De[t]=ke(t)))(e)}function je(e){return e.charAt(0).toUpperCase()+e.slice(1)}var Me=e=>e!==void 0,Ne=e=>typeof e==`function`,Pe=(e,t)=>{if(e.size!==t.size)return!1;for(let n of e)if(!t.has(n))return!1;return!0};function Fe(e){return e.type===`mouseup`||e.type===`click`||e.type===`contextmenu`}var L=Math.PI,R=2*L,Ie=R+L,Le=1/0,Re=L/180,z=L/2,ze=L/4,Be=L*2/3,Ve=Math.log10,B=Math.sign;function He(e,t,n){return Math.abs(e-t)e-t).pop(),t}function Ge(e){return typeof e==`symbol`||typeof e==`object`&&!!e&&!(Symbol.toPrimitive in e||`toString`in e||`valueOf`in e)}function Ke(e){return!Ge(e)&&!isNaN(parseFloat(e))&&isFinite(e)}function qe(e,t){let n=Math.round(e);return n-t<=e&&n+t>=e}function Je(e,t,n){let r,i,a;for(r=0,i=e.length;rc&&l=Math.min(t,n)-r&&e<=Math.max(t,n)+r}function rt(e,t,n){n||=(n=>e[n]1;)a=i+r>>1,n(a)?i=a:r=a;return{lo:i,hi:r}}var it=(e,t,n,r)=>rt(e,n,r?r=>{let i=e[r][t];return ie[r][t]rt(e,n,r=>e[r][t]>=n);function ot(e,t,n){let r=0,i=e.length;for(;rr&&e[i-1]>n;)i--;return r>0||i{let n=`_onData`+je(t),r=e[t];Object.defineProperty(e,t,{configurable:!0,enumerable:!1,value(...t){let i=r.apply(this,t);return e._chartjs.listeners.forEach(e=>{typeof e[n]==`function`&&e[n](...t)}),i}})})}function lt(e,t){let n=e._chartjs;if(!n)return;let r=n.listeners,i=r.indexOf(t);i!==-1&&r.splice(i,1),!(r.length>0)&&(st.forEach(t=>{delete e[t]}),delete e._chartjs)}function ut(e){let t=new Set(e);return t.size===e.length?e:Array.from(t)}var dt=function(){return typeof window>`u`?function(e){return e()}:window.requestAnimationFrame}();function ft(e,t){let n=[],r=!1;return function(...i){n=i,r||(r=!0,dt.call(window,()=>{r=!1,e.apply(t,n)}))}}function pt(e,t){let n;return function(...r){return t?(clearTimeout(n),n=setTimeout(e,t,r)):e.apply(this,r),t}}var mt=e=>e===`start`?`left`:e===`end`?`right`:`center`,W=(e,t,n)=>e===`start`?t:e===`end`?n:(t+n)/2,ht=(e,t,n,r)=>e===(r?`left`:`right`)?n:e===`center`?(t+n)/2:t;function gt(e,t,n){let r=t.length,i=0,a=r;if(e._sorted){let{iScale:o,vScale:s,_parsed:c}=e,l=e.dataset&&e.dataset.options?e.dataset.options.spanGaps:null,u=o.axis,{min:d,max:f,minDefined:p,maxDefined:m}=o.getUserBounds();if(p){if(i=Math.min(it(c,u,d).lo,n?r:it(t,u,o.getPixelForValue(d)).lo),l){let e=c.slice(0,i+1).reverse().findIndex(e=>!k(e[s.axis]));i-=Math.max(0,e)}i=U(i,0,r-1)}if(m){let e=Math.max(it(c,o.axis,f,!0).hi+1,n?0:it(t,u,o.getPixelForValue(f),!0).hi+1);if(l){let t=c.slice(e-1).findIndex(e=>!k(e[s.axis]));e+=Math.max(0,t)}a=U(e,i,r)-i}else a=r-i}return{start:i,count:a}}function _t(e){let{xScale:t,yScale:n,_scaleRanges:r}=e,i={xmin:t.min,xmax:t.max,ymin:n.min,ymax:n.max};if(!r)return e._scaleRanges=i,!0;let a=r.xmin!==t.min||r.xmax!==t.max||r.ymin!==n.min||r.ymax!==n.max;return Object.assign(r,i),a}var vt=e=>e===0||e===1,yt=(e,t,n)=>-(2**(10*--e)*Math.sin((e-t)*R/n)),bt=(e,t,n)=>2**(-10*e)*Math.sin((e-t)*R/n)+1,xt={linear:e=>e,easeInQuad:e=>e*e,easeOutQuad:e=>-e*(e-2),easeInOutQuad:e=>(e/=.5)<1?.5*e*e:-.5*(--e*(e-2)-1),easeInCubic:e=>e*e*e,easeOutCubic:e=>--e*e*e+1,easeInOutCubic:e=>(e/=.5)<1?.5*e*e*e:.5*((e-=2)*e*e+2),easeInQuart:e=>e*e*e*e,easeOutQuart:e=>-(--e*e*e*e-1),easeInOutQuart:e=>(e/=.5)<1?.5*e*e*e*e:-.5*((e-=2)*e*e*e-2),easeInQuint:e=>e*e*e*e*e,easeOutQuint:e=>--e*e*e*e*e+1,easeInOutQuint:e=>(e/=.5)<1?.5*e*e*e*e*e:.5*((e-=2)*e*e*e*e+2),easeInSine:e=>-Math.cos(e*z)+1,easeOutSine:e=>Math.sin(e*z),easeInOutSine:e=>-.5*(Math.cos(L*e)-1),easeInExpo:e=>e===0?0:2**(10*(e-1)),easeOutExpo:e=>e===1?1:-(2**(-10*e))+1,easeInOutExpo:e=>vt(e)?e:e<.5?.5*2**(10*(e*2-1)):.5*(-(2**(-10*(e*2-1)))+2),easeInCirc:e=>e>=1?e:-(Math.sqrt(1-e*e)-1),easeOutCirc:e=>Math.sqrt(1- --e*e),easeInOutCirc:e=>(e/=.5)<1?-.5*(Math.sqrt(1-e*e)-1):.5*(Math.sqrt(1-(e-=2)*e)+1),easeInElastic:e=>vt(e)?e:yt(e,.075,.3),easeOutElastic:e=>vt(e)?e:bt(e,.075,.3),easeInOutElastic(e){let t=.1125,n=.45;return vt(e)?e:e<.5?.5*yt(e*2,t,n):.5+.5*bt(e*2-1,t,n)},easeInBack(e){let t=1.70158;return e*e*((t+1)*e-t)},easeOutBack(e){let t=1.70158;return--e*e*((t+1)*e+t)+1},easeInOutBack(e){let t=1.70158;return(e/=.5)<1?.5*(e*e*(((t*=1.525)+1)*e-t)):.5*((e-=2)*e*(((t*=1.525)+1)*e+t)+2)},easeInBounce:e=>1-xt.easeOutBounce(1-e),easeOutBounce(e){let t=7.5625,n=2.75;return e<1/n?t*e*e:e<2/n?t*(e-=1.5/n)*e+.75:e<2.5/n?t*(e-=2.25/n)*e+.9375:t*(e-=2.625/n)*e+.984375},easeInOutBounce:e=>e<.5?xt.easeInBounce(e*2)*.5:xt.easeOutBounce(e*2-1)*.5+.5};function St(e){if(e&&typeof e==`object`){let t=e.toString();return t===`[object CanvasPattern]`||t===`[object CanvasGradient]`}return!1}function Ct(e){return St(e)?e:new he(e)}function wt(e){return St(e)?e:new he(e).saturate(.5).darken(.1).hexString()}var Tt=[`x`,`y`,`borderWidth`,`radius`,`tension`],Et=[`color`,`borderColor`,`backgroundColor`];function Dt(e){e.set(`animation`,{delay:void 0,duration:1e3,easing:`easeOutQuart`,fn:void 0,from:void 0,loop:void 0,to:void 0,type:void 0}),e.describe(`animation`,{_fallback:!1,_indexable:!1,_scriptable:e=>e!==`onProgress`&&e!==`onComplete`&&e!==`fn`}),e.set(`animations`,{colors:{type:`color`,properties:Et},numbers:{type:`number`,properties:Tt}}),e.describe(`animations`,{_fallback:`animation`}),e.set(`transitions`,{active:{animation:{duration:400}},resize:{animation:{duration:0}},show:{animations:{colors:{from:`transparent`},visible:{type:`boolean`,duration:0}}},hide:{animations:{colors:{to:`transparent`},visible:{type:`boolean`,easing:`linear`,fn:e=>e|0}}}})}function Ot(e){e.set(`layout`,{autoPadding:!0,padding:{top:0,right:0,bottom:0,left:0}})}var kt=new Map;function At(e,t){t||={};let n=e+JSON.stringify(t),r=kt.get(n);return r||(r=new Intl.NumberFormat(e,t),kt.set(n,r)),r}function jt(e,t,n){return At(t,n).format(e)}var Mt={values(e){return A(e)?e:``+e},numeric(e,t,n){if(e===0)return`0`;let r=this.chart.options.locale,i,a=e;if(n.length>1){let t=Math.max(Math.abs(n[0].value),Math.abs(n[n.length-1].value));(t<1e-4||t>0x38d7ea4c68000)&&(i=`scientific`),a=Nt(e,n)}let o=Ve(Math.abs(a)),s=isNaN(o)?1:Math.max(Math.min(-1*Math.floor(o),20),0),c={notation:i,minimumFractionDigits:s,maximumFractionDigits:s};return Object.assign(c,this.options.ticks.format),jt(e,r,c)},logarithmic(e,t,n){if(e===0)return`0`;let r=n[t].significand||e/10**Math.floor(Ve(e));return[1,2,3,5,10,15].includes(r)||t>.8*n.length?Mt.numeric.call(this,e,t,n):``}};function Nt(e,t){let n=t.length>3?t[2].value-t[1].value:t[1].value-t[0].value;return Math.abs(n)>=1&&e!==Math.floor(e)&&(n=e-Math.floor(e)),n}var Pt={formatters:Mt};function Ft(e){e.set(`scale`,{display:!0,offset:!1,reverse:!1,beginAtZero:!1,bounds:`ticks`,clip:!0,grace:0,grid:{display:!0,lineWidth:1,drawOnChartArea:!0,drawTicks:!0,tickLength:8,tickWidth:(e,t)=>t.lineWidth,tickColor:(e,t)=>t.color,offset:!1},border:{display:!0,dash:[],dashOffset:0,width:1},title:{display:!1,text:``,padding:{top:4,bottom:4}},ticks:{minRotation:0,maxRotation:50,mirror:!1,textStrokeWidth:0,textStrokeColor:``,padding:3,display:!0,autoSkip:!0,autoSkipPadding:3,labelOffset:0,callback:Pt.formatters.values,minor:{},major:{},align:`center`,crossAlign:`near`,showLabelBackdrop:!1,backdropColor:`rgba(255, 255, 255, 0.75)`,backdropPadding:2}}),e.route(`scale.ticks`,`color`,``,`color`),e.route(`scale.grid`,`color`,``,`borderColor`),e.route(`scale.border`,`color`,``,`borderColor`),e.route(`scale.title`,`color`,``,`color`),e.describe(`scale`,{_fallback:!1,_scriptable:e=>!e.startsWith(`before`)&&!e.startsWith(`after`)&&e!==`callback`&&e!==`parser`,_indexable:e=>e!==`borderDash`&&e!==`tickBorderDash`&&e!==`dash`}),e.describe(`scales`,{_fallback:`scale`}),e.describe(`scale.ticks`,{_scriptable:e=>e!==`backdropPadding`&&e!==`callback`,_indexable:e=>e!==`backdropPadding`})}var It=Object.create(null),Lt=Object.create(null);function Rt(e,t){if(!t)return e;let n=t.split(`.`);for(let t=0,r=n.length;te.chart.platform.getDevicePixelRatio(),this.elements={},this.events=[`mousemove`,`mouseout`,`click`,`touchstart`,`touchmove`],this.font={family:`'Helvetica Neue', 'Helvetica', 'Arial', sans-serif`,size:12,style:`normal`,lineHeight:1.2,weight:null},this.hover={},this.hoverBackgroundColor=(e,t)=>wt(t.backgroundColor),this.hoverBorderColor=(e,t)=>wt(t.borderColor),this.hoverColor=(e,t)=>wt(t.color),this.indexAxis=`x`,this.interaction={mode:`nearest`,intersect:!0,includeInvisible:!1},this.maintainAspectRatio=!0,this.onHover=null,this.onClick=null,this.parsing=!0,this.plugins={},this.responsive=!0,this.scale=void 0,this.scales={},this.showLine=!0,this.drawActiveElementsOnTop=!0,this.describe(e),this.apply(t)}set(e,t){return zt(this,e,t)}get(e){return Rt(this,e)}describe(e,t){return zt(Lt,e,t)}override(e,t){return zt(It,e,t)}route(e,t,n,r){let i=Rt(this,e),a=Rt(this,n),o=`_`+t;Object.defineProperties(i,{[o]:{value:i[t],writable:!0},[t]:{enumerable:!0,get(){let e=this[o],t=a[r];return j(e)?Object.assign({},t,e):P(e,t)},set(e){this[o]=e}}})}apply(e){e.forEach(e=>e(this))}}({_scriptable:e=>!e.startsWith(`on`),_indexable:e=>e!==`events`,hover:{_fallback:`interaction`},interaction:{_scriptable:!1,_indexable:!1}},[Dt,Ot,Ft]);function Bt(e){return!e||k(e.size)||k(e.family)?null:(e.style?e.style+` `:``)+(e.weight?e.weight+` `:``)+e.size+`px `+e.family}function Vt(e,t,n,r,i){let a=t[i];return a||(a=t[i]=e.measureText(i).width,n.push(i)),a>r&&(r=a),r}function Ht(e,t,n,r){r||={};let i=r.data=r.data||{},a=r.garbageCollect=r.garbageCollect||[];r.font!==t&&(i=r.data={},a=r.garbageCollect=[],r.font=t),e.save(),e.font=t;let o=0,s=n.length,c,l,u,d,f;for(c=0;cn.length){for(c=0;c0&&e.stroke()}}function K(e,t,n){return n||=.5,!t||e&&e.x>t.left-n&&e.xt.top-n&&e.y0&&a.strokeColor!==``,c,l;for(e.save(),e.font=i.string,Zt(e,a),c=0;c+e||0;function sn(e,t){let n={},r=j(t),i=r?Object.keys(t):t,a=j(e)?r?n=>P(e[n],e[t[n]]):t=>e[t]:()=>e;for(let e of i)n[e]=on(a(e));return n}function cn(e){return sn(e,{top:`y`,right:`x`,bottom:`y`,left:`x`})}function ln(e){return sn(e,[`topLeft`,`topRight`,`bottomLeft`,`bottomRight`])}function q(e){let t=cn(e);return t.width=t.left+t.right,t.height=t.top+t.bottom,t}function J(e,t){e||={},t||=G.font;let n=P(e.size,t.size);typeof n==`string`&&(n=parseInt(n,10));let r=P(e.style,t.style);r&&!(``+r).match(rn)&&(console.warn(`Invalid font style specified: "`+r+`"`),r=void 0);let i={family:P(e.family,t.family),lineHeight:an(P(e.lineHeight,t.lineHeight),n),size:n,style:r,weight:P(e.weight,t.weight),string:``};return i.string=Bt(i),i}function un(e,t,n,r){let i=!0,a,o,s;for(a=0,o=e.length;an&&e===0?0:e+t;return{min:o(r,-Math.abs(a)),max:o(i,a)}}function fn(e,t){return Object.assign(Object.create(e),t)}function pn(e,t=[``],n,r,i=()=>e[0]){let a=n||e;return r===void 0&&(r=kn(`_fallback`,e)),new Proxy({[Symbol.toStringTag]:`Object`,_cacheable:!0,_scopes:e,_rootScopes:a,_fallback:r,_getTarget:i,override:n=>pn([n,...e],t,a,r)},{deleteProperty(t,n){return delete t[n],delete t._keys,delete e[0][n],!0},get(n,r){return vn(n,r,()=>On(r,t,e,n))},getOwnPropertyDescriptor(e,t){return Reflect.getOwnPropertyDescriptor(e._scopes[0],t)},getPrototypeOf(){return Reflect.getPrototypeOf(e[0])},has(e,t){return An(e).includes(t)},ownKeys(e){return An(e)},set(e,t,n){let r=e._storage||=i();return e[t]=r[t]=n,delete e._keys,!0}})}function mn(e,t,n,r){let i={_cacheable:!1,_proxy:e,_context:t,_subProxy:n,_stack:new Set,_descriptors:hn(e,r),setContext:t=>mn(e,t,n,r),override:i=>mn(e.override(i),t,n,r)};return new Proxy(i,{deleteProperty(t,n){return delete t[n],delete e[n],!0},get(e,t,n){return vn(e,t,()=>yn(e,t,n))},getOwnPropertyDescriptor(t,n){return t._descriptors.allKeys?Reflect.has(e,n)?{enumerable:!0,configurable:!0}:void 0:Reflect.getOwnPropertyDescriptor(e,n)},getPrototypeOf(){return Reflect.getPrototypeOf(e)},has(t,n){return Reflect.has(e,n)},ownKeys(){return Reflect.ownKeys(e)},set(t,n,r){return e[n]=r,delete t[n],!0}})}function hn(e,t={scriptable:!0,indexable:!0}){let{_scriptable:n=t.scriptable,_indexable:r=t.indexable,_allKeys:i=t.allKeys}=e;return{allKeys:i,scriptable:n,indexable:r,isScriptable:Ne(n)?n:()=>n,isIndexable:Ne(r)?r:()=>r}}var gn=(e,t)=>e?e+je(t):t,_n=(e,t)=>j(t)&&e!==`adapters`&&(Object.getPrototypeOf(t)===null||t.constructor===Object);function vn(e,t,n){if(Object.prototype.hasOwnProperty.call(e,t)||t===`constructor`)return e[t];let r=n();return e[t]=r,r}function yn(e,t,n){let{_proxy:r,_context:i,_subProxy:a,_descriptors:o}=e,s=r[t];return Ne(s)&&o.isScriptable(t)&&(s=bn(t,s,e,n)),A(s)&&s.length&&(s=xn(t,s,e,o.isIndexable)),_n(t,s)&&(s=mn(s,i,a&&a[t],o)),s}function bn(e,t,n,r){let{_proxy:i,_context:a,_subProxy:o,_stack:s}=n;if(s.has(e))throw Error(`Recursion detected: `+Array.from(s).join(`->`)+`->`+e);s.add(e);let c=t(a,o||r);return s.delete(e),_n(e,c)&&(c=Tn(i._scopes,i,e,c)),c}function xn(e,t,n,r){let{_proxy:i,_context:a,_subProxy:o,_descriptors:s}=n;if(a.index!==void 0&&r(e))return t[a.index%t.length];if(j(t[0])){let n=t,r=i._scopes.filter(e=>e!==n);t=[];for(let c of n){let n=Tn(r,i,e,c);t.push(mn(n,a,o&&o[e],s))}}return t}function Sn(e,t,n){return Ne(e)?e(t,n):e}var Cn=(e,t)=>e===!0?t:typeof e==`string`?Ae(t,e):void 0;function wn(e,t,n,r,i){for(let a of t){let t=Cn(n,a);if(t){e.add(t);let a=Sn(t._fallback,n,i);if(a!==void 0&&a!==n&&a!==r)return a}else if(t===!1&&r!==void 0&&n!==r)return null}return!1}function Tn(e,t,n,r){let i=t._rootScopes,a=Sn(t._fallback,n,r),o=[...e,...i],s=new Set;s.add(r);let c=En(s,o,n,a||n,r);return c===null||a!==void 0&&a!==n&&(c=En(s,o,a,c,r),c===null)?!1:pn(Array.from(s),[``],i,a,()=>Dn(t,n,r))}function En(e,t,n,r,i){for(;n;)n=wn(e,t,n,r,i);return n}function Dn(e,t,n){let r=e._getTarget();t in r||(r[t]={});let i=r[t];return A(i)&&j(n)?n:i||{}}function On(e,t,n,r){let i;for(let a of t)if(i=kn(gn(a,e),n),i!==void 0)return _n(e,i)?Tn(n,r,e,i):i}function kn(e,t){for(let n of t){if(!n)continue;let t=n[e];if(t!==void 0)return t}}function An(e){let t=e._keys;return t||=e._keys=jn(e._scopes),t}function jn(e){let t=new Set;for(let n of e)for(let e of Object.keys(n).filter(e=>!e.startsWith(`_`)))t.add(e);return Array.from(t)}var Mn=2**-52||1e-14,Nn=(e,t)=>te===`x`?`y`:`x`;function Fn(e,t,n,r){let i=e.skip?t:e,a=t,o=n.skip?t:n,s=Qe(a,i),c=Qe(o,a),l=s/(s+c),u=c/(s+c);l=isNaN(l)?0:l,u=isNaN(u)?0:u;let d=r*l,f=r*u;return{previous:{x:a.x-d*(o.x-i.x),y:a.y-d*(o.y-i.y)},next:{x:a.x+f*(o.x-i.x),y:a.y+f*(o.y-i.y)}}}function In(e,t,n){let r=e.length,i,a,o,s,c,l=Nn(e,0);for(let u=0;u!e.skip)),t.cubicInterpolationMode===`monotone`)Rn(e,i);else{let n=r?e[e.length-1]:e[0];for(a=0,o=e.length;ae.ownerDocument.defaultView.getComputedStyle(e,null);function Kn(e,t){return Gn(e).getPropertyValue(t)}var qn=[`top`,`right`,`bottom`,`left`];function Jn(e,t,n){let r={};n=n?`-`+n:``;for(let i=0;i<4;i++){let a=qn[i];r[a]=parseFloat(e[t+`-`+a+n])||0}return r.width=r.left+r.right,r.height=r.top+r.bottom,r}var Yn=(e,t,n)=>(e>0||t>0)&&(!n||!n.shadowRoot);function Xn(e,t){let n=e.touches,r=n&&n.length?n[0]:e,{offsetX:i,offsetY:a}=r,o=!1,s,c;if(Yn(i,a,e.target))s=i,c=a;else{let e=t.getBoundingClientRect();s=r.clientX-e.left,c=r.clientY-e.top,o=!0}return{x:s,y:c,box:o}}function Zn(e,t){if(`native`in e)return e;let{canvas:n,currentDevicePixelRatio:r}=t,i=Gn(n),a=i.boxSizing===`border-box`,o=Jn(i,`padding`),s=Jn(i,`border`,`width`),{x:c,y:l,box:u}=Xn(e,n),d=o.left+(u&&s.left),f=o.top+(u&&s.top),{width:p,height:m}=t;return a&&(p-=o.width+s.width,m-=o.height+s.height),{x:Math.round((c-d)/p*n.width/r),y:Math.round((l-f)/m*n.height/r)}}function Qn(e,t,n){let r,i;if(t===void 0||n===void 0){let a=e&&Un(e);if(!a)t=e.clientWidth,n=e.clientHeight;else{let e=a.getBoundingClientRect(),o=Gn(a),s=Jn(o,`border`,`width`),c=Jn(o,`padding`);t=e.width-c.width-s.width,n=e.height-c.height-s.height,r=Wn(o.maxWidth,a,`clientWidth`),i=Wn(o.maxHeight,a,`clientHeight`)}}return{width:t,height:n,maxWidth:r||Le,maxHeight:i||Le}}var $n=e=>Math.round(e*10)/10;function er(e,t,n,r){let i=Gn(e),a=Jn(i,`margin`),o=Wn(i.maxWidth,e,`clientWidth`)||Le,s=Wn(i.maxHeight,e,`clientHeight`)||Le,c=Qn(e,t,n),{width:l,height:u}=c;if(i.boxSizing===`content-box`){let e=Jn(i,`border`,`width`),t=Jn(i,`padding`);l-=t.width+e.width,u-=t.height+e.height}return l=Math.max(0,l-a.width),u=Math.max(0,r?l/r:u-a.height),l=$n(Math.min(l,o,c.maxWidth)),u=$n(Math.min(u,s,c.maxHeight)),l&&!u&&(u=$n(l/2)),(t!==void 0||n!==void 0)&&r&&c.height&&u>c.height&&(u=c.height,l=$n(Math.floor(u*r))),{width:l,height:u}}function tr(e,t,n){let r=t||1,i=$n(e.height*r),a=$n(e.width*r);e.height=$n(e.height),e.width=$n(e.width);let o=e.canvas;return o.style&&(n||!o.style.height&&!o.style.width)&&(o.style.height=`${e.height}px`,o.style.width=`${e.width}px`),e.currentDevicePixelRatio!==r||o.height!==i||o.width!==a?(e.currentDevicePixelRatio=r,o.height=i,o.width=a,e.ctx.setTransform(r,0,0,r,0,0),!0):!1}var nr=function(){let e=!1;try{let t={get passive(){return e=!0,!1}};Hn()&&(window.addEventListener(`test`,null,t),window.removeEventListener(`test`,null,t))}catch{}return e}();function rr(e,t){let n=Kn(e,t),r=n&&n.match(/^(\d+)(\.\d+)?px$/);return r?+r[1]:void 0}function ir(e,t,n,r){return{x:e.x+n*(t.x-e.x),y:e.y+n*(t.y-e.y)}}function ar(e,t,n,r){return{x:e.x+n*(t.x-e.x),y:r===`middle`?n<.5?e.y:t.y:r===`after`?n<1?e.y:t.y:n>0?t.y:e.y}}function or(e,t,n,r){let i={x:e.cp2x,y:e.cp2y},a={x:t.cp1x,y:t.cp1y},o=ir(e,i,n),s=ir(i,a,n),c=ir(a,t,n);return ir(ir(o,s,n),ir(s,c,n),n)}var sr=function(e,t){return{x(n){return e+e+t-n},setWidth(e){t=e},textAlign(e){return e===`center`?e:e===`right`?`left`:`right`},xPlus(e,t){return e-t},leftForLtr(e,t){return e-t}}},cr=function(){return{x(e){return e},setWidth(e){},textAlign(e){return e},xPlus(e,t){return e+t},leftForLtr(e,t){return e}}};function lr(e,t,n){return e?sr(t,n):cr()}function ur(e,t){let n,r;(t===`ltr`||t===`rtl`)&&(n=e.canvas.style,r=[n.getPropertyValue(`direction`),n.getPropertyPriority(`direction`)],n.setProperty(`direction`,t,`important`),e.prevTextDirection=r)}function dr(e,t){t!==void 0&&(delete e.prevTextDirection,e.canvas.style.setProperty(`direction`,t[0],t[1]))}function fr(e){return e===`angle`?{between:et,compare:$e,normalize:H}:{between:nt,compare:(e,t)=>e-t,normalize:e=>e}}function pr({start:e,end:t,count:n,loop:r,style:i}){return{start:e%n,end:t%n,loop:r&&(t-e+1)%n===0,style:i}}function mr(e,t,n){let{property:r,start:i,end:a}=n,{between:o,normalize:s}=fr(r),c=t.length,{start:l,end:u,loop:d}=e,f,p;if(d){for(l+=c,u+=c,f=0,p=c;fc(i,y,_)&&s(i,y)!==0,x=()=>s(a,_)===0||c(a,y,_),S=()=>h||b(),C=()=>!h||x();for(let e=u,n=u;e<=d;++e)v=t[e%o],!v.skip&&(_=l(v[r]),_!==y&&(h=c(_,i,a),g===null&&S()&&(g=s(_,i)===0?e:n),g!==null&&C()&&(m.push(pr({start:g,end:e,loop:f,count:o,style:p})),g=null),n=e,y=_));return g!==null&&m.push(pr({start:g,end:d,loop:f,count:o,style:p})),m}function gr(e,t){let n=[],r=e.segments;for(let i=0;ii&&e[a%t].skip;)a--;return a%=t,{start:i,end:a}}function vr(e,t,n,r){let i=e.length,a=[],o=t,s=e[t],c;for(c=t+1;c<=n;++c){let n=e[c%i];n.skip||n.stop?s.skip||(r=!1,a.push({start:t%i,end:(c-1)%i,loop:r}),t=o=n.stop?c:null):(o=c,s.skip&&(t=c)),s=n}return o!==null&&a.push({start:t%i,end:o%i,loop:r}),a}function yr(e,t){let n=e.points,r=e.options.spanGaps,i=n.length;if(!i)return[];let a=!!e._loop,{start:o,end:s}=_r(n,i,a,r);return r===!0?br(e,[{start:o,end:s,loop:a}],n,t):br(e,vr(n,o,sr({chart:e,initial:t.initial,numSteps:a,currentStep:Math.min(n-t.start,a)}))}_refresh(){this._request||=(this._running=!0,dt.call(window,()=>{this._update(),this._request=null,this._running&&this._refresh()}))}_update(e=Date.now()){let t=0;this._charts.forEach((n,r)=>{if(!n.running||!n.items.length)return;let i=n.items,a=i.length-1,o=!1,s;for(;a>=0;--a)s=i[a],s._active?(s._total>n.duration&&(n.duration=s._total),s.tick(e),o=!0):(i[a]=i[i.length-1],i.pop());o&&(r.draw(),this._notify(r,n,e,`progress`)),i.length||(n.running=!1,this._notify(r,n,e,`complete`),n.initial=!1),t+=i.length}),this._lastDate=e,t===0&&(this._running=!1)}_getAnims(e){let t=this._charts,n=t.get(e);return n||(n={running:!1,initial:!0,items:[],listeners:{complete:[],progress:[]}},t.set(e,n)),n}listen(e,t,n){this._getAnims(e).listeners[t].push(n)}add(e,t){!t||!t.length||this._getAnims(e).items.push(...t)}has(e){return this._getAnims(e).items.length>0}start(e){let t=this._charts.get(e);t&&(t.running=!0,t.start=Date.now(),t.duration=t.items.reduce((e,t)=>Math.max(e,t._duration),0),this._refresh())}running(e){if(!this._running)return!1;let t=this._charts.get(e);return!(!t||!t.running||!t.items.length)}stop(e){let t=this._charts.get(e);if(!t||!t.items.length)return;let n=t.items,r=n.length-1;for(;r>=0;--r)n[r].cancel();t.items=[],this._notify(e,t,Date.now(),`complete`)}remove(e){return this._charts.delete(e)}},Or=`transparent`,kr={boolean(e,t,n){return n>.5?t:e},color(e,t,n){let r=Ct(e||Or),i=r.valid&&Ct(t||Or);return i&&i.valid?i.mix(r,n).hexString():t},number(e,t,n){return e+(t-e)*n}},Ar=class{constructor(e,t,n,r){let i=t[n];r=un([e.to,r,i,e.from]);let a=un([e.from,i,r]);this._active=!0,this._fn=e.fn||kr[e.type||typeof a],this._easing=xt[e.easing]||xt.linear,this._start=Math.floor(Date.now()+(e.delay||0)),this._duration=this._total=Math.floor(e.duration),this._loop=!!e.loop,this._target=t,this._prop=n,this._from=a,this._to=r,this._promises=void 0}active(){return this._active}update(e,t,n){if(this._active){this._notify(!1);let r=this._target[this._prop],i=n-this._start,a=this._duration-i;this._start=n,this._duration=Math.floor(Math.max(a,e.duration)),this._total+=i,this._loop=!!e.loop,this._to=un([e.to,t,r,e.from]),this._from=un([e.from,r,t])}}cancel(){this._active&&(this.tick(Date.now()),this._active=!1,this._notify(!1))}tick(e){let t=e-this._start,n=this._duration,r=this._prop,i=this._from,a=this._loop,o=this._to,s;if(this._active=i!==o&&(a||t1?2-s:s,s=this._easing(Math.min(1,Math.max(0,s))),this._target[r]=this._fn(i,o,s)}wait(){let e=this._promises||=[];return new Promise((t,n)=>{e.push({res:t,rej:n})})}_notify(e){let t=e?`res`:`rej`,n=this._promises||[];for(let e=0;e{let i=e[r];if(!j(i))return;let a={};for(let e of t)a[e]=i[e];(A(i.properties)&&i.properties||[r]).forEach(e=>{(e===r||!n.has(e))&&n.set(e,a)})})}_animateOptions(e,t){let n=t.options,r=Nr(e,n);if(!r)return[];let i=this._createAnimations(r,n);return n.$shared&&Mr(e.options.$animations,n).then(()=>{e.options=n},()=>{}),i}_createAnimations(e,t){let n=this._properties,r=[],i=e.$animations||={},a=Object.keys(t),o=Date.now(),s;for(s=a.length-1;s>=0;--s){let c=a[s];if(c.charAt(0)===`$`)continue;if(c===`options`){r.push(...this._animateOptions(e,t));continue}let l=t[c],u=i[c],d=n.get(c);if(u)if(d&&u.active()){u.update(d,l,o);continue}else u.cancel();if(!d||!d.duration){e[c]=l;continue}i[c]=u=new Ar(d,e,c,l),r.push(u)}return r}update(e,t){if(this._properties.size===0){Object.assign(e,t);return}let n=this._createAnimations(e,t);if(n.length)return Dr.add(this._chart,n),!0}};function Mr(e,t){let n=[],r=Object.keys(t);for(let t=0;t0||!n&&t<0)return i.index}return null}function Gr(e,t){let{chart:n,_cachedMeta:r}=e,i=n._stacks||={},{iScale:a,vScale:o,index:s}=r,c=a.axis,l=o.axis,u=Vr(a,o,r),d=t.length,f;for(let e=0;en[e].axis===t).shift()}function qr(e,t){return fn(e,{active:!1,dataset:void 0,datasetIndex:t,index:t,mode:`default`,type:`dataset`})}function Jr(e,t,n){return fn(e,{active:!1,dataIndex:t,parsed:void 0,raw:void 0,element:n,index:t,mode:`default`,type:`data`})}function Yr(e,t){let n=e.controller.index,r=e.vScale&&e.vScale.axis;if(r){t||=e._parsed;for(let e of t){let t=e._stacks;if(!t||t[r]===void 0||t[r][n]===void 0)return;delete t[r][n],t[r]._visualValues!==void 0&&t[r]._visualValues[n]!==void 0&&delete t[r]._visualValues[n]}}}var Xr=e=>e===`reset`||e===`none`,Zr=(e,t)=>t?e:Object.assign({},e),Qr=(e,t,n)=>e&&!t.hidden&&t._stacked&&{keys:Lr(n,!0),values:null},$r=class{static defaults={};static datasetElementType=null;static dataElementType=null;constructor(e,t){this.chart=e,this._ctx=e.ctx,this.index=t,this._cachedDataOpts={},this._cachedMeta=this.getMeta(),this._type=this._cachedMeta.type,this.options=void 0,this._parsing=!1,this._data=void 0,this._objectData=void 0,this._sharedOptions=void 0,this._drawStart=void 0,this._drawCount=void 0,this.enableOptionSharing=!1,this.supportsDecimation=!1,this.$context=void 0,this._syncList=[],this.datasetElementType=new.target.datasetElementType,this.dataElementType=new.target.dataElementType,this.initialize()}initialize(){let e=this._cachedMeta;this.configure(),this.linkScales(),e._stacked=Br(e.vScale,e),this.addElements(),this.options.fill&&!this.chart.isPluginEnabled(`filler`)&&console.warn(`Tried to use the 'fill' option without the 'Filler' plugin enabled. Please import and register the 'Filler' plugin and make sure it is not disabled in the options`)}updateIndex(e){this.index!==e&&Yr(this._cachedMeta),this.index=e}linkScales(){let e=this.chart,t=this._cachedMeta,n=this.getDataset(),r=(e,t,n,r)=>e===`x`?t:e===`r`?r:n,i=t.xAxisID=P(n.xAxisID,Kr(e,`x`)),a=t.yAxisID=P(n.yAxisID,Kr(e,`y`)),o=t.rAxisID=P(n.rAxisID,Kr(e,`r`)),s=t.indexAxis,c=t.iAxisID=r(s,i,a,o),l=t.vAxisID=r(s,a,i,o);t.xScale=this.getScaleForId(i),t.yScale=this.getScaleForId(a),t.rScale=this.getScaleForId(o),t.iScale=this.getScaleForId(c),t.vScale=this.getScaleForId(l)}getDataset(){return this.chart.data.datasets[this.index]}getMeta(){return this.chart.getDatasetMeta(this.index)}getScaleForId(e){return this.chart.scales[e]}_getOtherScale(e){let t=this._cachedMeta;return e===t.iScale?t.vScale:t.iScale}reset(){this._update(`reset`)}_destroy(){let e=this._cachedMeta;this._data&<(this._data,this),e._stacked&&Yr(e)}_dataCheck(){let e=this.getDataset(),t=e.data||=[],n=this._data;if(j(t)){let e=this._cachedMeta;this._data=zr(t,e)}else if(n!==t){if(n){lt(n,this);let e=this._cachedMeta;Yr(e),e._parsed=[]}t&&Object.isExtensible(t)&&ct(t,this),this._syncList=[],this._data=t}}addElements(){let e=this._cachedMeta;this._dataCheck(),this.datasetElementType&&(e.dataset=new this.datasetElementType)}buildOrUpdateElements(e){let t=this._cachedMeta,n=this.getDataset(),r=!1;this._dataCheck();let i=t._stacked;t._stacked=Br(t.vScale,t),t.stack!==n.stack&&(r=!0,Yr(t),t.stack=n.stack),this._resyncElements(e),(r||i!==t._stacked)&&(Gr(this,t._parsed),t._stacked=Br(t.vScale,t))}configure(){let e=this.chart.config,t=e.datasetScopeKeys(this._type),n=e.getOptionScopes(this.getDataset(),t,!0);this.options=e.createResolver(n,this.getContext()),this._parsing=this.options.parsing,this._cachedDataOpts={}}parse(e,t){let{_cachedMeta:n,_data:r}=this,{iScale:i,_stacked:a}=n,o=i.axis,s=e===0&&t===r.length?!0:n._sorted,c=e>0&&n._parsed[e-1],l,u,d;if(this._parsing===!1)n._parsed=r,n._sorted=!0,d=r;else{d=A(r[e])?this.parseArrayData(n,r,e,t):j(r[e])?this.parseObjectData(n,r,e,t):this.parsePrimitiveData(n,r,e,t);let i=()=>u[o]===null||c&&u[o]t||u=0;--d)if(!p()){this.updateRangeFromParsed(c,e,f,s);break}}return c}getAllParsedValues(e){let t=this._cachedMeta._parsed,n=[],r,i,a;for(r=0,i=t.length;r=0&&ethis.getContext(n,r,t),u);return p.$shared&&(p.$shared=s,i[a]=Object.freeze(Zr(p,s))),p}_resolveAnimations(e,t,n){let r=this.chart,i=this._cachedDataOpts,a=`animation-${t}`,o=i[a];if(o)return o;let s;if(r.options.animation!==!1){let r=this.chart.config,i=r.datasetAnimationScopeKeys(this._type,t),a=r.getOptionScopes(this.getDataset(),i);s=r.createResolver(a,this.getContext(e,n,t))}let c=new jr(r,s&&s.animations);return s&&s._cacheable&&(i[a]=Object.freeze(c)),c}getSharedOptions(e){if(e.$shared)return this._sharedOptions||=Object.assign({},e)}includeOptions(e,t){return!t||Xr(e)||this.chart._animationsDisabled}_getSharedOptions(e,t){let n=this.resolveDataElementOptions(e,t),r=this._sharedOptions,i=this.getSharedOptions(n),a=this.includeOptions(t,i)||i!==r;return this.updateSharedOptions(i,t,n),{sharedOptions:i,includeOptions:a}}updateElement(e,t,n,r){Xr(r)?Object.assign(e,n):this._resolveAnimations(t,r).update(e,n)}updateSharedOptions(e,t,n){e&&!Xr(t)&&this._resolveAnimations(void 0,t).update(e,n)}_setStyle(e,t,n,r){e.active=r;let i=this.getStyle(t,r);this._resolveAnimations(t,n,r).update(e,{options:!r&&this.getSharedOptions(i)||i})}removeHoverStyle(e,t,n){this._setStyle(e,n,`active`,!1)}setHoverStyle(e,t,n){this._setStyle(e,n,`active`,!0)}_removeDatasetHoverStyle(){let e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,`active`,!1)}_setDatasetHoverStyle(){let e=this._cachedMeta.dataset;e&&this._setStyle(e,void 0,`active`,!0)}_resyncElements(e){let t=this._data,n=this._cachedMeta.data;for(let[e,t,n]of this._syncList)this[e](t,n);this._syncList=[];let r=n.length,i=t.length,a=Math.min(i,r);a&&this.parse(0,a),i>r?this._insertElements(r,i-r,e):i{for(e.length+=t,o=e.length-1;o>=a;o--)e[o]=e[o-t]};for(s(i),o=e;oe-t))}return e._cache.$bar}function ti(e){let t=e.iScale,n=ei(t,e.type),r=t._length,i,a,o,s,c=()=>{o===32767||o===-32768||(Me(s)&&(r=Math.min(r,Math.abs(o-s)||r)),s=o)};for(i=0,a=n.length;i0?i[e-1]:null,s=eMath.abs(s)&&(c=s,l=o),t[n.axis]=l,t._custom={barStart:c,barEnd:l,start:i,end:a,min:o,max:s}}function ai(e,t,n,r){return A(e)?ii(e,t,n,r):t[n.axis]=n.parse(e,r),t}function oi(e,t,n,r){let i=e.iScale,a=e.vScale,o=i.getLabels(),s=i===a,c=[],l,u,d,f;for(l=n,u=n+r;l=n?1:-1):B(e)}function li(e){let t,n,r,i,a;return e.horizontal?(t=e.base>e.x,n=`left`,r=`right`):(t=e.basee.controller.options.grouped),i=n.options.stacked,a=[],o=this._cachedMeta.controller.getParsed(t),s=o&&o[n.axis],c=e=>{let t=e._parsed.find(e=>e[n.axis]===s),r=t&&t[e.vScale.axis];if(k(r)||isNaN(r))return!0};for(let n of r)if(!(t!==void 0&&c(n))&&((i===!1||a.indexOf(n.stack)===-1||i===void 0&&n.stack===void 0)&&a.push(n.stack),n.index===e))break;return a.length||a.push(void 0),a}_getStackCount(e){return this._getStacks(void 0,e).length}_getAxisCount(){return this._getAxis().length}getFirstScaleIdForIndexAxis(){let e=this.chart.scales,t=this.chart.options.indexAxis;return Object.keys(e).filter(n=>e[n].axis===t).shift()}_getAxis(){let e={},t=this.getFirstScaleIdForIndexAxis();for(let n of this.chart.data.datasets)e[P(this.chart.options.indexAxis===`x`?n.xAxisID:n.yAxisID,t)]=!0;return Object.keys(e)}_getStackIndex(e,t,n){let r=this._getStacks(e,n),i=t===void 0?-1:r.indexOf(t);return i===-1?r.length-1:i}_getRuler(){let e=this.options,t=this._cachedMeta,n=t.iScale,r=[],i,a;for(i=0,a=t.data.length;iet(e,s,c,!0)?1:Math.max(t,t*n,r,r*n),m=(e,t,r)=>et(e,s,c,!0)?-1:Math.min(t,t*n,r,r*n),h=p(0,l,d),g=p(z,u,f),_=m(L,l,d),v=m(L+z,u,f);r=(h-_)/2,i=(g-v)/2,a=-(h+_)/2,o=-(g+v)/2}return{ratioX:r,ratioY:i,offsetX:a,offsetY:o}}var _i=class extends $r{static id=`doughnut`;static defaults={datasetElementType:!1,dataElementType:`arc`,animation:{animateRotate:!0,animateScale:!1},animations:{numbers:{type:`number`,properties:[`circumference`,`endAngle`,`innerRadius`,`outerRadius`,`startAngle`,`x`,`y`,`offset`,`borderWidth`,`spacing`]}},cutout:`50%`,rotation:0,circumference:360,radius:`100%`,spacing:0,indexAxis:`r`};static descriptors={_scriptable:e=>e!==`spacing`,_indexable:e=>e!==`spacing`&&!e.startsWith(`borderDash`)&&!e.startsWith(`hoverBorderDash`)};static overrides={aspectRatio:1,plugins:{legend:{labels:{generateLabels(e){let t=e.data,{labels:{pointStyle:n,textAlign:r,color:i,useBorderRadius:a,borderRadius:o}}=e.legend.options;return t.labels.length&&t.datasets.length?t.labels.map((t,s)=>{let c=e.getDatasetMeta(0).controller.getStyle(s);return{text:t,fillStyle:c.backgroundColor,fontColor:i,hidden:!e.getDataVisibility(s),lineDash:c.borderDash,lineDashOffset:c.borderDashOffset,lineJoin:c.borderJoinStyle,lineWidth:c.borderWidth,strokeStyle:c.borderColor,textAlign:r,pointStyle:n,borderRadius:a&&(o||c.borderRadius),index:s}}):[]}},onClick(e,t,n){n.chart.toggleDataVisibility(t.index),n.chart.update()}}}};constructor(e,t){super(e,t),this.enableOptionSharing=!0,this.innerRadius=void 0,this.outerRadius=void 0,this.offsetX=void 0,this.offsetY=void 0}linkScales(){}parse(e,t){let n=this.getDataset().data,r=this._cachedMeta;if(this._parsing===!1)r._parsed=n;else{let i=e=>+n[e];if(j(n[e])){let{key:e=`value`}=this._parsing;i=t=>+Ae(n[t],e)}let a,o;for(a=e,o=e+t;a0&&!isNaN(e)?Math.abs(e)/t*R:0}getLabelAndValue(e){let t=this._cachedMeta,n=this.chart,r=n.data.labels||[],i=jt(t._parsed[e],n.options.locale);return{label:r[e]||``,value:i}}getMaxBorderWidth(e){let t=0,n=this.chart,r,i,a,o,s;if(!e){for(r=0,i=n.data.datasets.length;r0&&this.getParsed(t-1);for(let n=0;n=_){v.skip=!0;continue}let b=this.getParsed(n),x=k(b[f]),S=v[d]=a.getPixelForValue(b[d],n),C=v[f]=i||x?o.getBasePixel():o.getPixelForValue(s?this.applyStack(o,b,s):b[f],n);v.skip=isNaN(S)||isNaN(C)||x,v.stop=n>0&&Math.abs(b[d]-y[d])>h,m&&(v.parsed=b,v.raw=c.data[n]),u&&(v.options=l||this.resolveDataElementOptions(n,p.active?`active`:r)),g||this.updateElement(p,n,v,r),y=b}}getMaxOverflow(){let e=this._cachedMeta,t=e.dataset,n=t.options&&t.options.borderWidth||0,r=e.data||[];if(!r.length)return n;let i=r[0].size(this.resolveDataElementOptions(0)),a=r[r.length-1].size(this.resolveDataElementOptions(r.length-1));return Math.max(n,i,a)/2}draw(){let e=this._cachedMeta;e.dataset.updateControlPoints(this.chart.chartArea,e.iScale.axis),super.draw()}},yi=class extends $r{static id=`scatter`;static defaults={datasetElementType:!1,dataElementType:`point`,showLine:!1,fill:!1};static overrides={interaction:{mode:`point`},scales:{x:{type:`linear`},y:{type:`linear`}}};getLabelAndValue(e){let t=this._cachedMeta,n=this.chart.data.labels||[],{xScale:r,yScale:i}=t,a=this.getParsed(e),o=r.getLabelForValue(a.x),s=i.getLabelForValue(a.y);return{label:n[e]||``,value:`(`+o+`, `+s+`)`}}update(e){let t=this._cachedMeta,{data:n=[]}=t,r=this.chart._animationsDisabled,{start:i,count:a}=gt(t,n,r);if(this._drawStart=i,this._drawCount=a,_t(t)&&(i=0,a=n.length),this.options.showLine){this.datasetElementType||this.addElements();let{dataset:i,_dataset:a}=t;i._chart=this.chart,i._datasetIndex=this.index,i._decimated=!!a._decimated,i.points=n;let o=this.resolveDatasetElementOptions(e);o.segment=this.options.segment,this.updateElement(i,void 0,{animated:!r,options:o},e)}else this.datasetElementType&&=(delete t.dataset,!1);this.updateElements(n,i,a,e)}addElements(){let{showLine:e}=this.options;!this.datasetElementType&&e&&(this.datasetElementType=this.chart.registry.getElement(`line`)),super.addElements()}updateElements(e,t,n,r){let i=r===`reset`,{iScale:a,vScale:o,_stacked:s,_dataset:c}=this._cachedMeta,l=this.resolveDataElementOptions(t,r),u=this.getSharedOptions(l),d=this.includeOptions(r,u),f=a.axis,p=o.axis,{spanGaps:m,segment:h}=this.options,g=Ke(m)?m:1/0,_=this.chart._animationsDisabled||i||r===`none`,v=t>0&&this.getParsed(t-1);for(let l=t;l0&&Math.abs(n[f]-v[f])>g,h&&(m.parsed=n,m.raw=c.data[l]),d&&(m.options=u||this.resolveDataElementOptions(l,t.active?`active`:r)),_||this.updateElement(t,l,m,r),v=n}this.updateSharedOptions(u,r,l)}getMaxOverflow(){let e=this._cachedMeta,t=e.data||[];if(!this.options.showLine){let e=0;for(let n=t.length-1;n>=0;--n)e=Math.max(e,t[n].size(this.resolveDataElementOptions(n))/2);return e>0&&e}let n=e.dataset,r=n.options&&n.options.borderWidth||0;if(!t.length)return r;let i=t[0].size(this.resolveDataElementOptions(0)),a=t[t.length-1].size(this.resolveDataElementOptions(t.length-1));return Math.max(r,i,a)/2}};function bi(){throw Error(`This method is not implemented: Check that a complete date adapter is provided.`)}var xi={_date:class e{static override(t){Object.assign(e.prototype,t)}options;constructor(e){this.options=e||{}}init(){}formats(){return bi()}parse(){return bi()}format(){return bi()}add(){return bi()}diff(){return bi()}startOf(){return bi()}endOf(){return bi()}}};function Si(e,t,n,r){let{controller:i,data:a,_sorted:o}=e,s=i._cachedMeta.iScale,c=e.dataset&&e.dataset.options?e.dataset.options.spanGaps:null;if(s&&t===s.axis&&t!==`r`&&o&&a.length){let o=s._reversePixels?at:it;if(!r){let r=o(a,t,n);if(c){let{vScale:t}=i._cachedMeta,{_parsed:n}=e,a=n.slice(0,r.lo+1).reverse().findIndex(e=>!k(e[t.axis]));r.lo-=Math.max(0,a);let o=n.slice(r.hi).findIndex(e=>!k(e[t.axis]));r.hi+=Math.max(0,o)}return r}else if(i._sharedOptions){let e=a[0],r=typeof e.getRange==`function`&&e.getRange(t);if(r){let e=o(a,t,n-r),i=o(a,t,n+r);return{lo:e.lo,hi:i.hi}}}}return{lo:0,hi:a.length-1}}function Ci(e,t,n,r,i){let a=e.getSortedVisibleDatasetMetas(),o=n[t];for(let e=0,n=a.length;e{e[o]&&e[o](t[n],i)&&(a.push({element:e,datasetIndex:r,index:c}),s||=e.inRange(t.x,t.y,i))}),r&&!s?[]:a}var Ai={evaluateInteractionItems:Ci,modes:{index(e,t,n,r){let i=Zn(t,e),a=n.axis||`x`,o=n.includeInvisible||!1,s=n.intersect?Ti(e,i,a,r,o):Oi(e,i,a,!1,r,o),c=[];return s.length?(e.getSortedVisibleDatasetMetas().forEach(e=>{let t=s[0].index,n=e.data[t];n&&!n.skip&&c.push({element:n,datasetIndex:e.index,index:t})}),c):[]},dataset(e,t,n,r){let i=Zn(t,e),a=n.axis||`xy`,o=n.includeInvisible||!1,s=n.intersect?Ti(e,i,a,r,o):Oi(e,i,a,!1,r,o);if(s.length>0){let t=s[0].datasetIndex,n=e.getDatasetMeta(t).data;s=[];for(let e=0;ee.pos===t)}function Ni(e,t){return e.filter(e=>ji.indexOf(e.pos)===-1&&e.box.axis===t)}function Pi(e,t){return e.sort((e,n)=>{let r=t?n:e,i=t?e:n;return r.weight===i.weight?r.index-i.index:r.weight-i.weight})}function Fi(e){let t=[],n,r,i,a,o,s;for(n=0,r=(e||[]).length;ne.box.fullSize),!0),r=Pi(Mi(t,`left`),!0),i=Pi(Mi(t,`right`)),a=Pi(Mi(t,`top`),!0),o=Pi(Mi(t,`bottom`)),s=Ni(t,`x`),c=Ni(t,`y`);return{fullSize:n,leftAndTop:r.concat(a),rightAndBottom:i.concat(c).concat(o).concat(s),chartArea:Mi(t,`chartArea`),vertical:r.concat(i).concat(c),horizontal:a.concat(o).concat(s)}}function zi(e,t,n,r){return Math.max(e[n],t[n])+Math.max(e[r],t[r])}function Bi(e,t){e.top=Math.max(e.top,t.top),e.left=Math.max(e.left,t.left),e.bottom=Math.max(e.bottom,t.bottom),e.right=Math.max(e.right,t.right)}function Vi(e,t,n,r){let{pos:i,box:a}=n,o=e.maxPadding;if(!j(i)){n.size&&(e[i]-=n.size);let t=r[n.stack]||{size:0,count:1};t.size=Math.max(t.size,n.horizontal?a.height:a.width),n.size=t.size/t.count,e[i]+=n.size}a.getPadding&&Bi(o,a.getPadding());let s=Math.max(0,t.outerWidth-zi(o,e,`left`,`right`)),c=Math.max(0,t.outerHeight-zi(o,e,`top`,`bottom`)),l=s!==e.w,u=c!==e.h;return e.w=s,e.h=c,n.horizontal?{same:l,other:u}:{same:u,other:l}}function Hi(e){let t=e.maxPadding;function n(n){let r=Math.max(t[n]-e[n],0);return e[n]+=r,r}e.y+=n(`top`),e.x+=n(`left`),n(`right`),n(`bottom`)}function Ui(e,t){let n=t.maxPadding;function r(e){let r={left:0,top:0,right:0,bottom:0};return e.forEach(e=>{r[e]=Math.max(t[e],n[e])}),r}return r(e?[`left`,`right`]:[`top`,`bottom`])}function Wi(e,t,n,r){let i=[],a,o,s,c,l,u;for(a=0,o=e.length,l=0;a{typeof e.beforeLayout==`function`&&e.beforeLayout()});let u=c.reduce((e,t)=>t.box.options&&t.box.options.display===!1?e:e+1,0)||1,d=Object.freeze({outerWidth:t,outerHeight:n,padding:i,availableWidth:a,availableHeight:o,vBoxMaxWidth:a/2/u,hBoxMaxHeight:o/2}),f=Object.assign({},i);Bi(f,q(r));let p=Object.assign({maxPadding:f,w:a,h:o,x:i.left,y:i.top},i),m=Li(c.concat(l),d);Wi(s.fullSize,p,d,m),Wi(c,p,d,m),Wi(l,p,d,m)&&Wi(c,p,d,m),Hi(p),Ki(s.leftAndTop,p,d,m),p.x+=p.w,p.y+=p.h,Ki(s.rightAndBottom,p,d,m),e.chartArea={left:p.left,top:p.top,right:p.left+p.w,bottom:p.top+p.h,height:p.h,width:p.w},I(s.chartArea,t=>{let n=t.box;Object.assign(n,e.chartArea),n.update(p.w,p.h,{left:0,top:0,right:0,bottom:0})})}},qi=class{acquireContext(e,t){}releaseContext(e){return!1}addEventListener(e,t,n){}removeEventListener(e,t,n){}getDevicePixelRatio(){return 1}getMaximumSize(e,t,n,r){return t=Math.max(0,t||e.width),n||=e.height,{width:t,height:Math.max(0,r?Math.floor(t/r):n)}}isAttached(e){return!0}updateConfig(e){}},Ji=class extends qi{acquireContext(e){return e&&e.getContext&&e.getContext(`2d`)||null}updateConfig(e){e.options.animation=!1}},Yi=`$chartjs`,Xi={touchstart:`mousedown`,touchmove:`mousemove`,touchend:`mouseup`,pointerenter:`mouseenter`,pointerdown:`mousedown`,pointermove:`mousemove`,pointerup:`mouseup`,pointerleave:`mouseout`,pointerout:`mouseout`},Zi=e=>e===null||e===``;function Qi(e,t){let n=e.style,r=e.getAttribute(`height`),i=e.getAttribute(`width`);if(e[Yi]={initial:{height:r,width:i,style:{display:n.display,height:n.height,width:n.width}}},n.display=n.display||`block`,n.boxSizing=n.boxSizing||`border-box`,Zi(i)){let t=rr(e,`width`);t!==void 0&&(e.width=t)}if(Zi(r))if(e.style.height===``)e.height=e.width/(t||2);else{let t=rr(e,`height`);t!==void 0&&(e.height=t)}return e}var $i=nr?{passive:!0}:!1;function ea(e,t,n){e&&e.addEventListener(t,n,$i)}function ta(e,t,n){e&&e.canvas&&e.canvas.removeEventListener(t,n,$i)}function na(e,t){let n=Xi[e.type]||e.type,{x:r,y:i}=Zn(e,t);return{type:n,chart:t,native:e,x:r===void 0?null:r,y:i===void 0?null:i}}function ra(e,t){for(let n of e)if(n===t||n.contains(t))return!0}function ia(e,t,n){let r=e.canvas,i=new MutationObserver(e=>{let t=!1;for(let n of e)t||=ra(n.addedNodes,r),t&&=!ra(n.removedNodes,r);t&&n()});return i.observe(document,{childList:!0,subtree:!0}),i}function aa(e,t,n){let r=e.canvas,i=new MutationObserver(e=>{let t=!1;for(let n of e)t||=ra(n.removedNodes,r),t&&=!ra(n.addedNodes,r);t&&n()});return i.observe(document,{childList:!0,subtree:!0}),i}var oa=new Map,sa=0;function ca(){let e=window.devicePixelRatio;e!==sa&&(sa=e,oa.forEach((t,n)=>{n.currentDevicePixelRatio!==e&&t()}))}function la(e,t){oa.size||window.addEventListener(`resize`,ca),oa.set(e,t)}function ua(e){oa.delete(e),oa.size||window.removeEventListener(`resize`,ca)}function da(e,t,n){let r=e.canvas,i=r&&Un(r);if(!i)return;let a=ft((e,t)=>{let r=i.clientWidth;n(e,t),r{let t=e[0],n=t.contentRect.width,r=t.contentRect.height;n===0&&r===0||a(n,r)});return o.observe(i),la(e,a),o}function fa(e,t,n){n&&n.disconnect(),t===`resize`&&ua(e)}function pa(e,t,n){let r=e.canvas,i=ft(t=>{e.ctx!==null&&n(na(t,e))},e);return ea(r,t,i),i}var ma=class extends qi{acquireContext(e,t){let n=e&&e.getContext&&e.getContext(`2d`);return n&&n.canvas===e?(Qi(e,t),n):null}releaseContext(e){let t=e.canvas;if(!t[Yi])return!1;let n=t[Yi].initial;[`height`,`width`].forEach(e=>{let r=n[e];k(r)?t.removeAttribute(e):t.setAttribute(e,r)});let r=n.style||{};return Object.keys(r).forEach(e=>{t.style[e]=r[e]}),t.width=t.width,delete t[Yi],!0}addEventListener(e,t,n){this.removeEventListener(e,t);let r=e.$proxies||={};r[t]=({attach:ia,detach:aa,resize:da}[t]||pa)(e,t,n)}removeEventListener(e,t){let n=e.$proxies||={},r=n[t];r&&(({attach:fa,detach:fa,resize:fa}[t]||ta)(e,t,r),n[t]=void 0)}getDevicePixelRatio(){return window.devicePixelRatio}getMaximumSize(e,t,n,r){return er(e,t,n,r)}isAttached(e){let t=e&&Un(e);return!!(t&&t.isConnected)}};function ha(e){return!Hn()||typeof OffscreenCanvas<`u`&&e instanceof OffscreenCanvas?Ji:ma}var ga=class{static defaults={};static defaultRoutes=void 0;x;y;active=!1;options;$animations;tooltipPosition(e){let{x:t,y:n}=this.getProps([`x`,`y`],e);return{x:t,y:n}}hasValue(){return Ke(this.x)&&Ke(this.y)}getProps(e,t){let n=this.$animations;if(!t||!n)return this;let r={};return e.forEach(e=>{r[e]=n[e]&&n[e].active()?n[e]._to:this[e]}),r}};function _a(e,t){let n=e.options.ticks,r=va(e),i=Math.min(n.maxTicksLimit||r,r),a=n.major.enabled?ba(t):[],o=a.length,s=a[0],c=a[o-1],l=[];if(o>i)return xa(t,l,a,o/i),l;let u=ya(a,t,i);if(o>0){let e,n,r=o>1?Math.round((c-s)/(o-1)):null;for(Sa(t,l,u,k(r)?0:s-r,s),e=0,n=o-1;ei)return t}return Math.max(i,1)}function ba(e){let t=[],n,r;for(n=0,r=e.length;ne===`left`?`right`:e===`right`?`left`:e,Ta=(e,t,n)=>t===`top`||t===`left`?e[t]+n:e[t]-n,Ea=(e,t)=>Math.min(t||e,e);function Da(e,t){let n=[],r=e.length/t,i=e.length,a=0;for(;ao+s)))return c}function ka(e,t){I(e,e=>{let n=e.gc,r=n.length/2,i;if(r>t){for(i=0;in?n:t,n=r&&t>n?t:n,{min:N(t,N(n,t)),max:N(n,N(t,n))}}getPadding(){return{left:this.paddingLeft||0,top:this.paddingTop||0,right:this.paddingRight||0,bottom:this.paddingBottom||0}}getTicks(){return this.ticks}getLabels(){let e=this.chart.data;return this.options.labels||(this.isHorizontal()?e.xLabels:e.yLabels)||e.labels||[]}getLabelItems(e=this.chart.chartArea){return this._labelItems||=this._computeLabelItems(e)}beforeLayout(){this._cache={},this._dataLimitsCached=!1}beforeUpdate(){F(this.options.beforeUpdate,[this])}update(e,t,n){let{beginAtZero:r,grace:i,ticks:a}=this.options,o=a.sampleSize;this.beforeUpdate(),this.maxWidth=e,this.maxHeight=t,this._margins=n=Object.assign({left:0,right:0,top:0,bottom:0},n),this.ticks=null,this._labelSizes=null,this._gridLineItems=null,this._labelItems=null,this.beforeSetDimensions(),this.setDimensions(),this.afterSetDimensions(),this._maxLength=this.isHorizontal()?this.width+n.left+n.right:this.height+n.top+n.bottom,this._dataLimitsCached||=(this.beforeDataLimits(),this.determineDataLimits(),this.afterDataLimits(),this._range=dn(this,i,r),!0),this.beforeBuildTicks(),this.ticks=this.buildTicks()||[],this.afterBuildTicks();let s=o=i||n<=1||!this.isHorizontal()){this.labelRotation=r;return}let l=this._getLabelSizes(),u=l.widest.width,d=l.highest.height,f=U(this.chart.width-u,0,this.maxWidth);o=e.offset?this.maxWidth/n:f/(n-1),u+6>o&&(o=f/(n-(e.offset?.5:1)),s=this.maxHeight-Aa(e.grid)-t.padding-ja(e.title,this.chart.options.font),c=Math.sqrt(u*u+d*d),a=Ye(Math.min(Math.asin(U((l.highest.height+6)/o,-1,1)),Math.asin(U(s/c,-1,1))-Math.asin(U(d/c,-1,1)))),a=Math.max(r,Math.min(i,a))),this.labelRotation=a}afterCalculateLabelRotation(){F(this.options.afterCalculateLabelRotation,[this])}afterAutoSkip(){}beforeFit(){F(this.options.beforeFit,[this])}fit(){let e={width:0,height:0},{chart:t,options:{ticks:n,title:r,grid:i}}=this,a=this._isVisible(),o=this.isHorizontal();if(a){let a=ja(r,t.options.font);if(o?(e.width=this.maxWidth,e.height=Aa(i)+a):(e.height=this.maxHeight,e.width=Aa(i)+a),n.display&&this.ticks.length){let{first:t,last:r,widest:i,highest:a}=this._getLabelSizes(),s=n.padding*2,c=V(this.labelRotation),l=Math.cos(c),u=Math.sin(c);if(o){let t=n.mirror?0:u*i.width+l*a.height;e.height=Math.min(this.maxHeight,e.height+t+s)}else{let t=n.mirror?0:l*i.width+u*a.height;e.width=Math.min(this.maxWidth,e.width+t+s)}this._calculatePadding(t,r,u,l)}}this._handleMargins(),o?(this.width=this._length=t.width-this._margins.left-this._margins.right,this.height=e.height):(this.width=e.width,this.height=this._length=t.height-this._margins.top-this._margins.bottom)}_calculatePadding(e,t,n,r){let{ticks:{align:i,padding:a},position:o}=this.options,s=this.labelRotation!==0,c=o!==`top`&&this.axis===`x`;if(this.isHorizontal()){let o=this.getPixelForTick(0)-this.left,l=this.right-this.getPixelForTick(this.ticks.length-1),u=0,d=0;s?c?(u=r*e.width,d=n*t.height):(u=n*e.height,d=r*t.width):i===`start`?d=t.width:i===`end`?u=e.width:i!==`inner`&&(u=e.width/2,d=t.width/2),this.paddingLeft=Math.max((u-o+a)*this.width/(this.width-o),0),this.paddingRight=Math.max((d-l+a)*this.width/(this.width-l),0)}else{let n=t.height/2,r=e.height/2;i===`start`?(n=0,r=e.height):i===`end`&&(n=t.height,r=0),this.paddingTop=n+a,this.paddingBottom=r+a}}_handleMargins(){this._margins&&(this._margins.left=Math.max(this.paddingLeft,this._margins.left),this._margins.top=Math.max(this.paddingTop,this._margins.top),this._margins.right=Math.max(this.paddingRight,this._margins.right),this._margins.bottom=Math.max(this.paddingBottom,this._margins.bottom))}afterFit(){F(this.options.afterFit,[this])}isHorizontal(){let{axis:e,position:t}=this.options;return t===`top`||t===`bottom`||e===`x`}isFullSize(){return this.options.fullSize}_convertTicksToLabels(e){this.beforeTickToLabelConversion(),this.generateTickLabels(e);let t,n;for(t=0,n=e.length;t({width:a[e]||0,height:o[e]||0});return{first:C(0),last:C(t-1),widest:C(x),highest:C(S),widths:a,heights:o}}getLabelForValue(e){return e}getPixelForValue(e,t){return NaN}getValueForPixel(e){}getPixelForTick(e){let t=this.ticks;return e<0||e>t.length-1?null:this.getPixelForValue(t[e].value)}getPixelForDecimal(e){this._reversePixels&&(e=1-e);let t=this._startPixel+e*this._length;return tt(this._alignToPixels?Ut(this.chart,t,0):t)}getDecimalForPixel(e){let t=(e-this._startPixel)/this._length;return this._reversePixels?1-t:t}getBasePixel(){return this.getPixelForValue(this.getBaseValue())}getBaseValue(){let{min:e,max:t}=this;return e<0&&t<0?t:e>0&&t>0?e:0}getContext(e){let t=this.ticks||[];if(e>=0&&eo*r?o/n:s/r:s*r0:!!e}_computeGridLineItems(e){let t=this.axis,n=this.chart,r=this.options,{grid:i,position:a,border:o}=r,s=i.offset,c=this.isHorizontal(),l=this.ticks.length+(s?1:0),u=Aa(i),d=[],f=o.setContext(this.getContext()),p=f.display?f.width:0,m=p/2,h=function(e){return Ut(n,e,p)},g,_,v,y,b,x,S,C,w,T,E,D;if(a===`top`)g=h(this.bottom),x=this.bottom-u,C=g-m,T=h(e.top)+m,D=e.bottom;else if(a===`bottom`)g=h(this.top),T=e.top,D=h(e.bottom)-m,x=g+m,C=this.top+u;else if(a===`left`)g=h(this.right),b=this.right-u,S=g-m,w=h(e.left)+m,E=e.right;else if(a===`right`)g=h(this.left),w=e.left,E=h(e.right)-m,b=g+m,S=this.left+u;else if(t===`x`){if(a===`center`)g=h((e.top+e.bottom)/2+.5);else if(j(a)){let e=Object.keys(a)[0],t=a[e];g=h(this.chart.scales[e].getPixelForValue(t))}T=e.top,D=e.bottom,x=g+m,C=x+u}else if(t===`y`){if(a===`center`)g=h((e.left+e.right)/2);else if(j(a)){let e=Object.keys(a)[0],t=a[e];g=h(this.chart.scales[e].getPixelForValue(t))}b=g-m,S=b-u,w=e.left,E=e.right}let ee=P(r.ticks.maxTicksLimit,l),O=Math.max(1,Math.ceil(l/ee));for(_=0;_0&&(a-=r/2);break}f={left:a,top:i,width:r+t.width,height:n+t.height,color:e.backdropColor}}h.push({label:y,font:w,textOffset:D,options:{rotation:m,color:n,strokeColor:s,strokeWidth:l,textAlign:d,textBaseline:ee,translation:[b,x],backdrop:f}})}return h}_getXAxisLabelAlignment(){let{position:e,ticks:t}=this.options;if(-V(this.labelRotation))return e===`top`?`left`:`right`;let n=`center`;return t.align===`start`?n=`left`:t.align===`end`?n=`right`:t.align===`inner`&&(n=`inner`),n}_getYAxisLabelAlignment(e){let{position:t,ticks:{crossAlign:n,mirror:r,padding:i}}=this.options,a=this._getLabelSizes(),o=e+i,s=a.widest.width,c,l;return t===`left`?r?(l=this.right+i,n===`near`?c=`left`:n===`center`?(c=`center`,l+=s/2):(c=`right`,l+=s)):(l=this.right-o,n===`near`?c=`right`:n===`center`?(c=`center`,l-=s/2):(c=`left`,l=this.left)):t===`right`?r?(l=this.left+i,n===`near`?c=`right`:n===`center`?(c=`center`,l-=s/2):(c=`left`,l-=s)):(l=this.left+o,n===`near`?c=`left`:n===`center`?(c=`center`,l+=s/2):(c=`right`,l=this.right)):c=`right`,{textAlign:c,x:l}}_computeLabelArea(){if(this.options.ticks.mirror)return;let e=this.chart,t=this.options.position;if(t===`left`||t===`right`)return{top:0,left:this.left,bottom:e.height,right:this.right};if(t===`top`||t===`bottom`)return{top:this.top,left:0,bottom:this.bottom,right:e.width}}drawBackground(){let{ctx:e,options:{backgroundColor:t},left:n,top:r,width:i,height:a}=this;t&&(e.save(),e.fillStyle=t,e.fillRect(n,r,i,a),e.restore())}getLineWidthForValue(e){let t=this.options.grid;if(!this._isVisible()||!t.display)return 0;let n=this.ticks.findIndex(t=>t.value===e);return n>=0?t.setContext(this.getContext(n)).lineWidth:0}drawGrid(e){let t=this.options.grid,n=this.ctx,r=this._gridLineItems||=this._computeGridLineItems(e),i,a,o=(e,t,r)=>{!r.width||!r.color||(n.save(),n.lineWidth=r.width,n.strokeStyle=r.color,n.setLineDash(r.borderDash||[]),n.lineDashOffset=r.borderDashOffset,n.beginPath(),n.moveTo(e.x,e.y),n.lineTo(t.x,t.y),n.stroke(),n.restore())};if(t.display)for(i=0,a=r.length;i{this.draw(e)}}]:[{z:r,draw:e=>{this.drawBackground(),this.drawGrid(e),this.drawTitle()}},{z:i,draw:()=>{this.drawBorder()}},{z:n,draw:e=>{this.drawLabels(e)}}]}getMatchingVisibleMetas(e){let t=this.chart.getSortedVisibleDatasetMetas(),n=this.axis+`AxisID`,r=[],i,a;for(i=0,a=t.length;i{let r=n.split(`.`),i=r.pop(),a=[e].concat(r).join(`.`),o=t[n].split(`.`),s=o.pop(),c=o.join(`.`);G.route(a,i,c,s)})}function Ba(e){return`id`in e&&`defaults`in e}var X=new class{constructor(){this.controllers=new La($r,`datasets`,!0),this.elements=new La(ga,`elements`),this.plugins=new La(Object,`plugins`),this.scales=new La(Ia,`scales`),this._typedRegistries=[this.controllers,this.scales,this.elements]}add(...e){this._each(`register`,e)}remove(...e){this._each(`unregister`,e)}addControllers(...e){this._each(`register`,e,this.controllers)}addElements(...e){this._each(`register`,e,this.elements)}addPlugins(...e){this._each(`register`,e,this.plugins)}addScales(...e){this._each(`register`,e,this.scales)}getController(e){return this._get(e,this.controllers,`controller`)}getElement(e){return this._get(e,this.elements,`element`)}getPlugin(e){return this._get(e,this.plugins,`plugin`)}getScale(e){return this._get(e,this.scales,`scale`)}removeControllers(...e){this._each(`unregister`,e,this.controllers)}removeElements(...e){this._each(`unregister`,e,this.elements)}removePlugins(...e){this._each(`unregister`,e,this.plugins)}removeScales(...e){this._each(`unregister`,e,this.scales)}_each(e,t,n){[...t].forEach(t=>{let r=n||this._getRegistryForType(t);n||r.isForType(t)||r===this.plugins&&t.id?this._exec(e,r,t):I(t,t=>{let r=n||this._getRegistryForType(t);this._exec(e,r,t)})})}_exec(e,t,n){let r=je(e);F(n[`before`+r],[],n),t[e](n),F(n[`after`+r],[],n)}_getRegistryForType(e){for(let t=0;te.filter(e=>!t.some(t=>e.plugin.id===t.plugin.id));this._notify(r(t,n),e,`stop`),this._notify(r(n,t),e,`start`)}};function Ha(e){let t={},n=[],r=Object.keys(X.plugins.items);for(let e=0;e1&&Ya(e[0].toLowerCase());if(t)return t}throw Error(`Cannot determine type of '${e}' axis. Please provide 'axis' or 'position' option.`)}function Qa(e,t,n){if(n[t+`AxisID`]===e)return{axis:t}}function $a(e,t){if(t.data&&t.data.datasets){let n=t.data.datasets.filter(t=>t.xAxisID===e||t.yAxisID===e);if(n.length)return Qa(e,`x`,n[0])||Qa(e,`y`,n[0])}return{}}function eo(e,t){let n=It[e.type]||{scales:{}},r=t.scales||{},i=Ka(e.type,t),a=Object.create(null);return Object.keys(r).forEach(t=>{let o=r[t];if(!j(o))return console.error(`Invalid scale configuration for scale: ${t}`);if(o._proxy)return console.warn(`Ignoring resolver passed as options for scale: ${t}`);let s=Za(t,o,$a(t,e),G.scales[o.type]),c=Ja(s,i),l=n.scales||{};a[t]=Te(Object.create(null),[{axis:s},o,l[s],l[c]])}),e.data.datasets.forEach(n=>{let i=n.type||e.type,o=n.indexAxis||Ka(i,t),s=(It[i]||{}).scales||{};Object.keys(s).forEach(e=>{let t=qa(e,o),i=n[t+`AxisID`]||t;a[i]=a[i]||Object.create(null),Te(a[i],[{axis:t},r[i],s[e]])})}),Object.keys(a).forEach(e=>{let t=a[e];Te(t,[G.scales[t.type],G.scale])}),a}function to(e){let t=e.options||={};t.plugins=P(t.plugins,{}),t.scales=eo(e,t)}function no(e){return e||={},e.datasets=e.datasets||[],e.labels=e.labels||[],e}function ro(e){return e||={},e.data=no(e.data),to(e),e}var io=new Map,ao=new Set;function oo(e,t){let n=io.get(e);return n||(n=t(),io.set(e,n),ao.add(n)),n}var so=(e,t,n)=>{let r=Ae(t,n);r!==void 0&&e.add(r)},co=class{constructor(e){this._config=ro(e),this._scopeCache=new Map,this._resolverCache=new Map}get platform(){return this._config.platform}get type(){return this._config.type}set type(e){this._config.type=e}get data(){return this._config.data}set data(e){this._config.data=no(e)}get options(){return this._config.options}set options(e){this._config.options=e}get plugins(){return this._config.plugins}update(){let e=this._config;this.clearCache(),to(e)}clearCache(){this._scopeCache.clear(),this._resolverCache.clear()}datasetScopeKeys(e){return oo(e,()=>[[`datasets.${e}`,``]])}datasetAnimationScopeKeys(e,t){return oo(`${e}.transition.${t}`,()=>[[`datasets.${e}.transitions.${t}`,`transitions.${t}`],[`datasets.${e}`,``]])}datasetElementScopeKeys(e,t){return oo(`${e}-${t}`,()=>[[`datasets.${e}.elements.${t}`,`datasets.${e}`,`elements.${t}`,``]])}pluginScopeKeys(e){let t=e.id,n=this.type;return oo(`${n}-plugin-${t}`,()=>[[`plugins.${t}`,...e.additionalOptionScopes||[]]])}_cachedScopes(e,t){let n=this._scopeCache,r=n.get(e);return(!r||t)&&(r=new Map,n.set(e,r)),r}getOptionScopes(e,t,n){let{options:r,type:i}=this,a=this._cachedScopes(e,n),o=a.get(t);if(o)return o;let s=new Set;t.forEach(t=>{e&&(s.add(e),t.forEach(t=>so(s,e,t))),t.forEach(e=>so(s,r,e)),t.forEach(e=>so(s,It[i]||{},e)),t.forEach(e=>so(s,G,e)),t.forEach(e=>so(s,Lt,e))});let c=Array.from(s);return c.length===0&&c.push(Object.create(null)),ao.has(t)&&a.set(t,c),c}chartOptionScopes(){let{options:e,type:t}=this;return[e,It[t]||{},G.datasets[t]||{},{type:t},G,Lt]}resolveNamedOptions(e,t,n,r=[``]){let i={$shared:!0},{resolver:a,subPrefixes:o}=lo(this._resolverCache,e,r),s=a;if(fo(a,t)){i.$shared=!1,n=Ne(n)?n():n;let t=this.createResolver(e,n,o);s=mn(a,n,t)}for(let e of t)i[e]=s[e];return i}createResolver(e,t,n=[``],r){let{resolver:i}=lo(this._resolverCache,e,n);return j(t)?mn(i,t,void 0,r):i}};function lo(e,t,n){let r=e.get(t);r||(r=new Map,e.set(t,r));let i=n.join(),a=r.get(i);return a||(a={resolver:pn(t,n),subPrefixes:n.filter(e=>!e.toLowerCase().includes(`hover`))},r.set(i,a)),a}var uo=e=>j(e)&&Object.getOwnPropertyNames(e).some(t=>Ne(e[t]));function fo(e,t){let{isScriptable:n,isIndexable:r}=hn(e);for(let i of t){let t=n(i),a=r(i),o=(a||t)&&e[i];if(t&&(Ne(o)||uo(o))||a&&A(o))return!0}return!1}var po=`4.5.1`,mo=[`top`,`bottom`,`left`,`right`,`chartArea`];function ho(e,t){return e===`top`||e===`bottom`||mo.indexOf(e)===-1&&t===`x`}function go(e,t){return function(n,r){return n[e]===r[e]?n[t]-r[t]:n[e]-r[e]}}function _o(e){let t=e.chart,n=t.options.animation;t.notifyPlugins(`afterRender`),F(n&&n.onComplete,[e],t)}function vo(e){let t=e.chart,n=t.options.animation;F(n&&n.onProgress,[e],t)}function yo(e){return Hn()&&typeof e==`string`?e=document.getElementById(e):e&&e.length&&(e=e[0]),e&&e.canvas&&(e=e.canvas),e}var bo={},xo=e=>{let t=yo(e);return Object.values(bo).filter(e=>e.canvas===t).pop()};function So(e,t,n){let r=Object.keys(e);for(let i of r){let r=+i;if(r>=t){let a=e[i];delete e[i],(n>0||r>t)&&(e[r+n]=a)}}}function Co(e,t,n,r){return!n||e.type===`mouseout`?null:r?t:e}var wo=class{static defaults=G;static instances=bo;static overrides=It;static registry=X;static version=po;static getChart=xo;static register(...e){X.add(...e),To()}static unregister(...e){X.remove(...e),To()}constructor(e,t){let n=this.config=new co(t),r=yo(e),i=xo(r);if(i)throw Error(`Canvas is already in use. Chart with ID '`+i.id+`' must be destroyed before the canvas with ID '`+i.canvas.id+`' can be reused.`);let a=n.createResolver(n.chartOptionScopes(),this.getContext());this.platform=new(n.platform||(ha(r))),this.platform.updateConfig(n);let o=this.platform.acquireContext(r,a.aspectRatio),s=o&&o.canvas,c=s&&s.height,l=s&&s.width;if(this.id=_e(),this.ctx=o,this.canvas=s,this.width=l,this.height=c,this._options=a,this._aspectRatio=this.aspectRatio,this._layers=[],this._metasets=[],this._stacks=void 0,this.boxes=[],this.currentDevicePixelRatio=void 0,this.chartArea=void 0,this._active=[],this._lastEvent=void 0,this._listeners={},this._responsiveListeners=void 0,this._sortedMetasets=[],this.scales={},this._plugins=new Va,this.$proxies={},this._hiddenIndices={},this.attached=!1,this._animationsDisabled=void 0,this.$context=void 0,this._doResize=pt(e=>this.update(e),a.resizeDelay||0),this._dataChanges=[],bo[this.id]=this,!o||!s){console.error(`Failed to create chart: can't acquire context from the given item`);return}Dr.listen(this,`complete`,_o),Dr.listen(this,`progress`,vo),this._initialize(),this.attached&&this.update()}get aspectRatio(){let{options:{aspectRatio:e,maintainAspectRatio:t},width:n,height:r,_aspectRatio:i}=this;return k(e)?t&&i?i:r?n/r:null:e}get data(){return this.config.data}set data(e){this.config.data=e}get options(){return this._options}set options(e){this.config.options=e}get registry(){return X}_initialize(){return this.notifyPlugins(`beforeInit`),this.options.responsive?this.resize():tr(this,this.options.devicePixelRatio),this.bindEvents(),this.notifyPlugins(`afterInit`),this}clear(){return Wt(this.canvas,this.ctx),this}stop(){return Dr.stop(this),this}resize(e,t){Dr.running(this)?this._resizeBeforeDraw={width:e,height:t}:this._resize(e,t)}_resize(e,t){let n=this.options,r=this.canvas,i=n.maintainAspectRatio&&this.aspectRatio,a=this.platform.getMaximumSize(r,e,t,i),o=n.devicePixelRatio||this.platform.getDevicePixelRatio(),s=this.width?`resize`:`attach`;this.width=a.width,this.height=a.height,this._aspectRatio=this.aspectRatio,tr(this,o,!0)&&(this.notifyPlugins(`resize`,{size:a}),F(n.onResize,[this,a],this),this.attached&&this._doResize(s)&&this.render())}ensureScalesHaveIDs(){I(this.options.scales||{},(e,t)=>{e.id=t})}buildOrUpdateScales(){let e=this.options,t=e.scales,n=this.scales,r=Object.keys(n).reduce((e,t)=>(e[t]=!1,e),{}),i=[];t&&(i=i.concat(Object.keys(t).map(e=>{let n=t[e],r=Za(e,n),i=r===`r`,a=r===`x`;return{options:n,dposition:i?`chartArea`:a?`bottom`:`left`,dtype:i?`radialLinear`:a?`category`:`linear`}}))),I(i,t=>{let i=t.options,a=i.id,o=Za(a,i),s=P(i.type,t.dtype);(i.position===void 0||ho(i.position,o)!==ho(t.dposition))&&(i.position=t.dposition),r[a]=!0;let c=null;a in n&&n[a].type===s?c=n[a]:(c=new(X.getScale(s))({id:a,type:s,ctx:this.ctx,chart:this}),n[c.id]=c),c.init(i,e)}),I(r,(e,t)=>{e||delete n[t]}),I(n,e=>{Y.configure(this,e,e.options),Y.addBox(this,e)})}_updateMetasets(){let e=this._metasets,t=this.data.datasets.length,n=e.length;if(e.sort((e,t)=>e.index-t.index),n>t){for(let e=t;et.length&&delete this._stacks,e.forEach((e,n)=>{t.filter(t=>t===e._dataset).length===0&&this._destroyDatasetMeta(n)})}buildOrUpdateControllers(){let e=[],t=this.data.datasets,n,r;for(this._removeUnreferencedMetasets(),n=0,r=t.length;n{this.getDatasetMeta(t).controller.reset()},this)}reset(){this._resetElements(),this.notifyPlugins(`reset`)}update(e){let t=this.config;t.update();let n=this._options=t.createResolver(t.chartOptionScopes(),this.getContext()),r=this._animationsDisabled=!n.animation;if(this._updateScales(),this._checkEventBindings(),this._updateHiddenIndices(),this._plugins.invalidate(),this.notifyPlugins(`beforeUpdate`,{mode:e,cancelable:!0})===!1)return;let i=this.buildOrUpdateControllers();this.notifyPlugins(`beforeElementsUpdate`);let a=0;for(let e=0,t=this.data.datasets.length;e{e.reset()}),this._updateDatasets(e),this.notifyPlugins(`afterUpdate`,{mode:e}),this._layers.sort(go(`z`,`_idx`));let{_active:o,_lastEvent:s}=this;s?this._eventHandler(s,!0):o.length&&this._updateHoverStyles(o,o,!0),this.render()}_updateScales(){I(this.scales,e=>{Y.removeBox(this,e)}),this.ensureScalesHaveIDs(),this.buildOrUpdateScales()}_checkEventBindings(){let e=this.options;(!Pe(new Set(Object.keys(this._listeners)),new Set(e.events))||!!this._responsiveListeners!==e.responsive)&&(this.unbindEvents(),this.bindEvents())}_updateHiddenIndices(){let{_hiddenIndices:e}=this,t=this._getUniformDataChanges()||[];for(let{method:n,start:r,count:i}of t)So(e,r,n===`_removeElements`?-i:i)}_getUniformDataChanges(){let e=this._dataChanges;if(!e||!e.length)return;this._dataChanges=[];let t=this.data.datasets.length,n=t=>new Set(e.filter(e=>e[0]===t).map((e,t)=>t+`,`+e.splice(1).join(`,`))),r=n(0);for(let e=1;ee.split(`,`)).map(e=>({method:e[1],start:+e[2],count:+e[3]}))}_updateLayout(e){if(this.notifyPlugins(`beforeLayout`,{cancelable:!0})===!1)return;Y.update(this,this.width,this.height,e);let t=this.chartArea,n=t.width<=0||t.height<=0;this._layers=[],I(this.boxes,e=>{n&&e.position===`chartArea`||(e.configure&&e.configure(),this._layers.push(...e._layers()))},this),this._layers.forEach((e,t)=>{e._idx=t}),this.notifyPlugins(`afterLayout`)}_updateDatasets(e){if(this.notifyPlugins(`beforeDatasetsUpdate`,{mode:e,cancelable:!0})!==!1){for(let e=0,t=this.data.datasets.length;e=0;--t)this._drawDataset(e[t]);this.notifyPlugins(`afterDatasetsDraw`)}_drawDataset(e){let t=this.ctx,n={meta:e,index:e.index,cancelable:!0},r=Er(this,e);this.notifyPlugins(`beforeDatasetDraw`,n)!==!1&&(r&&qt(t,r),e.controller.draw(),r&&Jt(t),n.cancelable=!1,this.notifyPlugins(`afterDatasetDraw`,n))}isPointInArea(e){return K(e,this.chartArea,this._minPadding)}getElementsAtEventForMode(e,t,n,r){let i=Ai.modes[t];return typeof i==`function`?i(this,e,n,r):[]}getDatasetMeta(e){let t=this.data.datasets[e],n=this._metasets,r=n.filter(e=>e&&e._dataset===t).pop();return r||(r={type:null,data:[],dataset:null,controller:null,hidden:null,xAxisID:null,yAxisID:null,order:t&&t.order||0,index:e,_dataset:t,_parsed:[],_sorted:!1},n.push(r)),r}getContext(){return this.$context||=fn(null,{chart:this,type:`chart`})}getVisibleDatasetCount(){return this.getSortedVisibleDatasetMetas().length}isDatasetVisible(e){let t=this.data.datasets[e];if(!t)return!1;let n=this.getDatasetMeta(e);return typeof n.hidden==`boolean`?!n.hidden:!t.hidden}setDatasetVisibility(e,t){let n=this.getDatasetMeta(e);n.hidden=!t}toggleDataVisibility(e){this._hiddenIndices[e]=!this._hiddenIndices[e]}getDataVisibility(e){return!this._hiddenIndices[e]}_updateVisibility(e,t,n){let r=n?`show`:`hide`,i=this.getDatasetMeta(e),a=i.controller._resolveAnimations(void 0,r);Me(t)?(i.data[t].hidden=!n,this.update()):(this.setDatasetVisibility(e,n),a.update(i,{visible:n}),this.update(t=>t.datasetIndex===e?r:void 0))}hide(e,t){this._updateVisibility(e,t,!1)}show(e,t){this._updateVisibility(e,t,!0)}_destroyDatasetMeta(e){let t=this._metasets[e];t&&t.controller&&t.controller._destroy(),delete this._metasets[e]}_stop(){let e,t;for(this.stop(),Dr.remove(this),e=0,t=this.data.datasets.length;e{t.addEventListener(this,n,r),e[n]=r},r=(e,t,n)=>{e.offsetX=t,e.offsetY=n,this._eventHandler(e)};I(this.options.events,e=>n(e,r))}bindResponsiveEvents(){this._responsiveListeners||={};let e=this._responsiveListeners,t=this.platform,n=(n,r)=>{t.addEventListener(this,n,r),e[n]=r},r=(n,r)=>{e[n]&&(t.removeEventListener(this,n,r),delete e[n])},i=(e,t)=>{this.canvas&&this.resize(e,t)},a,o=()=>{r(`attach`,o),this.attached=!0,this.resize(),n(`resize`,i),n(`detach`,a)};a=()=>{this.attached=!1,r(`resize`,i),this._stop(),this._resize(0,0),n(`attach`,o)},t.isAttached(this.canvas)?o():a()}unbindEvents(){I(this._listeners,(e,t)=>{this.platform.removeEventListener(this,t,e)}),this._listeners={},I(this._responsiveListeners,(e,t)=>{this.platform.removeEventListener(this,t,e)}),this._responsiveListeners=void 0}updateHoverStyle(e,t,n){let r=n?`set`:`remove`,i,a,o,s;for(t===`dataset`&&(i=this.getDatasetMeta(e[0].datasetIndex),i.controller[`_`+r+`DatasetHoverStyle`]()),o=0,s=e.length;o{let n=this.getDatasetMeta(e);if(!n)throw Error(`No dataset found at index `+e);return{datasetIndex:e,element:n.data[t],index:t}});be(n,t)||(this._active=n,this._lastEvent=null,this._updateHoverStyles(n,t))}notifyPlugins(e,t,n){return this._plugins.notify(this,e,t,n)}isPluginEnabled(e){return this._plugins._cache.filter(t=>t.plugin.id===e).length===1}_updateHoverStyles(e,t,n){let r=this.options.hover,i=(e,t)=>e.filter(e=>!t.some(t=>e.datasetIndex===t.datasetIndex&&e.index===t.index)),a=i(t,e),o=n?e:i(e,t);a.length&&this.updateHoverStyle(a,r.mode,!1),o.length&&r.mode&&this.updateHoverStyle(o,r.mode,!0)}_eventHandler(e,t){let n={event:e,replay:t,cancelable:!0,inChartArea:this.isPointInArea(e)},r=t=>(t.options.events||this.options.events).includes(e.native.type);if(this.notifyPlugins(`beforeEvent`,n,r)===!1)return;let i=this._handleEvent(e,t,n.inChartArea);return n.cancelable=!1,this.notifyPlugins(`afterEvent`,n,r),(i||n.changed)&&this.render(),this}_handleEvent(e,t,n){let{_active:r=[],options:i}=this,a=t,o=this._getActiveElements(e,r,n,a),s=Fe(e),c=Co(e,this._lastEvent,n,s);n&&(this._lastEvent=null,F(i.onHover,[e,o,this],this),s&&F(i.onClick,[e,o,this],this));let l=!be(o,r);return(l||t)&&(this._active=o,this._updateHoverStyles(o,r,t)),this._lastEvent=c,l}_getActiveElements(e,t,n,r){if(e.type===`mouseout`)return[];if(!n)return t;let i=this.options.hover;return this.getElementsAtEventForMode(e,i.mode,i,r)}};function To(){return I(wo.instances,e=>e._plugins.invalidate())}function Eo(e,t,n){let{startAngle:r,x:i,y:a,outerRadius:o,innerRadius:s,options:c}=t,{borderWidth:l,borderJoinStyle:u}=c,d=Math.min(l/o,H(r-n));if(e.beginPath(),e.arc(i,a,o-l/2,r+d/2,n-d/2),s>0){let t=Math.min(l/s,H(r-n));e.arc(i,a,s+l/2,n-t/2,r+t/2,!0)}else{let t=Math.min(l/2,o*H(r-n));if(u===`round`)e.arc(i,a,t,n-L/2,r+L/2,!0);else if(u===`bevel`){let o=2*t*t,s=-o*Math.cos(n+L/2)+i,c=-o*Math.sin(n+L/2)+a,l=o*Math.cos(r+L/2)+i,u=o*Math.sin(r+L/2)+a;e.lineTo(s,c),e.lineTo(l,u)}}e.closePath(),e.moveTo(0,0),e.rect(0,0,e.canvas.width,e.canvas.height),e.clip(`evenodd`)}function Do(e,t,n){let{startAngle:r,pixelMargin:i,x:a,y:o,outerRadius:s,innerRadius:c}=t,l=i/s;e.beginPath(),e.arc(a,o,s,r-l,n+l),c>i?(l=i/c,e.arc(a,o,c,n+l,r-l,!0)):e.arc(a,o,i,n+z,r-z),e.closePath(),e.clip()}function Oo(e){return sn(e,[`outerStart`,`outerEnd`,`innerStart`,`innerEnd`])}function ko(e,t,n,r){let i=Oo(e.options.borderRadius),a=(n-t)/2,o=Math.min(a,r*t/2),s=e=>{let t=(n-Math.min(a,e))*r/2;return U(e,0,Math.min(a,t))};return{outerStart:s(i.outerStart),outerEnd:s(i.outerEnd),innerStart:U(i.innerStart,0,o),innerEnd:U(i.innerEnd,0,o)}}function Ao(e,t,n,r){return{x:n+e*Math.cos(t),y:r+e*Math.sin(t)}}function jo(e,t,n,r,i,a){let{x:o,y:s,startAngle:c,pixelMargin:l,innerRadius:u}=t,d=Math.max(t.outerRadius+r+n-l,0),f=u>0?u+r+n+l:0,p=0,m=i-c;if(r){let e=((u>0?u-r:0)+(d>0?d-r:0))/2;p=(m-(e===0?m:m*e/(e+r)))/2}let h=(m-Math.max(.001,m*d-n/L)/d)/2,g=c+h+p,_=i-h-p,{outerStart:v,outerEnd:y,innerStart:b,innerEnd:x}=ko(t,f,d,_-g),S=d-v,C=d-y,w=g+v/S,T=_-y/C,E=f+b,D=f+x,ee=g+b/E,O=_-x/D;if(e.beginPath(),a){let t=(w+T)/2;if(e.arc(o,s,d,w,t),e.arc(o,s,d,t,T),y>0){let t=Ao(C,T,o,s);e.arc(t.x,t.y,y,T,_+z)}let n=Ao(D,_,o,s);if(e.lineTo(n.x,n.y),x>0){let t=Ao(D,O,o,s);e.arc(t.x,t.y,x,_+z,O+Math.PI)}let r=(_-x/f+(g+b/f))/2;if(e.arc(o,s,f,_-x/f,r,!0),e.arc(o,s,f,r,g+b/f,!0),b>0){let t=Ao(E,ee,o,s);e.arc(t.x,t.y,b,ee+Math.PI,g-z)}let i=Ao(S,g,o,s);if(e.lineTo(i.x,i.y),v>0){let t=Ao(S,w,o,s);e.arc(t.x,t.y,v,g-z,w)}}else{e.moveTo(o,s);let t=Math.cos(w)*d+o,n=Math.sin(w)*d+s;e.lineTo(t,n);let r=Math.cos(T)*d+o,i=Math.sin(T)*d+s;e.lineTo(r,i)}e.closePath()}function Mo(e,t,n,r,i){let{fullCircles:a,startAngle:o,circumference:s}=t,c=t.endAngle;if(a){jo(e,t,n,r,c,i);for(let t=0;t=L&&p===0&&u!==`miter`&&Eo(e,t,h),a||(jo(e,t,n,r,h,i),e.stroke())}var Po=class extends ga{static id=`arc`;static defaults={borderAlign:`center`,borderColor:`#fff`,borderDash:[],borderDashOffset:0,borderJoinStyle:void 0,borderRadius:0,borderWidth:2,offset:0,spacing:0,angle:void 0,circular:!0,selfJoin:!1};static defaultRoutes={backgroundColor:`backgroundColor`};static descriptors={_scriptable:!0,_indexable:e=>e!==`borderDash`};circumference;endAngle;fullCircles;innerRadius;outerRadius;pixelMargin;startAngle;constructor(e){super(),this.options=void 0,this.circumference=void 0,this.startAngle=void 0,this.endAngle=void 0,this.innerRadius=void 0,this.outerRadius=void 0,this.pixelMargin=0,this.fullCircles=0,e&&Object.assign(this,e)}inRange(e,t,n){let{angle:r,distance:i}=Ze(this.getProps([`x`,`y`],n),{x:e,y:t}),{startAngle:a,endAngle:o,innerRadius:s,outerRadius:c,circumference:l}=this.getProps([`startAngle`,`endAngle`,`innerRadius`,`outerRadius`,`circumference`],n),u=(this.options.spacing+this.options.borderWidth)/2,d=P(l,o-a),f=et(r,a,o)&&a!==o,p=d>=R||f,m=nt(i,s+u,c+u);return p&&m}getCenterPoint(e){let{x:t,y:n,startAngle:r,endAngle:i,innerRadius:a,outerRadius:o}=this.getProps([`x`,`y`,`startAngle`,`endAngle`,`innerRadius`,`outerRadius`],e),{offset:s,spacing:c}=this.options,l=(r+i)/2,u=(a+o+c+s)/2;return{x:t+Math.cos(l)*u,y:n+Math.sin(l)*u}}tooltipPosition(e){return this.getCenterPoint(e)}draw(e){let{options:t,circumference:n}=this,r=(t.offset||0)/4,i=(t.spacing||0)/2,a=t.circular;if(this.pixelMargin=t.borderAlign===`inner`?.33:0,this.fullCircles=n>R?Math.floor(n/R):0,n===0||this.innerRadius<0||this.outerRadius<0)return;e.save();let o=(this.startAngle+this.endAngle)/2;e.translate(Math.cos(o)*r,Math.sin(o)*r);let s=r*(1-Math.sin(Math.min(L,n||0)));e.fillStyle=t.backgroundColor,e.strokeStyle=t.borderColor,Mo(e,this,s,i,a),No(e,this,s,i,a),e.restore()}};function Fo(e,t,n=t){e.lineCap=P(n.borderCapStyle,t.borderCapStyle),e.setLineDash(P(n.borderDash,t.borderDash)),e.lineDashOffset=P(n.borderDashOffset,t.borderDashOffset),e.lineJoin=P(n.borderJoinStyle,t.borderJoinStyle),e.lineWidth=P(n.borderWidth,t.borderWidth),e.strokeStyle=P(n.borderColor,t.borderColor)}function Io(e,t,n){e.lineTo(n.x,n.y)}function Lo(e){return e.stepped?Yt:e.tension||e.cubicInterpolationMode===`monotone`?Xt:Io}function Ro(e,t,n={}){let r=e.length,{start:i=0,end:a=r-1}=n,{start:o,end:s}=t,c=Math.max(i,o),l=Math.min(a,s),u=is&&a>s;return{count:r,start:c,loop:t.loop,ilen:l(o+(l?s-e:e))%a,y=()=>{h!==g&&(e.lineTo(u,g),e.lineTo(u,h),e.lineTo(u,_))};for(c&&(p=i[v(0)],e.moveTo(p.x,p.y)),f=0;f<=s;++f){if(p=i[v(f)],p.skip)continue;let t=p.x,n=p.y,r=t|0;r===m?(ng&&(g=n),u=(d*u+t)/++d):(y(),e.lineTo(t,n),m=r,d=0,h=g=n),_=n}y()}function Vo(e){let t=e.options,n=t.borderDash&&t.borderDash.length;return!e._decimated&&!e._loop&&!t.tension&&t.cubicInterpolationMode!==`monotone`&&!t.stepped&&!n?Bo:zo}function Ho(e){return e.stepped?ar:e.tension||e.cubicInterpolationMode===`monotone`?or:ir}function Uo(e,t,n,r){let i=t._path;i||(i=t._path=new Path2D,t.path(i,n,r)&&i.closePath()),Fo(e,t.options),e.stroke(i)}function Wo(e,t,n,r){let{segments:i,options:a}=t,o=Vo(t);for(let s of i)Fo(e,a,s.style),e.beginPath(),o(e,t,s,{start:n,end:n+r-1})&&e.closePath(),e.stroke()}var Go=typeof Path2D==`function`;function Ko(e,t,n,r){Go&&!t.options.segment?Uo(e,t,n,r):Wo(e,t,n,r)}var qo=class extends ga{static id=`line`;static defaults={borderCapStyle:`butt`,borderDash:[],borderDashOffset:0,borderJoinStyle:`miter`,borderWidth:3,capBezierPoints:!0,cubicInterpolationMode:`default`,fill:!1,spanGaps:!1,stepped:!1,tension:0};static defaultRoutes={backgroundColor:`backgroundColor`,borderColor:`borderColor`};static descriptors={_scriptable:!0,_indexable:e=>e!==`borderDash`&&e!==`fill`};constructor(e){super(),this.animated=!0,this.options=void 0,this._chart=void 0,this._loop=void 0,this._fullLoop=void 0,this._path=void 0,this._points=void 0,this._segments=void 0,this._decimated=!1,this._pointsUpdated=!1,this._datasetIndex=void 0,e&&Object.assign(this,e)}updateControlPoints(e,t){let n=this.options;if((n.tension||n.cubicInterpolationMode===`monotone`)&&!n.stepped&&!this._pointsUpdated){let r=n.spanGaps?this._loop:this._fullLoop;Vn(this._points,n,e,r,t),this._pointsUpdated=!0}}set points(e){this._points=e,delete this._segments,delete this._path,this._pointsUpdated=!1}get points(){return this._points}get segments(){return this._segments||=yr(this,this.options.segment)}first(){let e=this.segments,t=this.points;return e.length&&t[e[0].start]}last(){let e=this.segments,t=this.points,n=e.length;return n&&t[e[n-1].end]}interpolate(e,t){let n=this.options,r=e[t],i=this.points,a=gr(this,{property:t,start:r,end:r});if(!a.length)return;let o=[],s=Ho(n),c,l;for(c=0,l=a.length;c{t=ls(e,t,i);let o=i[e],s=i[t];r===null?n!==null&&(a.push({x:n,y:o.y}),a.push({x:n,y:s.y})):(a.push({x:o.x,y:r}),a.push({x:s.x,y:r}))}),a}function ls(e,t,n){for(;t>e;t--){let e=n[t];if(!isNaN(e.x)&&!isNaN(e.y))break}return t}function us(e,t,n,r){return e&&t?r(e[n],t[n]):e?e[n]:t?t[n]:0}function ds(e,t){let n=[],r=!1;return A(e)?(r=!0,n=e):n=cs(e,t),n.length?new qo({points:n,options:{tension:0},_loop:r,_fullLoop:r}):null}function fs(e){return e&&e.fill!==!1}function ps(e,t,n){let r=e[t].fill,i=[t],a;if(!n)return r;for(;r!==!1&&i.indexOf(r)===-1;){if(!M(r))return r;if(a=e[r],!a)return!1;if(a.visible)return r;i.push(r),r=a.fill}return!1}function ms(e,t,n){let r=vs(e);if(j(r))return isNaN(r.value)?!1:r;let i=parseFloat(r);return M(i)&&Math.floor(i)===i?hs(r[0],t,i,n):[`origin`,`start`,`end`,`stack`,`shape`].indexOf(r)>=0&&r}function hs(e,t,n,r){return(e===`-`||e===`+`)&&(n=t+n),n===t||n<0||n>=r?!1:n}function gs(e,t){let n=null;return e===`start`?n=t.bottom:e===`end`?n=t.top:j(e)?n=t.getPixelForValue(e.value):t.getBasePixel&&(n=t.getBasePixel()),n}function _s(e,t,n){let r;return r=e===`start`?n:e===`end`?t.options.reverse?t.min:t.max:j(e)?e.value:t.getBaseValue(),r}function vs(e){let t=e.options,n=t.fill,r=P(n&&n.target,n);return r===void 0&&(r=!!t.backgroundColor),r===!1||r===null?!1:r===!0?`origin`:r}function ys(e){let{scale:t,index:n,line:r}=e,i=[],a=r.segments,o=r.points,s=bs(t,n);s.push(ds({x:null,y:t.bottom},r));for(let e=0;e=0;--t){let n=i[t].$filler;n&&(n.line.updateControlPoints(a,n.axis),r&&n.fill&&ks(e.ctx,n,a))}},beforeDatasetsDraw(e,t,n){if(n.drawTime!==`beforeDatasetsDraw`)return;let r=e.getSortedVisibleDatasetMetas();for(let t=r.length-1;t>=0;--t){let n=r[t].$filler;fs(n)&&ks(e.ctx,n,e.chartArea)}},beforeDatasetDraw(e,t,n){let r=t.meta.$filler;!fs(r)||n.drawTime!==`beforeDatasetDraw`||ks(e.ctx,r,e.chartArea)},defaults:{propagate:!0,drawTime:`beforeDatasetDraw`}},Ls=(e,t)=>{let{boxHeight:n=t,boxWidth:r=t}=e;return e.usePointStyle&&(n=Math.min(n,t),r=e.pointStyleWidth||Math.min(r,t)),{boxWidth:r,boxHeight:n,itemHeight:Math.max(t,n)}},Rs=(e,t)=>e!==null&&t!==null&&e.datasetIndex===t.datasetIndex&&e.index===t.index,zs=class extends ga{constructor(e){super(),this._added=!1,this.legendHitBoxes=[],this._hoveredItem=null,this.doughnutMode=!1,this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this.legendItems=void 0,this.columnSizes=void 0,this.lineWidths=void 0,this.maxHeight=void 0,this.maxWidth=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.height=void 0,this.width=void 0,this._margins=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,t,n){this.maxWidth=e,this.maxHeight=t,this._margins=n,this.setDimensions(),this.buildLabels(),this.fit()}setDimensions(){this.isHorizontal()?(this.width=this.maxWidth,this.left=this._margins.left,this.right=this.width):(this.height=this.maxHeight,this.top=this._margins.top,this.bottom=this.height)}buildLabels(){let e=this.options.labels||{},t=F(e.generateLabels,[this.chart],this)||[];e.filter&&(t=t.filter(t=>e.filter(t,this.chart.data))),e.sort&&(t=t.sort((t,n)=>e.sort(t,n,this.chart.data))),this.options.reverse&&t.reverse(),this.legendItems=t}fit(){let{options:e,ctx:t}=this;if(!e.display){this.width=this.height=0;return}let n=e.labels,r=J(n.font),i=r.size,a=this._computeTitleHeight(),{boxWidth:o,itemHeight:s}=Ls(n,i),c,l;t.font=r.string,this.isHorizontal()?(c=this.maxWidth,l=this._fitRows(a,i,o,s)+10):(l=this.maxHeight,c=this._fitCols(a,r,o,s)+10),this.width=Math.min(c,e.maxWidth||this.maxWidth),this.height=Math.min(l,e.maxHeight||this.maxHeight)}_fitRows(e,t,n,r){let{ctx:i,maxWidth:a,options:{labels:{padding:o}}}=this,s=this.legendHitBoxes=[],c=this.lineWidths=[0],l=r+o,u=e;i.textAlign=`left`,i.textBaseline=`middle`;let d=-1,f=-l;return this.legendItems.forEach((e,p)=>{let m=n+t/2+i.measureText(e.text).width;(p===0||c[c.length-1]+m+2*o>a)&&(u+=l,c[c.length-(p>0?0:1)]=0,f+=l,d++),s[p]={left:0,top:f,row:d,width:m,height:r},c[c.length-1]+=m+o}),u}_fitCols(e,t,n,r){let{ctx:i,maxHeight:a,options:{labels:{padding:o}}}=this,s=this.legendHitBoxes=[],c=this.columnSizes=[],l=a-e,u=o,d=0,f=0,p=0,m=0;return this.legendItems.forEach((e,a)=>{let{itemWidth:h,itemHeight:g}=Bs(n,t,i,e,r);a>0&&f+g+2*o>l&&(u+=d+o,c.push({width:d,height:f}),p+=d+o,m++,d=f=0),s[a]={left:p,top:f,col:m,width:h,height:g},d=Math.max(d,h),f+=g+o}),u+=d,c.push({width:d,height:f}),u}adjustHitBoxes(){if(!this.options.display)return;let e=this._computeTitleHeight(),{legendHitBoxes:t,options:{align:n,labels:{padding:r},rtl:i}}=this,a=lr(i,this.left,this.width);if(this.isHorizontal()){let i=0,o=W(n,this.left+r,this.right-this.lineWidths[i]);for(let s of t)i!==s.row&&(i=s.row,o=W(n,this.left+r,this.right-this.lineWidths[i])),s.top+=this.top+e+r,s.left=a.leftForLtr(a.x(o),s.width),o+=s.width+r}else{let i=0,o=W(n,this.top+e+r,this.bottom-this.columnSizes[i].height);for(let s of t)s.col!==i&&(i=s.col,o=W(n,this.top+e+r,this.bottom-this.columnSizes[i].height)),s.top=o,s.left+=this.left+r,s.left=a.leftForLtr(a.x(s.left),s.width),o+=s.height+r}}isHorizontal(){return this.options.position===`top`||this.options.position===`bottom`}draw(){if(this.options.display){let e=this.ctx;qt(e,this),this._draw(),Jt(e)}}_draw(){let{options:e,columnSizes:t,lineWidths:n,ctx:r}=this,{align:i,labels:a}=e,o=G.color,s=lr(e.rtl,this.left,this.width),c=J(a.font),{padding:l}=a,u=c.size,d=u/2,f;this.drawTitle(),r.textAlign=s.textAlign(`left`),r.textBaseline=`middle`,r.lineWidth=.5,r.font=c.string;let{boxWidth:p,boxHeight:m,itemHeight:h}=Ls(a,u),g=function(e,t,n){if(isNaN(p)||p<=0||isNaN(m)||m<0)return;r.save();let i=P(n.lineWidth,1);if(r.fillStyle=P(n.fillStyle,o),r.lineCap=P(n.lineCap,`butt`),r.lineDashOffset=P(n.lineDashOffset,0),r.lineJoin=P(n.lineJoin,`miter`),r.lineWidth=i,r.strokeStyle=P(n.strokeStyle,o),r.setLineDash(P(n.lineDash,[])),a.usePointStyle)Kt(r,{radius:m*Math.SQRT2/2,pointStyle:n.pointStyle,rotation:n.rotation,borderWidth:i},s.xPlus(e,p/2),t+d,a.pointStyleWidth&&p);else{let a=t+Math.max((u-m)/2,0),o=s.leftForLtr(e,p),c=ln(n.borderRadius);r.beginPath(),Object.values(c).some(e=>e!==0)?tn(r,{x:o,y:a,w:p,h:m,radius:c}):r.rect(o,a,p,m),r.fill(),i!==0&&r.stroke()}r.restore()},_=function(e,t,n){en(r,n.text,e,t+h/2,c,{strikethrough:n.hidden,textAlign:s.textAlign(n.textAlign)})},v=this.isHorizontal(),y=this._computeTitleHeight();f=v?{x:W(i,this.left+l,this.right-n[0]),y:this.top+l+y,line:0}:{x:this.left+l,y:W(i,this.top+y+l,this.bottom-t[0].height),line:0},ur(this.ctx,e.textDirection);let b=h+l;this.legendItems.forEach((o,u)=>{r.strokeStyle=o.fontColor,r.fillStyle=o.fontColor;let m=r.measureText(o.text).width,h=s.textAlign(o.textAlign||=a.textAlign),x=p+d+m,S=f.x,C=f.y;if(s.setWidth(this.width),v?u>0&&S+x+l>this.right&&(C=f.y+=b,f.line++,S=f.x=W(i,this.left+l,this.right-n[f.line])):u>0&&C+b>this.bottom&&(S=f.x=S+t[f.line].width+l,f.line++,C=f.y=W(i,this.top+y+l,this.bottom-t[f.line].height)),g(s.x(S),C,o),S=ht(h,S+p+d,v?S+x:this.right,e.rtl),_(s.x(S),C,o),v)f.x+=x+l;else if(typeof o.text!=`string`){let e=c.lineHeight;f.y+=Us(o,e)+l}else f.y+=b}),dr(this.ctx,e.textDirection)}drawTitle(){let e=this.options,t=e.title,n=J(t.font),r=q(t.padding);if(!t.display)return;let i=lr(e.rtl,this.left,this.width),a=this.ctx,o=t.position,s=n.size/2,c=r.top+s,l,u=this.left,d=this.width;if(this.isHorizontal())d=Math.max(...this.lineWidths),l=this.top+c,u=W(e.align,u,this.right-d);else{let t=this.columnSizes.reduce((e,t)=>Math.max(e,t.height),0);l=c+W(e.align,this.top,this.bottom-t-e.labels.padding-this._computeTitleHeight())}let f=W(o,u,u+d);a.textAlign=i.textAlign(mt(o)),a.textBaseline=`middle`,a.strokeStyle=t.color,a.fillStyle=t.color,a.font=n.string,en(a,t.text,f,l,n)}_computeTitleHeight(){let e=this.options.title,t=J(e.font),n=q(e.padding);return e.display?t.lineHeight+n.height:0}_getLegendItemAt(e,t){let n,r,i;if(nt(e,this.left,this.right)&&nt(t,this.top,this.bottom)){for(i=this.legendHitBoxes,n=0;ne.length>t.length?e:t)),t+n.size/2+r.measureText(i).width}function Hs(e,t,n){let r=e;return typeof t.text!=`string`&&(r=Us(t,n)),r}function Us(e,t){return t*(e.text?e.text.length:0)}function Ws(e,t){return!!((e===`mousemove`||e===`mouseout`)&&(t.onHover||t.onLeave)||t.onClick&&(e===`click`||e===`mouseup`))}var Gs={id:`legend`,_element:zs,start(e,t,n){let r=e.legend=new zs({ctx:e.ctx,options:n,chart:e});Y.configure(e,r,n),Y.addBox(e,r)},stop(e){Y.removeBox(e,e.legend),delete e.legend},beforeUpdate(e,t,n){let r=e.legend;Y.configure(e,r,n),r.options=n},afterUpdate(e){let t=e.legend;t.buildLabels(),t.adjustHitBoxes()},afterEvent(e,t){t.replay||e.legend.handleEvent(t.event)},defaults:{display:!0,position:`top`,align:`center`,fullSize:!0,reverse:!1,weight:1e3,onClick(e,t,n){let r=t.datasetIndex,i=n.chart;i.isDatasetVisible(r)?(i.hide(r),t.hidden=!0):(i.show(r),t.hidden=!1)},onHover:null,onLeave:null,labels:{color:e=>e.chart.options.color,boxWidth:40,padding:10,generateLabels(e){let t=e.data.datasets,{labels:{usePointStyle:n,pointStyle:r,textAlign:i,color:a,useBorderRadius:o,borderRadius:s}}=e.legend.options;return e._getSortedDatasetMetas().map(e=>{let c=e.controller.getStyle(n?0:void 0),l=q(c.borderWidth);return{text:t[e.index].label,fillStyle:c.backgroundColor,fontColor:a,hidden:!e.visible,lineCap:c.borderCapStyle,lineDash:c.borderDash,lineDashOffset:c.borderDashOffset,lineJoin:c.borderJoinStyle,lineWidth:(l.width+l.height)/4,strokeStyle:c.borderColor,pointStyle:r||c.pointStyle,rotation:c.rotation,textAlign:i||c.textAlign,borderRadius:o&&(s||c.borderRadius),datasetIndex:e.index}},this)}},title:{color:e=>e.chart.options.color,display:!1,position:`center`,text:``}},descriptors:{_scriptable:e=>!e.startsWith(`on`),labels:{_scriptable:e=>![`generateLabels`,`filter`,`sort`].includes(e)}}},Ks=class extends ga{constructor(e){super(),this.chart=e.chart,this.options=e.options,this.ctx=e.ctx,this._padding=void 0,this.top=void 0,this.bottom=void 0,this.left=void 0,this.right=void 0,this.width=void 0,this.height=void 0,this.position=void 0,this.weight=void 0,this.fullSize=void 0}update(e,t){let n=this.options;if(this.left=0,this.top=0,!n.display){this.width=this.height=this.right=this.bottom=0;return}this.width=this.right=e,this.height=this.bottom=t;let r=A(n.text)?n.text.length:1;this._padding=q(n.padding);let i=r*J(n.font).lineHeight+this._padding.height;this.isHorizontal()?this.height=i:this.width=i}isHorizontal(){let e=this.options.position;return e===`top`||e===`bottom`}_drawArgs(e){let{top:t,left:n,bottom:r,right:i,options:a}=this,o=a.align,s=0,c,l,u;return this.isHorizontal()?(l=W(o,n,i),u=t+e,c=i-n):(a.position===`left`?(l=n+e,u=W(o,r,t),s=L*-.5):(l=i-e,u=W(o,t,r),s=L*.5),c=r-t),{titleX:l,titleY:u,maxWidth:c,rotation:s}}draw(){let e=this.ctx,t=this.options;if(!t.display)return;let n=J(t.font),r=n.lineHeight/2+this._padding.top,{titleX:i,titleY:a,maxWidth:o,rotation:s}=this._drawArgs(r);en(e,t.text,0,0,n,{color:t.color,maxWidth:o,rotation:s,textAlign:mt(t.align),textBaseline:`middle`,translation:[i,a]})}};function qs(e,t){let n=new Ks({ctx:e.ctx,options:t,chart:e});Y.configure(e,n,t),Y.addBox(e,n),e.titleBlock=n}var Js={id:`title`,_element:Ks,start(e,t,n){qs(e,n)},stop(e){let t=e.titleBlock;Y.removeBox(e,t),delete e.titleBlock},beforeUpdate(e,t,n){let r=e.titleBlock;Y.configure(e,r,n),r.options=n},defaults:{align:`center`,display:!1,font:{weight:`bold`},fullSize:!0,padding:10,position:`top`,text:``,weight:2e3},defaultRoutes:{color:`color`},descriptors:{_scriptable:!0,_indexable:!1}},Ys={average(e){if(!e.length)return!1;let t,n,r=new Set,i=0,a=0;for(t=0,n=e.length;te+t)/r.size,y:i/a}},nearest(e,t){if(!e.length)return!1;let n=t.x,r=t.y,i=1/0,a,o,s;for(a=0,o=e.length;a-1?e.split(` +`):e}function Zs(e,t){let{element:n,datasetIndex:r,index:i}=t,a=e.getDatasetMeta(r).controller,{label:o,value:s}=a.getLabelAndValue(i);return{chart:e,label:o,parsed:a.getParsed(i),raw:e.data.datasets[r].data[i],formattedValue:s,dataset:a.getDataset(),dataIndex:i,datasetIndex:r,element:n}}function Qs(e,t){let n=e.chart.ctx,{body:r,footer:i,title:a}=e,{boxWidth:o,boxHeight:s}=t,c=J(t.bodyFont),l=J(t.titleFont),u=J(t.footerFont),d=a.length,f=i.length,p=r.length,m=q(t.padding),h=m.height,g=0,_=r.reduce((e,t)=>e+t.before.length+t.lines.length+t.after.length,0);if(_+=e.beforeBody.length+e.afterBody.length,d&&(h+=d*l.lineHeight+(d-1)*t.titleSpacing+t.titleMarginBottom),_){let e=t.displayColors?Math.max(s,c.lineHeight):c.lineHeight;h+=p*e+(_-p)*c.lineHeight+(_-1)*t.bodySpacing}f&&(h+=t.footerMarginTop+f*u.lineHeight+(f-1)*t.footerSpacing);let v=0,y=function(e){g=Math.max(g,n.measureText(e).width+v)};return n.save(),n.font=l.string,I(e.title,y),n.font=c.string,I(e.beforeBody.concat(e.afterBody),y),v=t.displayColors?o+2+t.boxPadding:0,I(r,e=>{I(e.before,y),I(e.lines,y),I(e.after,y)}),v=0,n.font=u.string,I(e.footer,y),n.restore(),g+=m.width,{width:g,height:h}}function $s(e,t){let{y:n,height:r}=t;return ne.height-r/2?`bottom`:`center`}function ec(e,t,n,r){let{x:i,width:a}=r,o=n.caretSize+n.caretPadding;if(e===`left`&&i+a+o>t.width||e===`right`&&i-a-o<0)return!0}function tc(e,t,n,r){let{x:i,width:a}=n,{width:o,chartArea:{left:s,right:c}}=e,l=`center`;return r===`center`?l=i<=(s+c)/2?`left`:`right`:i<=a/2?l=`left`:i>=o-a/2&&(l=`right`),ec(l,e,t,n)&&(l=`center`),l}function nc(e,t,n){let r=n.yAlign||t.yAlign||$s(e,n);return{xAlign:n.xAlign||t.xAlign||tc(e,t,n,r),yAlign:r}}function rc(e,t){let{x:n,width:r}=e;return t===`right`?n-=r:t===`center`&&(n-=r/2),n}function ic(e,t,n){let{y:r,height:i}=e;return t===`top`?r+=n:t===`bottom`?r-=i+n:r-=i/2,r}function ac(e,t,n,r){let{caretSize:i,caretPadding:a,cornerRadius:o}=e,{xAlign:s,yAlign:c}=n,l=i+a,{topLeft:u,topRight:d,bottomLeft:f,bottomRight:p}=ln(o),m=rc(t,s),h=ic(t,c,l);return c===`center`?s===`left`?m+=l:s===`right`&&(m-=l):s===`left`?m-=Math.max(u,f)+i:s===`right`&&(m+=Math.max(d,p)+i),{x:U(m,0,r.width-t.width),y:U(h,0,r.height-t.height)}}function oc(e,t,n){let r=q(n.padding);return t===`center`?e.x+e.width/2:t===`right`?e.x+e.width-r.right:e.x+r.left}function sc(e){return Z([],Xs(e))}function cc(e,t,n){return fn(e,{tooltip:t,tooltipItems:n,type:`tooltip`})}function lc(e,t){let n=t&&t.dataset&&t.dataset.tooltip&&t.dataset.tooltip.callbacks;return n?e.override(n):e}var uc={beforeTitle:ge,title(e){if(e.length>0){let t=e[0],n=t.chart.data.labels,r=n?n.length:0;if(this&&this.options&&this.options.mode===`dataset`)return t.dataset.label||``;if(t.label)return t.label;if(r>0&&t.dataIndex{let t={before:[],lines:[],after:[]},i=lc(n,e);Z(t.before,Xs(Q(i,`beforeLabel`,this,e))),Z(t.lines,Q(i,`label`,this,e)),Z(t.after,Xs(Q(i,`afterLabel`,this,e))),r.push(t)}),r}getAfterBody(e,t){return sc(Q(t.callbacks,`afterBody`,this,e))}getFooter(e,t){let{callbacks:n}=t,r=Q(n,`beforeFooter`,this,e),i=Q(n,`footer`,this,e),a=Q(n,`afterFooter`,this,e),o=[];return o=Z(o,Xs(r)),o=Z(o,Xs(i)),o=Z(o,Xs(a)),o}_createItems(e){let t=this._active,n=this.chart.data,r=[],i=[],a=[],o=[],s,c;for(s=0,c=t.length;se.filter(t,r,i,n))),e.itemSort&&(o=o.sort((t,r)=>e.itemSort(t,r,n))),I(o,t=>{let n=lc(e.callbacks,t);r.push(Q(n,`labelColor`,this,t)),i.push(Q(n,`labelPointStyle`,this,t)),a.push(Q(n,`labelTextColor`,this,t))}),this.labelColors=r,this.labelPointStyles=i,this.labelTextColors=a,this.dataPoints=o,o}update(e,t){let n=this.options.setContext(this.getContext()),r=this._active,i,a=[];if(!r.length)this.opacity!==0&&(i={opacity:0});else{let e=Ys[n.position].call(this,r,this._eventPosition);a=this._createItems(n),this.title=this.getTitle(a,n),this.beforeBody=this.getBeforeBody(a,n),this.body=this.getBody(a,n),this.afterBody=this.getAfterBody(a,n),this.footer=this.getFooter(a,n);let t=this._size=Qs(this,n),o=Object.assign({},e,t),s=nc(this.chart,n,o),c=ac(n,o,s,this.chart);this.xAlign=s.xAlign,this.yAlign=s.yAlign,i={opacity:1,x:c.x,y:c.y,width:t.width,height:t.height,caretX:e.x,caretY:e.y}}this._tooltipItems=a,this.$context=void 0,i&&this._resolveAnimations().update(this,i),e&&n.external&&n.external.call(this,{chart:this.chart,tooltip:this,replay:t})}drawCaret(e,t,n,r){let i=this.getCaretPosition(e,n,r);t.lineTo(i.x1,i.y1),t.lineTo(i.x2,i.y2),t.lineTo(i.x3,i.y3)}getCaretPosition(e,t,n){let{xAlign:r,yAlign:i}=this,{caretSize:a,cornerRadius:o}=n,{topLeft:s,topRight:c,bottomLeft:l,bottomRight:u}=ln(o),{x:d,y:f}=e,{width:p,height:m}=t,h,g,_,v,y,b;return i===`center`?(y=f+m/2,r===`left`?(h=d,g=h-a,v=y+a,b=y-a):(h=d+p,g=h+a,v=y-a,b=y+a),_=h):(g=r===`left`?d+Math.max(s,l)+a:r===`right`?d+p-Math.max(c,u)-a:this.caretX,i===`top`?(v=f,y=v-a,h=g-a,_=g+a):(v=f+m,y=v+a,h=g+a,_=g-a),b=v),{x1:h,x2:g,x3:_,y1:v,y2:y,y3:b}}drawTitle(e,t,n){let r=this.title,i=r.length,a,o,s;if(i){let c=lr(n.rtl,this.x,this.width);for(e.x=oc(this,n.titleAlign,n),t.textAlign=c.textAlign(n.titleAlign),t.textBaseline=`middle`,a=J(n.titleFont),o=n.titleSpacing,t.fillStyle=n.titleColor,t.font=a.string,s=0;se!==0)?(e.beginPath(),e.fillStyle=i.multiKeyBackground,tn(e,{x:t,y:p,w:c,h:s,radius:o}),e.fill(),e.stroke(),e.fillStyle=a.backgroundColor,e.beginPath(),tn(e,{x:n,y:p+1,w:c-2,h:s-2,radius:o}),e.fill()):(e.fillStyle=i.multiKeyBackground,e.fillRect(t,p,c,s),e.strokeRect(t,p,c,s),e.fillStyle=a.backgroundColor,e.fillRect(n,p+1,c-2,s-2))}e.fillStyle=this.labelTextColors[n]}drawBody(e,t,n){let{body:r}=this,{bodySpacing:i,bodyAlign:a,displayColors:o,boxHeight:s,boxWidth:c,boxPadding:l}=n,u=J(n.bodyFont),d=u.lineHeight,f=0,p=lr(n.rtl,this.x,this.width),m=function(n){t.fillText(n,p.x(e.x+f),e.y+d/2),e.y+=d+i},h=p.textAlign(a),g,_,v,y,b,x,S;for(t.textAlign=a,t.textBaseline=`middle`,t.font=u.string,e.x=oc(this,h,n),t.fillStyle=n.bodyColor,I(this.beforeBody,m),f=o&&h!==`right`?a===`center`?c/2+l:c+2+l:0,y=0,x=r.length;y0&&t.stroke()}_updateAnimationTarget(e){let t=this.chart,n=this.$animations,r=n&&n.x,i=n&&n.y;if(r||i){let n=Ys[e.position].call(this,this._active,this._eventPosition);if(!n)return;let a=this._size=Qs(this,e),o=Object.assign({},n,this._size),s=nc(t,e,o),c=ac(e,o,s,t);(r._to!==c.x||i._to!==c.y)&&(this.xAlign=s.xAlign,this.yAlign=s.yAlign,this.width=a.width,this.height=a.height,this.caretX=n.x,this.caretY=n.y,this._resolveAnimations().update(this,c))}}_willRender(){return!!this.opacity}draw(e){let t=this.options.setContext(this.getContext()),n=this.opacity;if(!n)return;this._updateAnimationTarget(t);let r={width:this.width,height:this.height},i={x:this.x,y:this.y};n=Math.abs(n)<.001?0:n;let a=q(t.padding),o=this.title.length||this.beforeBody.length||this.body.length||this.afterBody.length||this.footer.length;t.enabled&&o&&(e.save(),e.globalAlpha=n,this.drawBackground(i,e,r,t),ur(e,t.textDirection),i.y+=a.top,this.drawTitle(i,e,t),this.drawBody(i,e,t),this.drawFooter(i,e,t),dr(e,t.textDirection),e.restore())}getActiveElements(){return this._active||[]}setActiveElements(e,t){let n=this._active,r=e.map(({datasetIndex:e,index:t})=>{let n=this.chart.getDatasetMeta(e);if(!n)throw Error(`Cannot find a dataset at index `+e);return{datasetIndex:e,element:n.data[t],index:t}}),i=!be(n,r),a=this._positionChanged(r,t);(i||a)&&(this._active=r,this._eventPosition=t,this._ignoreReplayEvents=!0,this.update(!0))}handleEvent(e,t,n=!0){if(t&&this._ignoreReplayEvents)return!1;this._ignoreReplayEvents=!1;let r=this.options,i=this._active||[],a=this._getActiveElements(e,i,t,n),o=this._positionChanged(a,e),s=t||!be(a,i)||o;return s&&(this._active=a,(r.enabled||r.external)&&(this._eventPosition={x:e.x,y:e.y},this.update(!0,t))),s}_getActiveElements(e,t,n,r){let i=this.options;if(e.type===`mouseout`)return[];if(!r)return t.filter(e=>this.chart.data.datasets[e.datasetIndex]&&this.chart.getDatasetMeta(e.datasetIndex).controller.getParsed(e.index)!==void 0);let a=this.chart.getElementsAtEventForMode(e,i.mode,i,n);return i.reverse&&a.reverse(),a}_positionChanged(e,t){let{caretX:n,caretY:r,options:i}=this,a=Ys[i.position].call(this,e,t);return a!==!1&&(n!==a.x||r!==a.y)}},fc={id:`tooltip`,_element:dc,positioners:Ys,afterInit(e,t,n){n&&(e.tooltip=new dc({chart:e,options:n}))},beforeUpdate(e,t,n){e.tooltip&&e.tooltip.initialize(n)},reset(e,t,n){e.tooltip&&e.tooltip.initialize(n)},afterDraw(e){let t=e.tooltip;if(t&&t._willRender()){let n={tooltip:t};if(e.notifyPlugins(`beforeTooltipDraw`,{...n,cancelable:!0})===!1)return;t.draw(e.ctx),e.notifyPlugins(`afterTooltipDraw`,n)}},afterEvent(e,t){if(e.tooltip){let n=t.replay;e.tooltip.handleEvent(t.event,n,t.inChartArea)&&(t.changed=!0)}},defaults:{enabled:!0,external:null,position:`average`,backgroundColor:`rgba(0,0,0,0.8)`,titleColor:`#fff`,titleFont:{weight:`bold`},titleSpacing:2,titleMarginBottom:6,titleAlign:`left`,bodyColor:`#fff`,bodySpacing:2,bodyFont:{},bodyAlign:`left`,footerColor:`#fff`,footerSpacing:2,footerMarginTop:6,footerFont:{weight:`bold`},footerAlign:`left`,padding:6,caretPadding:2,caretSize:5,cornerRadius:6,boxHeight:(e,t)=>t.bodyFont.size,boxWidth:(e,t)=>t.bodyFont.size,multiKeyBackground:`#fff`,displayColors:!0,boxPadding:0,borderColor:`rgba(0,0,0,0)`,borderWidth:0,animation:{duration:400,easing:`easeOutQuart`},animations:{numbers:{type:`number`,properties:[`x`,`y`,`width`,`height`,`caretX`,`caretY`]},opacity:{easing:`linear`,duration:200}},callbacks:uc},defaultRoutes:{bodyFont:`font`,footerFont:`font`,titleFont:`font`},descriptors:{_scriptable:e=>e!==`filter`&&e!==`itemSort`&&e!==`external`,_indexable:!1,callbacks:{_scriptable:!1,_indexable:!1},animation:{_fallback:!1},animations:{_fallback:`animation`}},additionalOptionScopes:[`interaction`]},pc=(e,t,n,r)=>(typeof t==`string`?(n=e.push(t)-1,r.unshift({index:n,label:t})):isNaN(t)&&(n=null),n);function mc(e,t,n,r){let i=e.indexOf(t);return i===-1?pc(e,t,n,r):i===e.lastIndexOf(t)?i:n}var hc=(e,t)=>e===null?null:U(Math.round(e),0,t);function gc(e){let t=this.getLabels();return e>=0&&et.length-1?null:this.getPixelForValue(t[e].value)}getValueForPixel(e){return Math.round(this._startValue+this.getDecimalForPixel(e)*this._valueRange)}getBasePixel(){return this.bottom}};function vc(e,t){let n=[],{bounds:r,step:i,min:a,max:o,precision:s,count:c,maxTicks:l,maxDigits:u,includeBounds:d}=e,f=i||1,p=l-1,{min:m,max:h}=t,g=!k(a),_=!k(o),v=!k(c),y=(h-m)/(u+1),b=Ue((h-m)/p/f)*f,x,S,C,w;if(b<1e-14&&!g&&!_)return[{value:m},{value:h}];w=Math.ceil(h/b)-Math.floor(m/b),w>p&&(b=Ue(w*b/p/f)*f),k(s)||(x=10**s,b=Math.ceil(b*x)/x),r===`ticks`?(S=Math.floor(m/b)*b,C=Math.ceil(h/b)*b):(S=m,C=h),g&&_&&i&&qe((o-a)/i,b/1e3)?(w=Math.round(Math.min((o-a)/b,l)),b=(o-a)/w,S=a,C=o):v?(S=g?a:S,C=_?o:C,w=c-1,b=(C-S)/w):(w=(C-S)/b,w=He(w,Math.round(w),b/1e3)?Math.round(w):Math.ceil(w));let T=Math.max(Xe(b),Xe(S));x=10**(k(s)?T:s),S=Math.round(S*x)/x,C=Math.round(C*x)/x;let E=0;for(g&&(d&&S!==a?(n.push({value:a}),So)break;n.push({value:e})}return _&&d&&C!==o?n.length&&He(n[n.length-1].value,o,yc(o,y,e))?n[n.length-1].value=o:n.push({value:o}):(!_||C===o)&&n.push({value:C}),n}function yc(e,t,{horizontal:n,minRotation:r}){let i=V(r),a=(n?Math.sin(i):Math.cos(i))||.001,o=.75*t*(``+e).length;return Math.min(t/a,o)}var bc=class extends Ia{constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._endValue=void 0,this._valueRange=0}parse(e,t){return k(e)||(typeof e==`number`||e instanceof Number)&&!isFinite(+e)?null:+e}handleTickRangeOptions(){let{beginAtZero:e}=this.options,{minDefined:t,maxDefined:n}=this.getUserBounds(),{min:r,max:i}=this,a=e=>r=t?r:e,o=e=>i=n?i:e;if(e){let e=B(r),t=B(i);e<0&&t<0?o(0):e>0&&t>0&&a(0)}if(r===i){let t=i===0?1:Math.abs(i*.05);o(i+t),e||a(r-t)}this.min=r,this.max=i}getTickLimit(){let{maxTicksLimit:e,stepSize:t}=this.options.ticks,n;return t?(n=Math.ceil(this.max/t)-Math.floor(this.min/t)+1,n>1e3&&(console.warn(`scales.${this.id}.ticks.stepSize: ${t} would result generating up to ${n} ticks. Limiting to 1000.`),n=1e3)):(n=this.computeTickLimit(),e||=11),e&&(n=Math.min(e,n)),n}computeTickLimit(){return 1/0}buildTicks(){let e=this.options,t=e.ticks,n=this.getTickLimit();n=Math.max(2,n);let r=vc({maxTicks:n,bounds:e.bounds,min:e.min,max:e.max,precision:t.precision,step:t.stepSize,count:t.count,maxDigits:this._maxDigits(),horizontal:this.isHorizontal(),minRotation:t.minRotation||0,includeBounds:t.includeBounds!==!1},this._range||this);return e.bounds===`ticks`&&Je(r,this,`value`),e.reverse?(r.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),r}configure(){let e=this.ticks,t=this.min,n=this.max;if(super.configure(),this.options.offset&&e.length){let r=(n-t)/Math.max(e.length-1,1)/2;t-=r,n+=r}this._startValue=t,this._endValue=n,this._valueRange=n-t}getLabelForValue(e){return jt(e,this.chart.options.locale,this.options.ticks.format)}},xc=class extends bc{static id=`linear`;static defaults={ticks:{callback:Pt.formatters.numeric}};determineDataLimits(){let{min:e,max:t}=this.getMinMax(!0);this.min=M(e)?e:0,this.max=M(t)?t:1,this.handleTickRangeOptions()}computeTickLimit(){let e=this.isHorizontal(),t=e?this.width:this.height,n=V(this.options.ticks.minRotation),r=(e?Math.sin(n):Math.cos(n))||.001,i=this._resolveTickFontOptions(0);return Math.ceil(t/Math.min(40,i.lineHeight/r))}getPixelForValue(e){return e===null?NaN:this.getPixelForDecimal((e-this._startValue)/this._valueRange)}getValueForPixel(e){return this._startValue+this.getDecimalForPixel(e)*this._valueRange}},Sc=e=>Math.floor(Ve(e)),Cc=(e,t)=>10**(Sc(e)+t);function wc(e){return e/10**Sc(e)==1}function Tc(e,t,n){let r=10**n,i=Math.floor(e/r);return Math.ceil(t/r)-i}function Ec(e,t){let n=Sc(t-e);for(;Tc(e,t,n)>10;)n++;for(;Tc(e,t,n)<10;)n--;return Math.min(n,Sc(e))}function Dc(e,{min:t,max:n}){t=N(e.min,t);let r=[],i=Sc(t),a=Ec(t,n),o=a<0?10**Math.abs(a):1,s=10**a,c=i>a?10**i:0,l=Math.round((t-c)*o)/o,u=Math.floor((t-c)/s/10)*s*10,d=Math.floor((l-u)/10**a),f=N(e.min,Math.round((c+u+d*10**a)*o)/o);for(;f=10?d=d<15?15:20:d++,d>=20&&(a++,d=2,o=a>=0?1:o),f=Math.round((c+u+d*10**a)*o)/o;let p=N(e.max,f);return r.push({value:p,major:wc(p),significand:d}),r}(class extends Ia{static id=`logarithmic`;static defaults={ticks:{callback:Pt.formatters.logarithmic,major:{enabled:!0}}};constructor(e){super(e),this.start=void 0,this.end=void 0,this._startValue=void 0,this._valueRange=0}parse(e,t){let n=bc.prototype.parse.apply(this,[e,t]);if(n===0){this._zero=!0;return}return M(n)&&n>0?n:null}determineDataLimits(){let{min:e,max:t}=this.getMinMax(!0);this.min=M(e)?Math.max(0,e):null,this.max=M(t)?Math.max(0,t):null,this.options.beginAtZero&&(this._zero=!0),this._zero&&this.min!==this._suggestedMin&&!M(this._userMin)&&(this.min=e===Cc(this.min,0)?Cc(this.min,-1):Cc(this.min,0)),this.handleTickRangeOptions()}handleTickRangeOptions(){let{minDefined:e,maxDefined:t}=this.getUserBounds(),n=this.min,r=this.max,i=t=>n=e?n:t,a=e=>r=t?r:e;n===r&&(n<=0?(i(1),a(10)):(i(Cc(n,-1)),a(Cc(r,1)))),n<=0&&i(Cc(r,-1)),r<=0&&a(Cc(n,1)),this.min=n,this.max=r}buildTicks(){let e=this.options,t=Dc({min:this._userMin,max:this._userMax},this);return e.bounds===`ticks`&&Je(t,this,`value`),e.reverse?(t.reverse(),this.start=this.max,this.end=this.min):(this.start=this.min,this.end=this.max),t}getLabelForValue(e){return e===void 0?`0`:jt(e,this.chart.options.locale,this.options.ticks.format)}configure(){let e=this.min;super.configure(),this._startValue=Ve(e),this._valueRange=Ve(this.max)-Ve(e)}getPixelForValue(e){return(e===void 0||e===0)&&(e=this.min),e===null||isNaN(e)?NaN:this.getPixelForDecimal(e===this.min?0:(Ve(e)-this._startValue)/this._valueRange)}getValueForPixel(e){let t=this.getDecimalForPixel(e);return 10**(this._startValue+t*this._valueRange)}});function Oc(e){let t=e.ticks;if(t.display&&e.display){let e=q(t.backdropPadding);return P(t.font&&t.font.size,G.font.size)+e.height}return 0}function kc(e,t,n){return n=A(n)?n:[n],{w:Ht(e,t.string,n),h:n.length*t.lineHeight}}function Ac(e,t,n,r,i){return e===r||e===i?{start:t-n/2,end:t+n/2}:ei?{start:t-n,end:t}:{start:t,end:t+n}}function jc(e){let t={l:e.left+e._padding.left,r:e.right-e._padding.right,t:e.top+e._padding.top,b:e.bottom-e._padding.bottom},n=Object.assign({},t),r=[],i=[],a=e._pointLabels.length,o=e.options.pointLabels,s=o.centerPointLabels?L/a:0;for(let c=0;ct.r&&(s=(r.end-t.r)/a,e.r=Math.max(e.r,t.r+s)),i.startt.b&&(c=(i.end-t.b)/o,e.b=Math.max(e.b,t.b+c))}function Nc(e,t,n){let r=e.drawingArea,{extra:i,additionalAngle:a,padding:o,size:s}=n,c=e.getPointPosition(t,r+i+o,a),l=Math.round(Ye(H(c.angle+z))),u=Rc(c.y,s.h,l),d=Ic(l),f=Lc(c.x,s.w,d);return{visible:!0,x:c.x,y:u,textAlign:d,left:f,top:u,right:f+s.w,bottom:u+s.h}}function Pc(e,t){if(!t)return!0;let{left:n,top:r,right:i,bottom:a}=e;return!(K({x:n,y:r},t)||K({x:n,y:a},t)||K({x:i,y:r},t)||K({x:i,y:a},t))}function Fc(e,t,n){let r=[],i=e._pointLabels.length,a=e.options,{centerPointLabels:o,display:s}=a.pointLabels,c={extra:Oc(a)/2,additionalAngle:o?L/i:0},l;for(let a=0;a270||n<90)&&(e-=t),e}function zc(e,t,n){let{left:r,top:i,right:a,bottom:o}=n,{backdropColor:s}=t;if(!k(s)){let n=ln(t.borderRadius),c=q(t.backdropPadding);e.fillStyle=s;let l=r-c.left,u=i-c.top,d=a-r+c.width,f=o-i+c.height;Object.values(n).some(e=>e!==0)?(e.beginPath(),tn(e,{x:l,y:u,w:d,h:f,radius:n}),e.fill()):e.fillRect(l,u,d,f)}}function Bc(e,t){let{ctx:n,options:{pointLabels:r}}=e;for(let i=t-1;i>=0;i--){let t=e._pointLabelItems[i];if(!t.visible)continue;let a=r.setContext(e.getPointLabelContext(i));zc(n,a,t);let o=J(a.font),{x:s,y:c,textAlign:l}=t;en(n,e._pointLabels[i],s,c+o.lineHeight/2,o,{color:a.color,textAlign:l,textBaseline:`middle`})}}function Vc(e,t,n,r){let{ctx:i}=e;if(n)i.arc(e.xCenter,e.yCenter,t,0,R);else{let n=e.getPointPosition(0,t);i.moveTo(n.x,n.y);for(let a=1;a{let n=F(this.options.pointLabels.callback,[e,t],this);return n||n===0?n:``}).filter((e,t)=>this.chart.getDataVisibility(t))}fit(){let e=this.options;e.display&&e.pointLabels.display?jc(this):this.setCenterPoint(0,0,0,0)}setCenterPoint(e,t,n,r){this.xCenter+=Math.floor((e-t)/2),this.yCenter+=Math.floor((n-r)/2),this.drawingArea-=Math.min(this.drawingArea/2,Math.max(e,t,n,r))}getIndexAngle(e){let t=R/(this._pointLabels.length||1),n=this.options.startAngle||0;return H(e*t+V(n))}getDistanceFromCenterForValue(e){if(k(e))return NaN;let t=this.drawingArea/(this.max-this.min);return this.options.reverse?(this.max-e)*t:(e-this.min)*t}getValueForDistanceFromCenter(e){if(k(e))return NaN;let t=e/(this.drawingArea/(this.max-this.min));return this.options.reverse?this.max-t:this.min+t}getPointLabelContext(e){let t=this._pointLabels||[];if(e>=0&&e{if(t!==0||t===0&&this.min<0){s=this.getDistanceFromCenterForValue(e.value);let n=this.getContext(t),o=r.setContext(n),c=i.setContext(n);Hc(this,o,s,a,c)}}),n.display){for(e.save(),o=a-1;o>=0;o--){let r=n.setContext(this.getPointLabelContext(o)),{color:i,lineWidth:a}=r;!a||!i||(e.lineWidth=a,e.strokeStyle=i,e.setLineDash(r.borderDash),e.lineDashOffset=r.borderDashOffset,s=this.getDistanceFromCenterForValue(t.reverse?this.min:this.max),c=this.getPointPosition(o,s),e.beginPath(),e.moveTo(this.xCenter,this.yCenter),e.lineTo(c.x,c.y),e.stroke())}e.restore()}}drawBorder(){}drawLabels(){let e=this.ctx,t=this.options,n=t.ticks;if(!n.display)return;let r=this.getIndexAngle(0),i,a;e.save(),e.translate(this.xCenter,this.yCenter),e.rotate(r),e.textAlign=`center`,e.textBaseline=`middle`,this.ticks.forEach((r,o)=>{if(o===0&&this.min>=0&&!t.reverse)return;let s=n.setContext(this.getContext(o)),c=J(s.font);if(i=this.getDistanceFromCenterForValue(this.ticks[o].value),s.showLabelBackdrop){e.font=c.string,a=e.measureText(r.label).width,e.fillStyle=s.backdropColor;let t=q(s.backdropPadding);e.fillRect(-a/2-t.left,-i-c.size/2-t.top,a+t.width,c.size+t.height)}en(e,r.label,0,-i,c,{color:s.color,strokeColor:s.textStrokeColor,strokeWidth:s.textStrokeWidth})}),e.restore()}drawTitle(){}});var Wc={millisecond:{common:!0,size:1,steps:1e3},second:{common:!0,size:1e3,steps:60},minute:{common:!0,size:6e4,steps:60},hour:{common:!0,size:36e5,steps:24},day:{common:!0,size:864e5,steps:30},week:{common:!1,size:6048e5,steps:4},month:{common:!0,size:2628e6,steps:12},quarter:{common:!1,size:7884e6,steps:4},year:{common:!0,size:3154e7}},$=Object.keys(Wc);function Gc(e,t){return e-t}function Kc(e,t){if(k(t))return null;let n=e._adapter,{parser:r,round:i,isoWeekday:a}=e._parseOpts,o=t;return typeof r==`function`&&(o=r(o)),M(o)||(o=typeof r==`string`?n.parse(o,r):n.parse(o)),o===null?null:(i&&(o=i===`week`&&(Ke(a)||a===!0)?n.startOf(o,`isoWeek`,a):n.startOf(o,i)),+o)}function qc(e,t,n,r){let i=$.length;for(let a=$.indexOf(e);a=$.indexOf(n);a--){let n=$[a];if(Wc[n].common&&e._adapter.diff(i,r,n)>=t-1)return n}return $[n?$.indexOf(n):0]}function Yc(e){for(let t=$.indexOf(e)+1,n=$.length;t=t?n[r]:n[i];e[a]=!0}}function Zc(e,t,n,r){let i=e._adapter,a=+i.startOf(t[0].value,r),o=t[t.length-1].value,s,c;for(s=a;s<=o;s=+i.add(s,1,r))c=n[s],c>=0&&(t[c].major=!0);return t}function Qc(e,t,n){let r=[],i={},a=t.length,o,s;for(o=0;o+e.value))}initOffsets(e=[]){let t=0,n=0,r,i;this.options.offset&&e.length&&(r=this.getDecimalForValue(e[0]),t=e.length===1?1-r:(this.getDecimalForValue(e[1])-r)/2,i=this.getDecimalForValue(e[e.length-1]),n=e.length===1?i:(i-this.getDecimalForValue(e[e.length-2]))/2);let a=e.length<3?.5:.25;t=U(t,0,a),n=U(n,0,a),this._offsets={start:t,end:n,factor:1/(t+1+n)}}_generate(){let e=this._adapter,t=this.min,n=this.max,r=this.options,i=r.time,a=i.unit||qc(i.minUnit,t,n,this._getLabelCapacity(t)),o=P(r.ticks.stepSize,1),s=a===`week`?i.isoWeekday:!1,c=Ke(s)||s===!0,l={},u=t,d,f;if(c&&(u=+e.startOf(u,`isoWeek`,s)),u=+e.startOf(u,c?`day`:a),e.diff(n,t,a)>1e5*o)throw Error(t+` and `+n+` are too far apart with stepSize of `+o+` `+a);let p=r.ticks.source===`data`&&this.getDataTimestamps();for(d=u,f=0;d+e)}getLabelForValue(e){let t=this._adapter,n=this.options.time;return n.tooltipFormat?t.format(e,n.tooltipFormat):t.format(e,n.displayFormats.datetime)}format(e,t){let n=this.options.time.displayFormats,r=this._unit,i=t||n[r];return this._adapter.format(e,i)}_tickFormatFunction(e,t,n,r){let i=this.options,a=i.ticks.callback;if(a)return F(a,[e,t,n],this);let o=i.time.displayFormats,s=this._unit,c=this._majorUnit,l=s&&o[s],u=c&&o[c],d=n[t],f=c&&u&&d&&d.major;return this._adapter.format(e,r||(f?u:l))}generateTickLabels(e){let t,n,r;for(t=0,n=e.length;t0?o:1}getDataTimestamps(){let e=this._cache.data||[],t,n;if(e.length)return e;let r=this.getMatchingVisibleMetas();if(this._normalized&&r.length)return this._cache.data=r[0].controller.getAllParsedValues(this);for(t=0,n=r.length;t=e[r].pos&&t<=e[i].pos&&({lo:r,hi:i}=it(e,`pos`,t)),{pos:a,time:s}=e[r],{pos:o,time:c}=e[i]):(t>=e[r].time&&t<=e[i].time&&({lo:r,hi:i}=it(e,`time`,t)),{time:a,pos:s}=e[r],{time:o,pos:c}=e[i]);let l=o-a;return l?s+(c-s)*(t-a)/l:s}(class extends $c{static id=`timeseries`;static defaults=$c.defaults;constructor(e){super(e),this._table=[],this._minPos=void 0,this._tableRange=void 0}initOffsets(){let e=this._getTimestampsForTable(),t=this._table=this.buildLookupTable(e);this._minPos=el(t,this.min),this._tableRange=el(t,this.max)-this._minPos,super.initOffsets(e)}buildLookupTable(e){let{min:t,max:n}=this,r=[],i=[],a,o,s,c,l;for(a=0,o=e.length;a=t&&c<=n&&r.push(c);if(r.length<2)return[{time:t,pos:0},{time:n,pos:1}];for(a=0,o=r.length;ae-t)}_getTimestampsForTable(){let e=this._cache.all||[];if(e.length)return e;let t=this.getDataTimestamps(),n=this.getLabelTimestamps();return e=t.length&&n.length?this.normalize(t.concat(n)):t.length?t:n,e=this._cache.all=e,e}getDecimalForValue(e){return(el(this._table,e)-this._minPos)/this._tableRange}getValueForPixel(e){let t=this._offsets,n=this.getDecimalForPixel(e)/t.factor-t.end;return el(this._table,n*this._tableRange+this._minPos,!0)}});export{fc as _,wo as a,qo as c,yi as d,$c as f,Js as g,Gs as h,_c as i,xc as l,Is as m,hi as n,_i as o,xi as p,as as r,vi as s,Po as t,Yo as u}; \ No newline at end of file diff --git a/repeater/web/html/assets/chartjs-adapter-date-fns-BqJ94ASW.css b/repeater/web/html/assets/chartjs-adapter-date-fns-BqJ94ASW.css new file mode 100644 index 0000000..b8a6947 --- /dev/null +++ b/repeater/web/html/assets/chartjs-adapter-date-fns-BqJ94ASW.css @@ -0,0 +1 @@ +.sparkline-card[data-v-dfc36682]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px);background:#ffffffbf;border:1px solid #0000000f;border-radius:12px;padding:12px 14px;transition:background .3s,border-color .3s,box-shadow .3s;overflow:hidden;box-shadow:0 4px 16px #0000000a,0 1px 3px #00000005}.dark .sparkline-card[data-v-dfc36682]{background:#0006;border:1px solid #ffffff0d;box-shadow:0 4px 16px #0003}.card-header[data-v-dfc36682]{justify-content:space-between;align-items:baseline;margin-bottom:8px;display:flex}.card-title[data-v-dfc36682]{color:#4b5563b3;text-transform:uppercase;letter-spacing:.05em;font-size:11px;font-weight:500;transition:color .3s}.dark .card-title[data-v-dfc36682]{color:#fff9}.card-subtitle[data-v-dfc36682]{color:#4b556380;margin-top:2px;font-size:9px;font-weight:400;transition:color .3s}.dark .card-subtitle[data-v-dfc36682]{color:#fff6}.card-value[data-v-dfc36682]{font-variant-numeric:tabular-nums;font-size:22px;font-weight:700;line-height:1}.card-chart[data-v-dfc36682]{width:100%;height:28px;overflow:hidden}.chart-svg[data-v-dfc36682]{width:100%;height:100%}.chart-loader[data-v-dfc36682]{justify-content:center;align-items:center;height:100%;display:flex}.loader-spinner[data-v-dfc36682]{border:2px solid #fff3;border-radius:50%;width:18px;height:18px;animation:1s linear infinite spin-dfc36682}.chart-text[data-v-dfc36682]{justify-content:center;align-items:center;height:100%;display:flex}.percent-value[data-v-dfc36682]{color:#ffffff80;font-variant-numeric:tabular-nums;font-size:20px;font-weight:500}.sparkline-path[data-v-dfc36682]{transition:d 1s ease-out}@keyframes spin-dfc36682{to{transform:rotate(360deg)}}@media (width>=1024px){.sparkline-card[data-v-dfc36682]{padding:14px 16px}.card-header[data-v-dfc36682]{margin-bottom:10px}.card-title[data-v-dfc36682]{font-size:12px}.card-value[data-v-dfc36682]{font-size:26px}.card-chart[data-v-dfc36682]{height:32px}.percent-value[data-v-dfc36682]{font-size:24px}} diff --git a/repeater/web/html/assets/chartjs-adapter-date-fns.esm-DnBoPdP1.js b/repeater/web/html/assets/chartjs-adapter-date-fns.esm-DnBoPdP1.js new file mode 100644 index 0000000..a9c2e1d --- /dev/null +++ b/repeater/web/html/assets/chartjs-adapter-date-fns.esm-DnBoPdP1.js @@ -0,0 +1 @@ +import{ft as e,g as t,l as n,o as r,pt as i,r as a,s as o,u as s,w as c}from"./runtime-core.esm-bundler-HnidnMFy.js";import{t as l}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{p as u}from"./chart-B1uYMRrx.js";var ee={class:`sparkline-card`},d={class:`card-header`},te={class:`card-title`},f={key:0,class:`card-subtitle`},p={key:0,class:`card-chart`},ne={key:0,class:`chart-loader`},re={key:1,class:`chart-text`},ie={class:`percent-value`},ae=[`id`,`viewBox`],oe=[`d`,`fill`],se=[`d`,`stroke`],m=100,h=40,g=l(t({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(t){let l=t,u=e=>{if(e.length<3)return e;let t=Math.min(15,Math.max(3,Math.floor(e.length*.2))),n=[];for(let r=0;re+t,0)/s.length)}let r=Math.min(10,n.length),i=n.length/r,a=[];for(let e=0;e!l.data||l.data.length===0?[]:l.variant===`smooth`?u(l.data):l.data),ce=e=>{if(e.length<2)return``;let t=Math.max(...e),n=Math.min(...e),r=t-n||1,i=l.variant===`classic`?4:2,a=``;return e.forEach((t,o)=>{let s=o/(e.length-1)*m,c=(t-n)/r,l=i+(h-i*2)*(1-c);if(o===0)a+=`M ${s.toFixed(2)} ${l.toFixed(2)}`;else{let t=((o-1)/(e.length-1)*m+s)/2;a+=` Q ${t.toFixed(2)} ${l.toFixed(2)} ${s.toFixed(2)} ${l.toFixed(2)}`}}),a},_=r(()=>ce(g.value)),le=r(()=>_.value?`${_.value} L ${m} ${h} L 0 ${h} Z`:``),v=r(()=>`sparkline-${l.title.replace(/\s+/g,`-`).toLowerCase()}`);return(r,l)=>(c(),s(`div`,ee,[o(`div`,d,[o(`div`,null,[o(`p`,te,i(t.title),1),t.subtitle?(c(),s(`p`,f,i(t.subtitle),1)):n(``,!0)]),o(`span`,{class:`card-value`,style:e({color:t.color})},i(typeof t.value==`number`?t.value.toLocaleString():t.value),5)]),t.showChart?(c(),s(`div`,p,[t.loading&&t.variant===`classic`?(c(),s(`div`,ne,[o(`div`,{class:`loader-spinner`,style:e({borderTopColor:t.color})},null,4)])):t.centerText?(c(),s(`div`,re,[o(`span`,ie,i(t.centerText),1)])):(c(),s(`svg`,{key:2,id:v.value,class:`chart-svg`,viewBox:`0 0 ${m} ${h}`,preserveAspectRatio:`none`},[t.variant===`classic`?(c(),s(a,{key:0},[g.value.length>1?(c(),s(`path`,{key:0,d:le.value,fill:t.color,"fill-opacity":`0.8`,class:`sparkline-path`},null,8,oe)):n(``,!0)],64)):(c(),s(a,{key:1},[g.value.length>1?(c(),s(`path`,{key:0,d:_.value,stroke:t.color,"stroke-width":`2.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`,fill:`none`,class:`sparkline-path`},null,8,se)):n(``,!0)],64))],8,ae))])):n(``,!0)]))}}),[[`__scopeId`,`data-v-dfc36682`]]),ce=365.2425,_=6048e5,le=864e5,v=6e4,y=36e5,ue=1e3,de=3600*24;de*7,de*ce/12*3;var fe=Symbol.for(`constructDateFrom`);function b(e,t){return typeof e==`function`?e(t):e&&typeof e==`object`&&fe in e?e[fe](t):e instanceof Date?new e.constructor(t):new Date(t)}function x(e,t){return b(t||e,e)}function S(e,t,n){let r=x(e,n?.in);return isNaN(t)?b(n?.in||e,NaN):(t&&r.setDate(r.getDate()+t),r)}function C(e,t,n){let r=x(e,n?.in);if(isNaN(t))return b(n?.in||e,NaN);if(!t)return r;let i=r.getDate(),a=b(n?.in||e,r.getTime());return a.setMonth(r.getMonth()+t+1,0),i>=a.getDate()?a:(r.setFullYear(a.getFullYear(),a.getMonth(),i),r)}function w(e,t,n){return b(n?.in||e,+x(e)+t)}function pe(e,t,n){return w(e,t*y,n)}var me={};function T(){return me}function E(e,t){let n=T(),r=t?.weekStartsOn??t?.locale?.options?.weekStartsOn??n.weekStartsOn??n.locale?.options?.weekStartsOn??0,i=x(e,t?.in),a=i.getDay(),o=(a=a.getTime()?r+1:n.getTime()>=s.getTime()?r:r-1}function O(e){let t=x(e),n=new Date(Date.UTC(t.getFullYear(),t.getMonth(),t.getDate(),t.getHours(),t.getMinutes(),t.getSeconds(),t.getMilliseconds()));return n.setUTCFullYear(t.getFullYear()),e-+n}function k(e,...t){let n=b.bind(null,e||t.find(e=>typeof e==`object`));return t.map(n)}function ge(e,t){let n=x(e,t?.in);return n.setHours(0,0,0,0),n}function _e(e,t,n){let[r,i]=k(n?.in,e,t),a=ge(r),o=ge(i),s=+a-O(a),c=+o-O(o);return Math.round((s-c)/le)}function ve(e,t){let n=he(e,t),r=b(t?.in||e,0);return r.setFullYear(n,0,4),r.setHours(0,0,0,0),D(r)}function ye(e,t,n){let r=x(e,n?.in);return r.setTime(r.getTime()+t*v),r}function be(e,t,n){return C(e,t*3,n)}function xe(e,t,n){return w(e,t*1e3,n)}function Se(e,t,n){return S(e,t*7,n)}function Ce(e,t,n){return C(e,t*12,n)}function A(e,t){let n=x(e)-+x(t);return n<0?-1:n>0?1:n}function we(e){return e instanceof Date||typeof e==`object`&&Object.prototype.toString.call(e)===`[object Date]`}function Te(e){return!(!we(e)&&typeof e!=`number`||isNaN(+x(e)))}function Ee(e,t,n){let[r,i]=k(n?.in,e,t),a=r.getFullYear()-i.getFullYear(),o=r.getMonth()-i.getMonth();return a*12+o}function De(e,t,n){let[r,i]=k(n?.in,e,t);return r.getFullYear()-i.getFullYear()}function Oe(e,t,n){let[r,i]=k(n?.in,e,t),a=ke(r,i),o=Math.abs(_e(r,i));r.setDate(r.getDate()-a*o);let s=a*(o-Number(ke(r,i)===-a));return s===0?0:s}function ke(e,t){let n=e.getFullYear()-t.getFullYear()||e.getMonth()-t.getMonth()||e.getDate()-t.getDate()||e.getHours()-t.getHours()||e.getMinutes()-t.getMinutes()||e.getSeconds()-t.getSeconds()||e.getMilliseconds()-t.getMilliseconds();return n<0?-1:n>0?1:n}function j(e){return t=>{let n=(e?Math[e]:Math.trunc)(t);return n===0?0:n}}function Ae(e,t,n){let[r,i]=k(n?.in,e,t),a=(r-+i)/y;return j(n?.roundingMethod)(a)}function M(e,t){return x(e)-+x(t)}function je(e,t,n){let r=M(e,t)/v;return j(n?.roundingMethod)(r)}function Me(e,t){let n=x(e,t?.in);return n.setHours(23,59,59,999),n}function Ne(e,t){let n=x(e,t?.in),r=n.getMonth();return n.setFullYear(n.getFullYear(),r+1,0),n.setHours(23,59,59,999),n}function Pe(e,t){let n=x(e,t?.in);return+Me(n,t)==+Ne(n,t)}function Fe(e,t,n){let[r,i,a]=k(n?.in,e,e,t),o=A(i,a),s=Math.abs(Ee(i,a));if(s<1)return 0;i.getMonth()===1&&i.getDate()>27&&i.setDate(30),i.setMonth(i.getMonth()-o*s);let c=A(i,a)===-o;Pe(r)&&s===1&&A(r,a)===1&&(c=!1);let l=o*(s-+c);return l===0?0:l}function Ie(e,t,n){let r=Fe(e,t,n)/3;return j(n?.roundingMethod)(r)}function Le(e,t,n){let r=M(e,t)/1e3;return j(n?.roundingMethod)(r)}function Re(e,t,n){let r=Oe(e,t,n)/7;return j(n?.roundingMethod)(r)}function ze(e,t,n){let[r,i]=k(n?.in,e,t),a=A(r,i),o=Math.abs(De(r,i));r.setFullYear(1584),i.setFullYear(1584);let s=a*(o-+(A(r,i)===-a));return s===0?0:s}function Be(e,t){let n=x(e,t?.in),r=n.getMonth(),i=r-r%3;return n.setMonth(i,1),n.setHours(0,0,0,0),n}function Ve(e,t){let n=x(e,t?.in);return n.setDate(1),n.setHours(0,0,0,0),n}function He(e,t){let n=x(e,t?.in),r=n.getFullYear();return n.setFullYear(r+1,0,0),n.setHours(23,59,59,999),n}function Ue(e,t){let n=x(e,t?.in);return n.setFullYear(n.getFullYear(),0,1),n.setHours(0,0,0,0),n}function We(e,t){let n=x(e,t?.in);return n.setMinutes(59,59,999),n}function Ge(e,t){let n=T(),r=t?.weekStartsOn??t?.locale?.options?.weekStartsOn??n.weekStartsOn??n.locale?.options?.weekStartsOn??0,i=x(e,t?.in),a=i.getDay(),o=(a{let r,i=Ye[e];return r=typeof i==`string`?i:t===1?i.one:i.other.replace(`{{count}}`,t.toString()),n?.addSuffix?n.comparison&&n.comparison>0?`in `+r:r+` ago`:r};function N(e){return(t={})=>{let n=t.width?String(t.width):e.defaultWidth;return e.formats[n]||e.formats[e.defaultWidth]}}var Ze={date:N({formats:{full:`EEEE, MMMM do, y`,long:`MMMM do, y`,medium:`MMM d, y`,short:`MM/dd/yyyy`},defaultWidth:`full`}),time:N({formats:{full:`h:mm:ss a zzzz`,long:`h:mm:ss a z`,medium:`h:mm:ss a`,short:`h:mm a`},defaultWidth:`full`}),dateTime:N({formats:{full:`{{date}} 'at' {{time}}`,long:`{{date}} 'at' {{time}}`,medium:`{{date}}, {{time}}`,short:`{{date}}, {{time}}`},defaultWidth:`full`})},Qe={lastWeek:`'last' eeee 'at' p`,yesterday:`'yesterday at' p`,today:`'today at' p`,tomorrow:`'tomorrow at' p`,nextWeek:`eeee 'at' p`,other:`P`},$e=(e,t,n,r)=>Qe[e];function P(e){return(t,n)=>{let r=n?.context?String(n.context):`standalone`,i;if(r===`formatting`&&e.formattingValues){let t=e.defaultFormattingWidth||e.defaultWidth,r=n?.width?String(n.width):t;i=e.formattingValues[r]||e.formattingValues[t]}else{let t=e.defaultWidth,r=n?.width?String(n.width):e.defaultWidth;i=e.values[r]||e.values[t]}let a=e.argumentCallback?e.argumentCallback(t):t;return i[a]}}var et={ordinalNumber:(e,t)=>{let n=Number(e),r=n%100;if(r>20||r<10)switch(r%10){case 1:return n+`st`;case 2:return n+`nd`;case 3:return n+`rd`}return n+`th`},era:P({values:{narrow:[`B`,`A`],abbreviated:[`BC`,`AD`],wide:[`Before Christ`,`Anno Domini`]},defaultWidth:`wide`}),quarter:P({values:{narrow:[`1`,`2`,`3`,`4`],abbreviated:[`Q1`,`Q2`,`Q3`,`Q4`],wide:[`1st quarter`,`2nd quarter`,`3rd quarter`,`4th quarter`]},defaultWidth:`wide`,argumentCallback:e=>e-1}),month:P({values:{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`]},defaultWidth:`wide`}),day:P({values:{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`]},defaultWidth:`wide`}),dayPeriod:P({values:{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`}},defaultWidth:`wide`,formattingValues:{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`}},defaultFormattingWidth:`wide`})};function F(e){return(t,n={})=>{let r=n.width,i=r&&e.matchPatterns[r]||e.matchPatterns[e.defaultMatchWidth],a=t.match(i);if(!a)return null;let o=a[0],s=r&&e.parsePatterns[r]||e.parsePatterns[e.defaultParseWidth],c=Array.isArray(s)?nt(s,e=>e.test(o)):tt(s,e=>e.test(o)),l;l=e.valueCallback?e.valueCallback(c):c,l=n.valueCallback?n.valueCallback(l):l;let u=t.slice(o.length);return{value:l,rest:u}}}function tt(e,t){for(let n in e)if(Object.prototype.hasOwnProperty.call(e,n)&&t(e[n]))return n}function nt(e,t){for(let n=0;n{let r=t.match(e.matchPattern);if(!r)return null;let i=r[0],a=t.match(e.parsePattern);if(!a)return null;let o=e.valueCallback?e.valueCallback(a[0]):a[0];o=n.valueCallback?n.valueCallback(o):o;let s=t.slice(i.length);return{value:o,rest:s}}}var it={code:`en-US`,formatDistance:Xe,formatLong:Ze,formatRelative:$e,localize:et,match:{ordinalNumber:rt({matchPattern:/^(\d+)(th|st|nd|rd)?/i,parsePattern:/\d+/i,valueCallback:e=>parseInt(e,10)}),era:F({matchPatterns:{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},defaultMatchWidth:`wide`,parsePatterns:{any:[/^b/i,/^(a|c)/i]},defaultParseWidth:`any`}),quarter:F({matchPatterns:{narrow:/^[1234]/i,abbreviated:/^q[1234]/i,wide:/^[1234](th|st|nd|rd)? quarter/i},defaultMatchWidth:`wide`,parsePatterns:{any:[/1/i,/2/i,/3/i,/4/i]},defaultParseWidth:`any`,valueCallback:e=>e+1}),month:F({matchPatterns:{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},defaultMatchWidth:`wide`,parsePatterns:{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]},defaultParseWidth:`any`}),day:F({matchPatterns:{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},defaultMatchWidth:`wide`,parsePatterns:{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]},defaultParseWidth:`any`}),dayPeriod:F({matchPatterns:{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},defaultMatchWidth:`any`,parsePatterns:{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}},defaultParseWidth:`any`})},options:{weekStartsOn:0,firstWeekContainsDate:1}};function at(e,t){let n=x(e,t?.in);return _e(n,Ue(n))+1}function ot(e,t){let n=x(e,t?.in),r=D(n)-+ve(n);return Math.round(r/_)+1}function I(e,t){let n=x(e,t?.in),r=n.getFullYear(),i=T(),a=t?.firstWeekContainsDate??t?.locale?.options?.firstWeekContainsDate??i.firstWeekContainsDate??i.locale?.options?.firstWeekContainsDate??1,o=b(t?.in||e,0);o.setFullYear(r+1,0,a),o.setHours(0,0,0,0);let s=E(o,t),c=b(t?.in||e,0);c.setFullYear(r,0,a),c.setHours(0,0,0,0);let l=E(c,t);return+n>=+s?r+1:+n>=+l?r:r-1}function st(e,t){let n=T(),r=t?.firstWeekContainsDate??t?.locale?.options?.firstWeekContainsDate??n.firstWeekContainsDate??n.locale?.options?.firstWeekContainsDate??1,i=I(e,t),a=b(t?.in||e,0);return a.setFullYear(i,0,r),a.setHours(0,0,0,0),E(a,t)}function ct(e,t){let n=x(e,t?.in),r=E(n,t)-+st(n,t);return Math.round(r/_)+1}function L(e,t){return(e<0?`-`:``)+Math.abs(e).toString().padStart(t,`0`)}var R={y(e,t){let n=e.getFullYear(),r=n>0?n:1-n;return L(t===`yy`?r%100:r,t.length)},M(e,t){let n=e.getMonth();return t===`M`?String(n+1):L(n+1,2)},d(e,t){return L(e.getDate(),t.length)},a(e,t){let n=e.getHours()/12>=1?`pm`:`am`;switch(t){case`a`:case`aa`:return n.toUpperCase();case`aaa`:return n;case`aaaaa`:return n[0];default:return n===`am`?`a.m.`:`p.m.`}},h(e,t){return L(e.getHours()%12||12,t.length)},H(e,t){return L(e.getHours(),t.length)},m(e,t){return L(e.getMinutes(),t.length)},s(e,t){return L(e.getSeconds(),t.length)},S(e,t){let n=t.length,r=e.getMilliseconds();return L(Math.trunc(r*10**(n-3)),t.length)}},z={am:`am`,pm:`pm`,midnight:`midnight`,noon:`noon`,morning:`morning`,afternoon:`afternoon`,evening:`evening`,night:`night`},lt={G:function(e,t,n){let r=e.getFullYear()>0?1:0;switch(t){case`G`:case`GG`:case`GGG`:return n.era(r,{width:`abbreviated`});case`GGGGG`:return n.era(r,{width:`narrow`});default:return n.era(r,{width:`wide`})}},y:function(e,t,n){if(t===`yo`){let t=e.getFullYear(),r=t>0?t:1-t;return n.ordinalNumber(r,{unit:`year`})}return R.y(e,t)},Y:function(e,t,n,r){let i=I(e,r),a=i>0?i:1-i;return t===`YY`?L(a%100,2):t===`Yo`?n.ordinalNumber(a,{unit:`year`}):L(a,t.length)},R:function(e,t){return L(he(e),t.length)},u:function(e,t){return L(e.getFullYear(),t.length)},Q:function(e,t,n){let r=Math.ceil((e.getMonth()+1)/3);switch(t){case`Q`:return String(r);case`QQ`:return L(r,2);case`Qo`:return n.ordinalNumber(r,{unit:`quarter`});case`QQQ`:return n.quarter(r,{width:`abbreviated`,context:`formatting`});case`QQQQQ`:return n.quarter(r,{width:`narrow`,context:`formatting`});default:return n.quarter(r,{width:`wide`,context:`formatting`})}},q:function(e,t,n){let r=Math.ceil((e.getMonth()+1)/3);switch(t){case`q`:return String(r);case`qq`:return L(r,2);case`qo`:return n.ordinalNumber(r,{unit:`quarter`});case`qqq`:return n.quarter(r,{width:`abbreviated`,context:`standalone`});case`qqqqq`:return n.quarter(r,{width:`narrow`,context:`standalone`});default:return n.quarter(r,{width:`wide`,context:`standalone`})}},M:function(e,t,n){let r=e.getMonth();switch(t){case`M`:case`MM`:return R.M(e,t);case`Mo`:return n.ordinalNumber(r+1,{unit:`month`});case`MMM`:return n.month(r,{width:`abbreviated`,context:`formatting`});case`MMMMM`:return n.month(r,{width:`narrow`,context:`formatting`});default:return n.month(r,{width:`wide`,context:`formatting`})}},L:function(e,t,n){let r=e.getMonth();switch(t){case`L`:return String(r+1);case`LL`:return L(r+1,2);case`Lo`:return n.ordinalNumber(r+1,{unit:`month`});case`LLL`:return n.month(r,{width:`abbreviated`,context:`standalone`});case`LLLLL`:return n.month(r,{width:`narrow`,context:`standalone`});default:return n.month(r,{width:`wide`,context:`standalone`})}},w:function(e,t,n,r){let i=ct(e,r);return t===`wo`?n.ordinalNumber(i,{unit:`week`}):L(i,t.length)},I:function(e,t,n){let r=ot(e);return t===`Io`?n.ordinalNumber(r,{unit:`week`}):L(r,t.length)},d:function(e,t,n){return t===`do`?n.ordinalNumber(e.getDate(),{unit:`date`}):R.d(e,t)},D:function(e,t,n){let r=at(e);return t===`Do`?n.ordinalNumber(r,{unit:`dayOfYear`}):L(r,t.length)},E:function(e,t,n){let r=e.getDay();switch(t){case`E`:case`EE`:case`EEE`:return n.day(r,{width:`abbreviated`,context:`formatting`});case`EEEEE`:return n.day(r,{width:`narrow`,context:`formatting`});case`EEEEEE`:return n.day(r,{width:`short`,context:`formatting`});default:return n.day(r,{width:`wide`,context:`formatting`})}},e:function(e,t,n,r){let i=e.getDay(),a=(i-r.weekStartsOn+8)%7||7;switch(t){case`e`:return String(a);case`ee`:return L(a,2);case`eo`:return n.ordinalNumber(a,{unit:`day`});case`eee`:return n.day(i,{width:`abbreviated`,context:`formatting`});case`eeeee`:return n.day(i,{width:`narrow`,context:`formatting`});case`eeeeee`:return n.day(i,{width:`short`,context:`formatting`});default:return n.day(i,{width:`wide`,context:`formatting`})}},c:function(e,t,n,r){let i=e.getDay(),a=(i-r.weekStartsOn+8)%7||7;switch(t){case`c`:return String(a);case`cc`:return L(a,t.length);case`co`:return n.ordinalNumber(a,{unit:`day`});case`ccc`:return n.day(i,{width:`abbreviated`,context:`standalone`});case`ccccc`:return n.day(i,{width:`narrow`,context:`standalone`});case`cccccc`:return n.day(i,{width:`short`,context:`standalone`});default:return n.day(i,{width:`wide`,context:`standalone`})}},i:function(e,t,n){let r=e.getDay(),i=r===0?7:r;switch(t){case`i`:return String(i);case`ii`:return L(i,t.length);case`io`:return n.ordinalNumber(i,{unit:`day`});case`iii`:return n.day(r,{width:`abbreviated`,context:`formatting`});case`iiiii`:return n.day(r,{width:`narrow`,context:`formatting`});case`iiiiii`:return n.day(r,{width:`short`,context:`formatting`});default:return n.day(r,{width:`wide`,context:`formatting`})}},a:function(e,t,n){let r=e.getHours()/12>=1?`pm`:`am`;switch(t){case`a`:case`aa`:return n.dayPeriod(r,{width:`abbreviated`,context:`formatting`});case`aaa`:return n.dayPeriod(r,{width:`abbreviated`,context:`formatting`}).toLowerCase();case`aaaaa`:return n.dayPeriod(r,{width:`narrow`,context:`formatting`});default:return n.dayPeriod(r,{width:`wide`,context:`formatting`})}},b:function(e,t,n){let r=e.getHours(),i;switch(i=r===12?z.noon:r===0?z.midnight:r/12>=1?`pm`:`am`,t){case`b`:case`bb`:return n.dayPeriod(i,{width:`abbreviated`,context:`formatting`});case`bbb`:return n.dayPeriod(i,{width:`abbreviated`,context:`formatting`}).toLowerCase();case`bbbbb`:return n.dayPeriod(i,{width:`narrow`,context:`formatting`});default:return n.dayPeriod(i,{width:`wide`,context:`formatting`})}},B:function(e,t,n){let r=e.getHours(),i;switch(i=r>=17?z.evening:r>=12?z.afternoon:r>=4?z.morning:z.night,t){case`B`:case`BB`:case`BBB`:return n.dayPeriod(i,{width:`abbreviated`,context:`formatting`});case`BBBBB`:return n.dayPeriod(i,{width:`narrow`,context:`formatting`});default:return n.dayPeriod(i,{width:`wide`,context:`formatting`})}},h:function(e,t,n){if(t===`ho`){let t=e.getHours()%12;return t===0&&(t=12),n.ordinalNumber(t,{unit:`hour`})}return R.h(e,t)},H:function(e,t,n){return t===`Ho`?n.ordinalNumber(e.getHours(),{unit:`hour`}):R.H(e,t)},K:function(e,t,n){let r=e.getHours()%12;return t===`Ko`?n.ordinalNumber(r,{unit:`hour`}):L(r,t.length)},k:function(e,t,n){let r=e.getHours();return r===0&&(r=24),t===`ko`?n.ordinalNumber(r,{unit:`hour`}):L(r,t.length)},m:function(e,t,n){return t===`mo`?n.ordinalNumber(e.getMinutes(),{unit:`minute`}):R.m(e,t)},s:function(e,t,n){return t===`so`?n.ordinalNumber(e.getSeconds(),{unit:`second`}):R.s(e,t)},S:function(e,t){return R.S(e,t)},X:function(e,t,n){let r=e.getTimezoneOffset();if(r===0)return`Z`;switch(t){case`X`:return dt(r);case`XXXX`:case`XX`:return B(r);default:return B(r,`:`)}},x:function(e,t,n){let r=e.getTimezoneOffset();switch(t){case`x`:return dt(r);case`xxxx`:case`xx`:return B(r);default:return B(r,`:`)}},O:function(e,t,n){let r=e.getTimezoneOffset();switch(t){case`O`:case`OO`:case`OOO`:return`GMT`+ut(r,`:`);default:return`GMT`+B(r,`:`)}},z:function(e,t,n){let r=e.getTimezoneOffset();switch(t){case`z`:case`zz`:case`zzz`:return`GMT`+ut(r,`:`);default:return`GMT`+B(r,`:`)}},t:function(e,t,n){return L(Math.trunc(e/1e3),t.length)},T:function(e,t,n){return L(+e,t.length)}};function ut(e,t=``){let n=e>0?`-`:`+`,r=Math.abs(e),i=Math.trunc(r/60),a=r%60;return a===0?n+String(i):n+String(i)+t+L(a,2)}function dt(e,t){return e%60==0?(e>0?`-`:`+`)+L(Math.abs(e)/60,2):B(e,t)}function B(e,t=``){let n=e>0?`-`:`+`,r=Math.abs(e),i=L(Math.trunc(r/60),2),a=L(r%60,2);return n+i+t+a}var ft=(e,t)=>{switch(e){case`P`:return t.date({width:`short`});case`PP`:return t.date({width:`medium`});case`PPP`:return t.date({width:`long`});default:return t.date({width:`full`})}},pt=(e,t)=>{switch(e){case`p`:return t.time({width:`short`});case`pp`:return t.time({width:`medium`});case`ppp`:return t.time({width:`long`});default:return t.time({width:`full`})}},V={p:pt,P:(e,t)=>{let n=e.match(/(P+)(p+)?/)||[],r=n[1],i=n[2];if(!i)return ft(e,t);let a;switch(r){case`P`:a=t.dateTime({width:`short`});break;case`PP`:a=t.dateTime({width:`medium`});break;case`PPP`:a=t.dateTime({width:`long`});break;default:a=t.dateTime({width:`full`});break}return a.replace(`{{date}}`,ft(r,t)).replace(`{{time}}`,pt(i,t))}},mt=/^D+$/,ht=/^Y+$/,gt=[`D`,`DD`,`YY`,`YYYY`];function _t(e){return mt.test(e)}function vt(e){return ht.test(e)}function H(e,t,n){let r=yt(e,t,n);if(console.warn(r),gt.includes(e))throw RangeError(r)}function yt(e,t,n){let r=e[0]===`Y`?`years`:`days of the month`;return`Use \`${e.toLowerCase()}\` instead of \`${e}\` (in \`${t}\`) for formatting ${r} to the input \`${n}\`; see: https://github.com/date-fns/date-fns/blob/master/docs/unicodeTokens.md`}var bt=/[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g,xt=/P+p+|P+|p+|''|'(''|[^'])+('|$)|./g,St=/^'([^]*?)'?$/,Ct=/''/g,wt=/[a-zA-Z]/;function Tt(e,t,n){let r=T(),i=n?.locale??r.locale??it,a=n?.firstWeekContainsDate??n?.locale?.options?.firstWeekContainsDate??r.firstWeekContainsDate??r.locale?.options?.firstWeekContainsDate??1,o=n?.weekStartsOn??n?.locale?.options?.weekStartsOn??r.weekStartsOn??r.locale?.options?.weekStartsOn??0,s=x(e,n?.in);if(!Te(s))throw RangeError(`Invalid time value`);let c=t.match(xt).map(e=>{let t=e[0];if(t===`p`||t===`P`){let n=V[t];return n(e,i.formatLong)}return e}).join(``).match(bt).map(e=>{if(e===`''`)return{isToken:!1,value:`'`};let t=e[0];if(t===`'`)return{isToken:!1,value:Et(e)};if(lt[t])return{isToken:!0,value:e};if(t.match(wt))throw RangeError("Format string contains an unescaped latin alphabet character `"+t+"`");return{isToken:!1,value:e}});i.localize.preprocessor&&(c=i.localize.preprocessor(s,c));let l={firstWeekContainsDate:a,weekStartsOn:o,locale:i};return c.map(r=>{if(!r.isToken)return r.value;let a=r.value;(!n?.useAdditionalWeekYearTokens&&vt(a)||!n?.useAdditionalDayOfYearTokens&&_t(a))&&H(a,t,String(e));let o=lt[a[0]];return o(s,a,i.localize,l)}).join(``)}function Et(e){let t=e.match(St);return t?t[1].replace(Ct,`'`):e}function Dt(){return Object.assign({},T())}function Ot(e,t){let n=x(e,t?.in).getDay();return n===0?7:n}function kt(e,t){let n=At(t)?new t(0):b(t,0);return n.setFullYear(e.getFullYear(),e.getMonth(),e.getDate()),n.setHours(e.getHours(),e.getMinutes(),e.getSeconds(),e.getMilliseconds()),n}function At(e){return typeof e==`function`&&e.prototype?.constructor===e}var jt=10,Mt=class{subPriority=0;validate(e,t){return!0}},Nt=class extends Mt{constructor(e,t,n,r,i){super(),this.value=e,this.validateValue=t,this.setValue=n,this.priority=r,i&&(this.subPriority=i)}validate(e,t){return this.validateValue(e,this.value,t)}set(e,t,n){return this.setValue(e,t,this.value,n)}},Pt=class extends Mt{priority=jt;subPriority=-1;constructor(e,t){super(),this.context=e||(e=>b(t,e))}set(e,t){return t.timestampIsSet?e:b(e,kt(e,this.context))}},U=class{run(e,t,n,r){let i=this.parse(e,t,n,r);return i?{setter:new Nt(i.value,this.validate,this.set,this.priority,this.subPriority),rest:i.rest}:null}validate(e,t,n){return!0}},Ft=class extends U{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`});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`]},W={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}/},G={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 K(e,t){return e&&{value:t(e.value),rest:e.rest}}function q(e,t){let n=t.match(e);return n?{value:parseInt(n[0],10),rest:t.slice(n[0].length)}:null}function J(e,t){let n=t.match(e);if(!n)return null;if(n[0]===`Z`)return{value:0,rest:t.slice(1)};let r=n[1]===`+`?1:-1,i=n[2]?parseInt(n[2],10):0,a=n[3]?parseInt(n[3],10):0,o=n[5]?parseInt(n[5],10):0;return{value:r*(i*y+a*v+o*ue),rest:t.slice(n[0].length)}}function It(e){return q(W.anyDigitsSigned,e)}function Y(e,t){switch(e){case 1:return q(W.singleDigit,t);case 2:return q(W.twoDigits,t);case 3:return q(W.threeDigits,t);case 4:return q(W.fourDigits,t);default:return q(RegExp(`^\\d{1,`+e+`}`),t)}}function Lt(e,t){switch(e){case 1:return q(W.singleDigitSigned,t);case 2:return q(W.twoDigitsSigned,t);case 3:return q(W.threeDigitsSigned,t);case 4:return q(W.fourDigitsSigned,t);default:return q(RegExp(`^-?\\d{1,`+e+`}`),t)}}function X(e){switch(e){case`morning`:return 4;case`evening`:return 17;case`pm`:case`noon`:case`afternoon`:return 12;default:return 0}}function Rt(e,t){let n=t>0,r=n?t:1-t,i;if(r<=50)i=e||100;else{let t=r+50,n=Math.trunc(t/100)*100,a=e>=t%100;i=e+n-(a?100:0)}return n?i:1-i}function zt(e){return e%400==0||e%4==0&&e%100!=0}var Bt=class extends U{priority=130;incompatibleTokens=[`Y`,`R`,`u`,`w`,`I`,`i`,`e`,`c`,`t`,`T`];parse(e,t,n){let r=e=>({year:e,isTwoDigitYear:t===`yy`});switch(t){case`y`:return K(Y(4,e),r);case`yo`:return K(n.ordinalNumber(e,{unit:`year`}),r);default:return K(Y(t.length,e),r)}}validate(e,t){return t.isTwoDigitYear||t.year>0}set(e,t,n){let r=e.getFullYear();if(n.isTwoDigitYear){let t=Rt(n.year,r);return e.setFullYear(t,0,1),e.setHours(0,0,0,0),e}let i=!(`era`in t)||t.era===1?n.year:1-n.year;return e.setFullYear(i,0,1),e.setHours(0,0,0,0),e}},Vt=class extends U{priority=130;parse(e,t,n){let r=e=>({year:e,isTwoDigitYear:t===`YY`});switch(t){case`Y`:return K(Y(4,e),r);case`Yo`:return K(n.ordinalNumber(e,{unit:`year`}),r);default:return K(Y(t.length,e),r)}}validate(e,t){return t.isTwoDigitYear||t.year>0}set(e,t,n,r){let i=I(e,r);if(n.isTwoDigitYear){let t=Rt(n.year,i);return e.setFullYear(t,0,r.firstWeekContainsDate),e.setHours(0,0,0,0),E(e,r)}let a=!(`era`in t)||t.era===1?n.year:1-n.year;return e.setFullYear(a,0,r.firstWeekContainsDate),e.setHours(0,0,0,0),E(e,r)}incompatibleTokens=[`y`,`R`,`u`,`Q`,`q`,`M`,`L`,`I`,`d`,`D`,`i`,`t`,`T`]},Ht=class extends U{priority=130;parse(e,t){return Lt(t===`R`?4:t.length,e)}set(e,t,n){let r=b(e,0);return r.setFullYear(n,0,4),r.setHours(0,0,0,0),D(r)}incompatibleTokens=[`G`,`y`,`Y`,`u`,`Q`,`q`,`M`,`L`,`w`,`d`,`D`,`e`,`c`,`t`,`T`]},Ut=class extends U{priority=130;parse(e,t){return Lt(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`]},Wt=class extends U{priority=120;parse(e,t,n){switch(t){case`Q`:case`QQ`:return Y(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`});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`]},Gt=class extends U{priority=120;parse(e,t,n){switch(t){case`q`:case`qq`:return Y(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`});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`]},Kt=class extends U{incompatibleTokens=[`Y`,`R`,`q`,`Q`,`L`,`w`,`I`,`D`,`i`,`e`,`c`,`t`,`T`];priority=110;parse(e,t,n){let r=e=>e-1;switch(t){case`M`:return K(q(W.month,e),r);case`MM`:return K(Y(2,e),r);case`Mo`:return K(n.ordinalNumber(e,{unit:`month`}),r);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`});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}},qt=class extends U{priority=110;parse(e,t,n){let r=e=>e-1;switch(t){case`L`:return K(q(W.month,e),r);case`LL`:return K(Y(2,e),r);case`Lo`:return K(n.ordinalNumber(e,{unit:`month`}),r);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`});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 Jt(e,t,n){let r=x(e,n?.in),i=ct(r,n)-t;return r.setDate(r.getDate()-i*7),x(r,n?.in)}var Yt=class extends U{priority=100;parse(e,t,n){switch(t){case`w`:return q(W.week,e);case`wo`:return n.ordinalNumber(e,{unit:`week`});default:return Y(t.length,e)}}validate(e,t){return t>=1&&t<=53}set(e,t,n,r){return E(Jt(e,n,r),r)}incompatibleTokens=[`y`,`R`,`u`,`q`,`Q`,`M`,`L`,`I`,`d`,`D`,`i`,`t`,`T`]};function Xt(e,t,n){let r=x(e,n?.in),i=ot(r,n)-t;return r.setDate(r.getDate()-i*7),r}var Zt=class extends U{priority=100;parse(e,t,n){switch(t){case`I`:return q(W.week,e);case`Io`:return n.ordinalNumber(e,{unit:`week`});default:return Y(t.length,e)}}validate(e,t){return t>=1&&t<=53}set(e,t,n){return D(Xt(e,n))}incompatibleTokens=[`y`,`Y`,`u`,`q`,`Q`,`M`,`L`,`w`,`d`,`D`,`e`,`c`,`t`,`T`]},Qt=[31,28,31,30,31,30,31,31,30,31,30,31],$t=[31,29,31,30,31,30,31,31,30,31,30,31],en=class extends U{priority=90;subPriority=1;parse(e,t,n){switch(t){case`d`:return q(W.date,e);case`do`:return n.ordinalNumber(e,{unit:`date`});default:return Y(t.length,e)}}validate(e,t){let n=zt(e.getFullYear()),r=e.getMonth();return n?t>=1&&t<=$t[r]:t>=1&&t<=Qt[r]}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`]},tn=class extends U{priority=90;subpriority=1;parse(e,t,n){switch(t){case`D`:case`DD`:return q(W.dayOfYear,e);case`Do`:return n.ordinalNumber(e,{unit:`date`});default:return Y(t.length,e)}}validate(e,t){return zt(e.getFullYear())?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 nn(e,t,n){let r=T(),i=n?.weekStartsOn??n?.locale?.options?.weekStartsOn??r.weekStartsOn??r.locale?.options?.weekStartsOn??0,a=x(e,n?.in),o=a.getDay(),s=(t%7+7)%7,c=7-i;return S(a,t<0||t>6?t-(o+c)%7:(s+c)%7-(o+c)%7,n)}var rn=class extends U{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`});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,r){return e=nn(e,n,r),e.setHours(0,0,0,0),e}incompatibleTokens=[`D`,`i`,`e`,`c`,`t`,`T`]},an=class extends U{priority=90;parse(e,t,n,r){let i=e=>{let t=Math.floor((e-1)/7)*7;return(e+r.weekStartsOn+6)%7+t};switch(t){case`e`:case`ee`:return K(Y(t.length,e),i);case`eo`:return K(n.ordinalNumber(e,{unit:`day`}),i);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`});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,r){return e=nn(e,n,r),e.setHours(0,0,0,0),e}incompatibleTokens=[`y`,`R`,`u`,`q`,`Q`,`M`,`L`,`I`,`d`,`D`,`E`,`i`,`c`,`t`,`T`]},on=class extends U{priority=90;parse(e,t,n,r){let i=e=>{let t=Math.floor((e-1)/7)*7;return(e+r.weekStartsOn+6)%7+t};switch(t){case`c`:case`cc`:return K(Y(t.length,e),i);case`co`:return K(n.ordinalNumber(e,{unit:`day`}),i);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`});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,r){return e=nn(e,n,r),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(e,t,n){let r=x(e,n?.in);return S(r,t-Ot(r,n),n)}var cn=class extends U{priority=90;parse(e,t,n){let r=e=>e===0?7:e;switch(t){case`i`:case`ii`:return Y(t.length,e);case`io`:return n.ordinalNumber(e,{unit:`day`});case`iii`:return K(n.day(e,{width:`abbreviated`,context:`formatting`})||n.day(e,{width:`short`,context:`formatting`})||n.day(e,{width:`narrow`,context:`formatting`}),r);case`iiiii`:return K(n.day(e,{width:`narrow`,context:`formatting`}),r);case`iiiiii`:return K(n.day(e,{width:`short`,context:`formatting`})||n.day(e,{width:`narrow`,context:`formatting`}),r);default:return K(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`}),r)}}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`]},ln=class extends U{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`});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(X(n),0,0,0),e}incompatibleTokens=[`b`,`B`,`H`,`k`,`t`,`T`]},un=class extends U{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`});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(X(n),0,0,0),e}incompatibleTokens=[`a`,`B`,`H`,`k`,`t`,`T`]},dn=class extends U{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`});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(X(n),0,0,0),e}incompatibleTokens=[`a`,`b`,`t`,`T`]},fn=class extends U{priority=70;parse(e,t,n){switch(t){case`h`:return q(W.hour12h,e);case`ho`:return n.ordinalNumber(e,{unit:`hour`});default:return Y(t.length,e)}}validate(e,t){return t>=1&&t<=12}set(e,t,n){let r=e.getHours()>=12;return r&&n<12?e.setHours(n+12,0,0,0):!r&&n===12?e.setHours(0,0,0,0):e.setHours(n,0,0,0),e}incompatibleTokens=[`H`,`K`,`k`,`t`,`T`]},pn=class extends U{priority=70;parse(e,t,n){switch(t){case`H`:return q(W.hour23h,e);case`Ho`:return n.ordinalNumber(e,{unit:`hour`});default:return Y(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`]},mn=class extends U{priority=70;parse(e,t,n){switch(t){case`K`:return q(W.hour11h,e);case`Ko`:return n.ordinalNumber(e,{unit:`hour`});default:return Y(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`]},hn=class extends U{priority=70;parse(e,t,n){switch(t){case`k`:return q(W.hour24h,e);case`ko`:return n.ordinalNumber(e,{unit:`hour`});default:return Y(t.length,e)}}validate(e,t){return t>=1&&t<=24}set(e,t,n){let r=n<=24?n%24:n;return e.setHours(r,0,0,0),e}incompatibleTokens=[`a`,`b`,`h`,`H`,`K`,`t`,`T`]},gn=class extends U{priority=60;parse(e,t,n){switch(t){case`m`:return q(W.minute,e);case`mo`:return n.ordinalNumber(e,{unit:`minute`});default:return Y(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`]},_n=class extends U{priority=50;parse(e,t,n){switch(t){case`s`:return q(W.second,e);case`so`:return n.ordinalNumber(e,{unit:`second`});default:return Y(t.length,e)}}validate(e,t){return t>=0&&t<=59}set(e,t,n){return e.setSeconds(n,0),e}incompatibleTokens=[`t`,`T`]},vn=class extends U{priority=30;parse(e,t){return K(Y(t.length,e),e=>Math.trunc(e*10**(-t.length+3)))}set(e,t,n){return e.setMilliseconds(n),e}incompatibleTokens=[`t`,`T`]},yn=class extends U{priority=10;parse(e,t){switch(t){case`X`:return J(G.basicOptionalMinutes,e);case`XX`:return J(G.basic,e);case`XXXX`:return J(G.basicOptionalSeconds,e);case`XXXXX`:return J(G.extendedOptionalSeconds,e);default:return J(G.extended,e)}}set(e,t,n){return t.timestampIsSet?e:b(e,e.getTime()-O(e)-n)}incompatibleTokens=[`t`,`T`,`x`]},bn=class extends U{priority=10;parse(e,t){switch(t){case`x`:return J(G.basicOptionalMinutes,e);case`xx`:return J(G.basic,e);case`xxxx`:return J(G.basicOptionalSeconds,e);case`xxxxx`:return J(G.extendedOptionalSeconds,e);default:return J(G.extended,e)}}set(e,t,n){return t.timestampIsSet?e:b(e,e.getTime()-O(e)-n)}incompatibleTokens=[`t`,`T`,`X`]},xn=class extends U{priority=40;parse(e){return It(e)}set(e,t,n){return[b(e,n*1e3),{timestampIsSet:!0}]}incompatibleTokens=`*`},Sn=class extends U{priority=20;parse(e){return It(e)}set(e,t,n){return[b(e,n),{timestampIsSet:!0}]}incompatibleTokens=`*`},Cn={G:new Ft,y:new Bt,Y:new Vt,R:new Ht,u:new Ut,Q:new Wt,q:new Gt,M:new Kt,L:new qt,w:new Yt,I:new Zt,d:new en,D:new tn,E:new rn,e:new an,c:new on,i:new cn,a:new ln,b:new un,B:new dn,h:new fn,H:new pn,K:new mn,k:new hn,m:new gn,s:new _n,S:new vn,X:new yn,x:new bn,t:new xn,T:new Sn},wn=/[yYQqMLwIdDecihHKkms]o|(\w)\1*|''|'(''|[^'])+('|$)|./g,Tn=/P+p+|P+|p+|''|'(''|[^'])+('|$)|./g,En=/^'([^]*?)'?$/,Dn=/''/g,On=/\S/,kn=/[a-zA-Z]/;function An(e,t,n,r){let i=()=>b(r?.in||n,NaN),a=Dt(),o=r?.locale??a.locale??it,s=r?.firstWeekContainsDate??r?.locale?.options?.firstWeekContainsDate??a.firstWeekContainsDate??a.locale?.options?.firstWeekContainsDate??1,c=r?.weekStartsOn??r?.locale?.options?.weekStartsOn??a.weekStartsOn??a.locale?.options?.weekStartsOn??0;if(!t)return e?i():x(n,r?.in);let l={firstWeekContainsDate:s,weekStartsOn:c,locale:o},u=[new Pt(r?.in,n)],ee=t.match(Tn).map(e=>{let t=e[0];if(t in V){let n=V[t];return n(e,o.formatLong)}return e}).join(``).match(wn),d=[];for(let n of ee){!r?.useAdditionalWeekYearTokens&&vt(n)&&H(n,t,e),!r?.useAdditionalDayOfYearTokens&&_t(n)&&H(n,t,e);let a=n[0],s=Cn[a];if(s){let{incompatibleTokens:t}=s;if(Array.isArray(t)){let e=d.find(e=>t.includes(e.token)||e.token===a);if(e)throw RangeError(`The format string mustn't contain \`${e.fullToken}\` and \`${n}\` at the same time`)}else if(s.incompatibleTokens===`*`&&d.length>0)throw RangeError(`The format string mustn't contain \`${n}\` and any other token at the same time`);d.push({token:a,fullToken:n});let r=s.run(e,n,o.match,l);if(!r)return i();u.push(r.setter),e=r.rest}else{if(a.match(kn))throw RangeError("Format string contains an unescaped latin alphabet character `"+a+"`");if(n===`''`?n=`'`:a===`'`&&(n=jn(n)),e.indexOf(n)===0)e=e.slice(n.length);else return i()}}if(e.length>0&&On.test(e))return i();let te=u.map(e=>e.priority).sort((e,t)=>t-e).filter((e,t,n)=>n.indexOf(e)===t).map(e=>u.filter(t=>t.priority===e).sort((e,t)=>t.subPriority-e.subPriority)).map(e=>e[0]),f=x(n,r?.in);if(isNaN(+f))return i();let p={};for(let e of te){if(!e.validate(f,l))return i();let t=e.set(f,p,l);Array.isArray(t)?(f=t[0],Object.assign(p,t[1])):f=t}return f}function jn(e){return e.match(En)[1].replace(Dn,`'`)}function Mn(e,t){let n=x(e,t?.in);return n.setMinutes(0,0,0),n}function Nn(e,t){let n=x(e,t?.in);return n.setSeconds(0,0),n}function Pn(e,t){let n=x(e,t?.in);return n.setMilliseconds(0),n}function Fn(e,t){let n=()=>b(t?.in,NaN),r=t?.additionalDigits??2,i=zn(e),a;if(i.date){let e=Bn(i.date,r);a=Vn(e.restDateString,e.year)}if(!a||isNaN(+a))return n();let o=+a,s=0,c;if(i.time&&(s=Hn(i.time),isNaN(s)))return n();if(i.timezone){if(c=Un(i.timezone),isNaN(c))return n()}else{let e=new Date(o+s),n=x(0,t?.in);return n.setFullYear(e.getUTCFullYear(),e.getUTCMonth(),e.getUTCDate()),n.setHours(e.getUTCHours(),e.getUTCMinutes(),e.getUTCSeconds(),e.getUTCMilliseconds()),n}return x(o+s+c,t?.in)}var Z={dateTimeDelimiter:/[T ]/,timeZoneDelimiter:/[Z ]/i,timezone:/([Z+-].*)$/},In=/^-?(?:(\d{3})|(\d{2})(?:-?(\d{2}))?|W(\d{2})(?:-?(\d{1}))?|)$/,Ln=/^(\d{2}(?:[.,]\d*)?)(?::?(\d{2}(?:[.,]\d*)?))?(?::?(\d{2}(?:[.,]\d*)?))?$/,Rn=/^([+-])(\d{2})(?::?(\d{2}))?$/;function zn(e){let t={},n=e.split(Z.dateTimeDelimiter),r;if(n.length>2)return t;if(/:/.test(n[0])?r=n[0]:(t.date=n[0],r=n[1],Z.timeZoneDelimiter.test(t.date)&&(t.date=e.split(Z.timeZoneDelimiter)[0],r=e.substr(t.date.length,e.length))),r){let e=Z.timezone.exec(r);e?(t.time=r.replace(e[1],``),t.timezone=e[1]):t.time=r}return t}function Bn(e,t){let n=RegExp(`^(?:(\\d{4}|[+-]\\d{`+(4+t)+`})|(\\d{2}|[+-]\\d{`+(2+t)+`})$)`),r=e.match(n);if(!r)return{year:NaN,restDateString:``};let i=r[1]?parseInt(r[1]):null,a=r[2]?parseInt(r[2]):null;return{year:a===null?i:a*100,restDateString:e.slice((r[1]||r[2]).length)}}function Vn(e,t){if(t===null)return new Date(NaN);let n=e.match(In);if(!n)return new Date(NaN);let r=!!n[4],i=Q(n[1]),a=Q(n[2])-1,o=Q(n[3]),s=Q(n[4]),c=Q(n[5])-1;if(r)return Yn(t,s,c)?Wn(t,s,c):new Date(NaN);{let e=new Date(0);return!qn(t,a,o)||!Jn(t,i)?new Date(NaN):(e.setUTCFullYear(t,a,Math.max(i,o)),e)}}function Q(e){return e?parseInt(e):1}function Hn(e){let t=e.match(Ln);if(!t)return NaN;let n=$(t[1]),r=$(t[2]),i=$(t[3]);return Xn(n,r,i)?n*y+r*v+i*1e3:NaN}function $(e){return e&&parseFloat(e.replace(`,`,`.`))||0}function Un(e){if(e===`Z`)return 0;let t=e.match(Rn);if(!t)return 0;let n=t[1]===`+`?-1:1,r=parseInt(t[2]),i=t[3]&&parseInt(t[3])||0;return Zn(r,i)?n*(r*y+i*v):NaN}function Wn(e,t,n){let r=new Date(0);r.setUTCFullYear(e,0,4);let i=r.getUTCDay()||7,a=(t-1)*7+n+1-i;return r.setUTCDate(r.getUTCDate()+a),r}var Gn=[31,null,31,30,31,30,31,31,30,31,30,31];function Kn(e){return e%400==0||e%4==0&&e%100!=0}function qn(e,t,n){return t>=0&&t<=11&&n>=1&&n<=(Gn[t]||(Kn(e)?29:28))}function Jn(e,t){return t>=1&&t<=(Kn(e)?366:365)}function Yn(e,t,n){return t>=1&&t<=53&&n>=0&&n<=6}function Xn(e,t,n){return e===24?t===0&&n===0:n>=0&&n<60&&t>=0&&t<60&&e>=0&&e<25}function Zn(e,t){return t>=0&&t<=59}var Qn={datetime:`MMM d, yyyy, h:mm:ss aaaa`,millisecond:`h:mm:ss.SSS aaaa`,second:`h:mm:ss aaaa`,minute:`h:mm aaaa`,hour:`ha`,day:`MMM d`,week:`PP`,month:`MMM yyyy`,quarter:`qqq - yyyy`,year:`yyyy`};u._date.override({_id:`date-fns`,formats:function(){return Qn},parse:function(e,t){if(e==null)return null;let n=typeof e;return n===`number`||e instanceof Date?e=x(e):n===`string`&&(e=typeof t==`string`?An(e,t,new Date,this.options):Fn(e,this.options)),Te(e)?e.getTime():null},format:function(e,t){return Tt(e,t,this.options)},add:function(e,t,n){switch(n){case`millisecond`:return w(e,t);case`second`:return xe(e,t);case`minute`:return ye(e,t);case`hour`:return pe(e,t);case`day`:return S(e,t);case`week`:return Se(e,t);case`month`:return C(e,t);case`quarter`:return be(e,t);case`year`:return Ce(e,t);default:return e}},diff:function(e,t,n){switch(n){case`millisecond`:return M(e,t);case`second`:return Le(e,t);case`minute`:return je(e,t);case`hour`:return Ae(e,t);case`day`:return Oe(e,t);case`week`:return Re(e,t);case`month`:return Fe(e,t);case`quarter`:return Ie(e,t);case`year`:return ze(e,t);default:return 0}},startOf:function(e,t,n){switch(t){case`second`:return Pn(e);case`minute`:return Nn(e);case`hour`:return Mn(e);case`day`:return ge(e);case`week`:return E(e);case`isoWeek`:return E(e,{weekStartsOn:+n});case`month`:return Ve(e);case`quarter`:return Be(e);case`year`:return Ue(e);default:return e}},endOf:function(e,t){switch(t){case`second`:return Je(e);case`minute`:return Ke(e);case`hour`:return We(e);case`day`:return Me(e);case`week`:return Ge(e);case`month`:return Ne(e);case`quarter`:return qe(e);case`year`:return He(e);default:return e}}});export{g as t}; \ No newline at end of file diff --git a/repeater/web/html/assets/chunk-DECur_0Z.js b/repeater/web/html/assets/chunk-DECur_0Z.js new file mode 100644 index 0000000..c7f3090 --- /dev/null +++ b/repeater/web/html/assets/chunk-DECur_0Z.js @@ -0,0 +1 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));export{s as n,l as r,o as t}; \ No newline at end of file diff --git a/repeater/web/html/assets/index-BFltqMtv.js b/repeater/web/html/assets/index-BFltqMtv.js new file mode 100644 index 0000000..76965c6 --- /dev/null +++ b/repeater/web/html/assets/index-BFltqMtv.js @@ -0,0 +1 @@ +import{$ as e,A as t,C as n,D as r,E as i,G as a,J as o,K as s,O as c,Q as l,S as u,X as d,Y as f,Z as p,_ as m,a as h,at as g,b as _,c as v,ct as y,d as b,dt as x,et as S,f as C,ft as w,g as T,h as E,i as D,it as O,j as ee,k,l as A,lt as j,m as M,mt as te,n as ne,nt as N,o as P,ot as F,p as I,pt as L,q as R,r as z,rt as B,s as V,st as H,t as U,tt as W,u as G,ut as re,w as K,x as ie,z as q}from"./runtime-core.esm-bundler-HnidnMFy.js";import{a as ae,i as oe,r as se,s as ce}from"./vue-router-Cr0wB7EX.js";import{a as le,c as ue,i as de,l as fe,o as pe,r as J,t as Y,u as X}from"./api-CbM6k1ZB.js";import{t as me}from"./system-BH4r-ii6.js";import{t as he}from"./packets-C-dzvp0W.js";import{t as ge}from"./websocket-nXR7EYbj.js";import{t as Z}from"./_plugin-vue_export-helper-B7aGp3iI.js";import{t as _e}from"./useTheme-DMOVV09x.js";(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var ve=void 0,ye=typeof window<`u`&&window.trustedTypes;if(ye)try{ve=ye.createPolicy(`vue`,{createHTML:e=>e})}catch{}var be=ve?e=>ve.createHTML(e):e=>e,xe=`http://www.w3.org/2000/svg`,Se=`http://www.w3.org/1998/Math/MathML`,Q=typeof document<`u`?document:null,Ce=Q&&Q.createElement(`template`),we={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{let t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,r)=>{let i=t===`svg`?Q.createElementNS(xe,e):t===`mathml`?Q.createElementNS(Se,e):n?Q.createElement(e,{is:n}):Q.createElement(e);return e===`select`&&r&&r.multiple!=null&&i.setAttribute(`multiple`,r.multiple),i},createText:e=>Q.createTextNode(e),createComment:e=>Q.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Q.querySelector(e),setScopeId(e,t){e.setAttribute(t,``)},insertStaticContent(e,t,n,r,i,a){let o=n?n.previousSibling:t.lastChild;if(i&&(i===a||i.nextSibling))for(;t.insertBefore(i.cloneNode(!0),n),!(i===a||!(i=i.nextSibling)););else{Ce.innerHTML=be(r===`svg`?`${e}`:r===`mathml`?`${e}`:e);let i=Ce.content;if(r===`svg`||r===`mathml`){let e=i.firstChild;for(;e.firstChild;)i.appendChild(e.firstChild);i.removeChild(e)}t.insertBefore(i,n)}return[o?o.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Te=`transition`,Ee=`animation`,De=Symbol(`_vtc`),Oe={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},ke=f({},ne,Oe),Ae=(e=>(e.displayName=`Transition`,e.props=ke,e))((e,{slots:t})=>m(U,Ne(e),t)),je=(t,n=[])=>{e(t)?t.forEach(e=>e(...n)):t&&t(...n)},Me=t=>t?e(t)?t.some(e=>e.length>1):t.length>1:!1;function Ne(e){let t={};for(let n in e)n in Oe||(t[n]=e[n]);if(e.css===!1)return t;let{name:n=`v`,type:r,duration:i,enterFromClass:a=`${n}-enter-from`,enterActiveClass:o=`${n}-enter-active`,enterToClass:s=`${n}-enter-to`,appearFromClass:c=a,appearActiveClass:l=o,appearToClass:u=s,leaveFromClass:d=`${n}-leave-from`,leaveActiveClass:p=`${n}-leave-active`,leaveToClass:m=`${n}-leave-to`}=e,h=Pe(i),g=h&&h[0],_=h&&h[1],{onBeforeEnter:v,onEnter:y,onEnterCancelled:b,onLeave:x,onLeaveCancelled:S,onBeforeAppear:C=v,onAppear:w=y,onAppearCancelled:T=b}=t,E=(e,t,n,r)=>{e._enterCancelled=r,Le(e,t?u:s),Le(e,t?l:o),n&&n()},D=(e,t)=>{e._isLeaving=!1,Le(e,d),Le(e,m),Le(e,p),t&&t()},O=e=>(t,n)=>{let i=e?w:y,o=()=>E(t,e,n);je(i,[t,o]),Re(()=>{Le(t,e?c:a),Ie(t,e?u:s),Me(i)||Be(t,r,g,o)})};return f(t,{onBeforeEnter(e){je(v,[e]),Ie(e,a),Ie(e,o)},onBeforeAppear(e){je(C,[e]),Ie(e,c),Ie(e,l)},onEnter:O(!1),onAppear:O(!0),onLeave(e,t){e._isLeaving=!0;let n=()=>D(e,t);Ie(e,d),e._enterCancelled?(Ie(e,p),We(e)):(We(e),Ie(e,p)),Re(()=>{e._isLeaving&&(Le(e,d),Ie(e,m),Me(x)||Be(e,r,_,n))}),je(x,[e,n])},onEnterCancelled(e){E(e,!1,void 0,!0),je(b,[e])},onAppearCancelled(e){E(e,!0,void 0,!0),je(T,[e])},onLeaveCancelled(e){D(e),je(S,[e])}})}function Pe(e){if(e==null)return null;if(N(e))return[Fe(e.enter),Fe(e.leave)];{let t=Fe(e);return[t,t]}}function Fe(e){return te(e)}function Ie(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.add(t)),(e[De]||(e[De]=new Set)).add(t)}function Le(e,t){t.split(/\s+/).forEach(t=>t&&e.classList.remove(t));let n=e[De];n&&(n.delete(t),n.size||(e[De]=void 0))}function Re(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}var ze=0;function Be(e,t,n,r){let i=e._endId=++ze,a=()=>{i===e._endId&&r()};if(n!=null)return setTimeout(a,n);let{type:o,timeout:s,propCount:c}=Ve(e,t);if(!o)return r();let l=o+`end`,u=0,d=()=>{e.removeEventListener(l,f),a()},f=t=>{t.target===e&&++u>=c&&d()};setTimeout(()=>{u(n[e]||``).split(`, `),i=r(`${Te}Delay`),a=r(`${Te}Duration`),o=He(i,a),s=r(`${Ee}Delay`),c=r(`${Ee}Duration`),l=He(s,c),u=null,d=0,f=0;t===Te?o>0&&(u=Te,d=o,f=a.length):t===Ee?l>0&&(u=Ee,d=l,f=c.length):(d=Math.max(o,l),u=d>0?o>l?Te:Ee:null,f=u?u===Te?a.length:c.length:0);let p=u===Te&&/\b(?:transform|all)(?:,|$)/.test(r(`${Te}Property`).toString());return{type:u,timeout:d,propCount:f,hasTransform:p}}function He(e,t){for(;e.lengthUe(t)+Ue(e[n])))}function Ue(e){return e===`auto`?0:Number(e.slice(0,-1).replace(`,`,`.`))*1e3}function We(e){return(e?e.ownerDocument:document).body.offsetHeight}function Ge(e,t,n){let r=e[De];r&&(t=(t?[t,...r]:[...r]).join(` `)),t==null?e.removeAttribute(`class`):n?e.setAttribute(`class`,t):e.className=t}var Ke=Symbol(`_vod`),qe=Symbol(`_vsh`),Je={name:`show`,beforeMount(e,{value:t},{transition:n}){e[Ke]=e.style.display===`none`?``:e.style.display,n&&t?n.beforeEnter(e):Ye(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),Ye(e,!0),r.enter(e)):r.leave(e,()=>{Ye(e,!1)}):Ye(e,t))},beforeUnmount(e,{value:t}){Ye(e,t)}};function Ye(e,t){e.style.display=t?e[Ke]:`none`,e[qe]=!t}var Xe=Symbol(``),Ze=/(?:^|;)\s*display\s*:/;function Qe(e,t,n){let r=e.style,i=F(n),a=!1;if(n&&!i){if(t)if(F(t))for(let e of t.split(`;`)){let t=e.slice(0,e.indexOf(`:`)).trim();n[t]??et(r,t,``)}else for(let e in t)n[e]??et(r,e,``);for(let e in n)e===`display`&&(a=!0),et(r,e,n[e])}else if(i){if(t!==n){let e=r[Xe];e&&(n+=`;`+e),r.cssText=n,a=Ze.test(n)}}else t&&e.removeAttribute(`style`);Ke in e&&(e[Ke]=a?r.display:``,e[qe]&&(r.display=`none`))}var $e=/\s*!important$/;function et(t,n,r){if(e(r))r.forEach(e=>et(t,n,e));else if(r??=``,n.startsWith(`--`))t.setProperty(n,r);else{let e=rt(t,n);$e.test(r)?t.setProperty(d(e),r.replace($e,``),`important`):t[e]=r}}var tt=[`Webkit`,`Moz`,`ms`],nt={};function rt(e,t){let n=nt[t];if(n)return n;let r=R(t);if(r!==`filter`&&r in e)return nt[t]=r;r=o(r);for(let n=0;npt||=(mt.then(()=>pt=0),Date.now());function gt(e,t){let n=e=>{if(!e._vts)e._vts=Date.now();else if(e._vts<=n.attached)return;h(_t(e,n.value),t,5,[e])};return n.value=e,n.attached=ht(),n}function _t(t,n){if(e(n)){let e=t.stopImmediatePropagation;return t.stopImmediatePropagation=()=>{e.call(t),t._stopped=!0},n.map(e=>t=>!t._stopped&&e&&e(t))}else return n}var vt=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,yt=(e,t,n,r,i,a)=>{let o=i===`svg`;t===`class`?Ge(e,r,o):t===`style`?Qe(e,n,r):B(t)?W(t)||ut(e,t,n,r,a):(t[0]===`.`?(t=t.slice(1),!0):t[0]===`^`?(t=t.slice(1),!1):bt(e,t,r,o))?(ot(e,t,r),!e.tagName.includes(`-`)&&(t===`value`||t===`checked`||t===`selected`)&&at(e,t,r,o,a,t!==`value`)):e._isVueCE&&(xt(e,t)||e._def.__asyncLoader&&(/[A-Z]/.test(t)||!F(r)))?ot(e,R(t),r,a,t):(t===`true-value`?e._trueValue=r:t===`false-value`&&(e._falseValue=r),at(e,t,r,o))};function bt(e,t,n,r){if(r)return!!(t===`innerHTML`||t===`textContent`||t in e&&vt(t)&&S(n));if(t===`spellcheck`||t===`draggable`||t===`translate`||t===`autocorrect`||t===`sandbox`&&e.tagName===`IFRAME`||t===`form`||t===`list`&&e.tagName===`INPUT`||t===`type`&&e.tagName===`TEXTAREA`)return!1;if(t===`width`||t===`height`){let t=e.tagName;if(t===`IMG`||t===`VIDEO`||t===`CANVAS`||t===`SOURCE`)return!1}return vt(t)&&F(n)?!1:t in e}function xt(e,t){let n=e._def.props;if(!n)return!1;let r=R(t);return Array.isArray(n)?n.some(e=>R(e)===r):Object.keys(n).some(e=>R(e)===r)}var St=t=>{let n=t.props[`onUpdate:modelValue`]||!1;return e(n)?e=>l(n,e):n};function Ct(e){e.target.composing=!0}function wt(e){let t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event(`input`)))}var $=Symbol(`_assign`);function Tt(e,t,n){return t&&(e=e.trim()),n&&(e=re(e)),e}var Et={created(e,{modifiers:{lazy:t,trim:n,number:r}},i){e[$]=St(i);let a=r||i.props&&i.props.type===`number`;st(e,t?`change`:`input`,t=>{t.target.composing||e[$](Tt(e.value,n,a))}),(n||a)&&st(e,`change`,()=>{e.value=Tt(e.value,n,a)}),t||(st(e,`compositionstart`,Ct),st(e,`compositionend`,wt),st(e,`change`,wt))},mounted(e,{value:t}){e.value=t??``},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:r,trim:i,number:a}},o){if(e[$]=St(o),e.composing)return;let s=(a||e.type===`number`)&&!/^0\d/.test(e.value)?re(e.value):e.value,c=t??``;if(s===c)return;let l=e.getRootNode();(l instanceof Document||l instanceof ShadowRoot)&&l.activeElement===e&&e.type!==`range`&&(r&&t===n||i&&e.value.trim()===c)||(e.value=c)}},Dt={deep:!0,created(t,n,r){t[$]=St(r),st(t,`change`,()=>{let n=t._modelValue,r=Mt(t),i=t.checked,a=t[$];if(e(n)){let e=j(n,r),t=e!==-1;if(i&&!t)a(n.concat(r));else if(!i&&t){let t=[...n];t.splice(e,1),a(t)}}else if(O(n)){let e=new Set(n);i?e.add(r):e.delete(r),a(e)}else a(Nt(t,i))})},mounted:Ot,beforeUpdate(e,t,n){e[$]=St(n),Ot(e,t,n)}};function Ot(t,{value:n,oldValue:r},i){t._modelValue=n;let a;if(e(n))a=j(n,i.props.value)>-1;else if(O(n))a=n.has(i.props.value);else{if(n===r)return;a=y(n,Nt(t,!0))}t.checked!==a&&(t.checked=a)}var kt={created(e,{value:t},n){e.checked=y(t,n.props.value),e[$]=St(n),st(e,`change`,()=>{e[$](Mt(e))})},beforeUpdate(e,{value:t,oldValue:n},r){e[$]=St(r),t!==n&&(e.checked=y(t,r.props.value))}},At={deep:!0,created(e,{value:t,modifiers:{number:n}},r){let i=O(t);st(e,`change`,()=>{let t=Array.prototype.filter.call(e.options,e=>e.selected).map(e=>n?re(Mt(e)):Mt(e));e[$](e.multiple?i?new Set(t):t:t[0]),e._assigning=!0,_(()=>{e._assigning=!1})}),e[$]=St(r)},mounted(e,{value:t}){jt(e,t)},beforeUpdate(e,t,n){e[$]=St(n)},updated(e,{value:t}){e._assigning||jt(e,t)}};function jt(t,n){let r=t.multiple,i=e(n);if(!(r&&!i&&!O(n))){for(let e=0,a=t.options.length;eString(e)===String(o)):a.selected=j(n,o)>-1}else a.selected=n.has(o);else if(y(Mt(a),n)){t.selectedIndex!==e&&(t.selectedIndex=e);return}}!r&&t.selectedIndex!==-1&&(t.selectedIndex=-1)}}function Mt(e){return`_value`in e?e._value:e.value}function Nt(e,t){let n=t?`_trueValue`:`_falseValue`;return n in e?e[n]:t}var Pt=[`ctrl`,`shift`,`alt`,`meta`],Ft={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)=>Pt.some(n=>e[`${n}Key`]&&!t.includes(n))},It=(e,t)=>{if(!e)return e;let n=e._withMods||={},r=t.join(`.`);return n[r]||(n[r]=((n,...r)=>{for(let e=0;e{let n=e._withKeys||={},r=t.join(`.`);return n[r]||(n[r]=(n=>{if(!(`key`in n))return;let r=d(n.key);if(t.some(e=>e===r||Lt[e]===r))return e(n)}))},zt=f({patchProp:yt},we),Bt;function Vt(){return Bt||=b(zt)}var Ht=((...e)=>{let t=Vt().createApp(...e),{mount:n}=t;return t.mount=e=>{let r=Wt(e);if(!r)return;let i=t._component;!S(i)&&!i.render&&!i.template&&(i.template=r.innerHTML),r.nodeType===1&&(r.textContent=``);let a=n(r,!1,Ut(r));return r instanceof Element&&(r.removeAttribute(`v-cloak`),r.setAttribute(`data-v-app`,``)),a},t});function Ut(e){if(e instanceof SVGElement)return`svg`;if(typeof MathMLElement==`function`&&e instanceof MathMLElement)return`mathml`}function Wt(e){return F(e)?document.querySelector(e):e}var Gt=`/assets/pymclogo-ew909fnk.png`,Kt=`/assets/meshcore-DQNtEl5I.svg`;function qt(e,t){let n=J(),r=null,i=null,o=P(()=>(t.enabled===void 0?!0:a(t.enabled))&&n.canMaintainConnections),s=async()=>i||(i=Promise.resolve(e()).finally(()=>{i=null}),i),c=()=>{r!==null&&(clearInterval(r),r=null)},l=async()=>{c(),o.value&&(t.immediate!==!1&&await s(),r=window.setInterval(()=>{s()},t.intervalMs))};return k(o,e=>{e?l():c()},{immediate:!0}),ie(()=>{c()}),{start:l,stop:c,runNow:s}}var Jt={},Yt={width:`23`,height:`25`,viewBox:`0 0 23 25`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function Xt(e,t){return K(),G(`svg`,Yt,[...t[0]||=[V(`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),V(`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)]])}var Zt=Z(Jt,[[`render`,Xt]]),Qt={},$t={width:`17`,height:`24`,viewBox:`0 0 17 24`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function en(e,t){return K(),G(`svg`,$t,[...t[0]||=[C(``,12)]])}var tn=Z(Qt,[[`render`,en]]),nn={class:`glass-card p-5 relative overflow-hidden`},rn={key:0,class:`absolute inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-10 rounded-lg`},an={class:`flex items-baseline gap-2 mb-4`},on={class:`text-content-primary dark:text-content-primary text-2xl font-medium`},sn=[`viewBox`],cn=[`y1`,`y2`],ln=[`cx`,`cy`],un=200,dn=50,fn=4,pn=Z(T({__name:`RFNoiseFloor`,props:{limit:{default:void 0}},setup(e){let t=e,n=he(),r=me(),a=q(null),o=(e,t)=>{let n=t/100*(e.length-1),r=Math.floor(n),i=Math.ceil(n);return r===i?e[r]:e[r]+(e[i]-e[r])*(n-r)},c=P(()=>{let e=m.value;if(e.length===0)return[];let t=[...e].sort((e,t)=>e-t),n=o(t,2.5),r=o(t,97.5),i=r-n,a=Math.max(i*.05,.5),s=n-a,c=r+a,l=c-s||1;return e.map((t,n)=>{let r=fn+n/Math.max(e.length-1,1)*(un-fn*2),i=(Math.max(s,Math.min(c,t))-s)/l;return{x:r,y:dn-fn-i*(dn-fn*2)}})}),l=async()=>{try{let e={hours:1};t.limit&&(e.limit=t.limit),await Promise.all([n.fetchNoiseFloorHistory(e),n.fetchNoiseFloorStats({hours:1})])}catch(e){console.error(`Error fetching noise floor data:`,e)}},d=()=>{a.value||=window.setInterval(l,5e3)},f=()=>{a.value&&=(clearInterval(a.value),null)};u(()=>{l(),d()}),ie(()=>{f()});let p=P(()=>{let e=n.noiseFloorSparklineData;return e&&e.length>0?e[e.length-1]:n.noiseFloorStats?.avg_noise_floor??-116}),m=P(()=>n.noiseFloorSparklineData);return(e,t)=>(K(),G(`div`,nn,[s(r).cadCalibrationRunning?(K(),G(`div`,rn,[...t[0]||=[C(`
CAD Calibration

In Progress

`,1)]])):A(``,!0),t[2]||=V(`p`,{class:`text-content-secondary dark:text-content-muted text-xs uppercase mb-2`},` RF NOISE FLOOR `,-1),V(`div`,an,[V(`span`,on,L(p.value),1),t[1]||=V(`span`,{class:`text-content-secondary dark:text-content-muted text-xs uppercase`},`dBm`,-1)]),(K(),G(`svg`,{class:`w-full h-[50px]`,viewBox:`0 0 ${un} ${dn}`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},[(K(),G(z,null,i(3,e=>V(`line`,{key:`grid-`+e,x1:0,y1:e*dn/4,x2:un,y2:e*dn/4,stroke:`rgba(255, 255, 255, 0.1)`,"stroke-width":`1`},null,8,cn)),64)),(K(!0),G(z,null,i(c.value,(e,t)=>(K(),G(`circle`,{key:`point-`+t,cx:e.x,cy:e.y,r:`2.5`,fill:`rgba(245, 158, 11, 0.8)`,class:`transition-all duration-300`},null,8,ln))),128))],8,sn))]))}}),[[`__scopeId`,`data-v-92b94522`]]),mn={},hn={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 gn(e,t){return K(),G(`svg`,hn,[...t[0]||=[V(`g`,{id:`Page-1`,stroke:`none`,"stroke-width":`1`,fill:`none`,"fill-rule":`evenodd`},[V(`g`,{transform:`translate(-420.000000, -3641.000000)`,fill:`currentColor`},[V(`g`,{id:`icons`,transform:`translate(56.000000, 160.000000)`},[V(`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)]])}var _n=Z(mn,[[`render`,gn]]),vn={class:`text-center`},yn={class:`relative flex items-center justify-center mb-8`},bn={class:`relative w-32 h-32`},xn={class:`absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2`},Sn={key:0,class:`absolute inset-0 flex items-center justify-center`},Cn={key:1,class:`absolute inset-0 flex items-center justify-center`},wn={key:2,class:`absolute inset-0`},Tn={class:`mb-6`},En={key:0,class:`text-content-primary dark:text-content-primary text-lg`},Dn={key:1,class:`text-accent-green text-lg font-medium`},On={key:2,class:`text-secondary text-lg`},kn={key:3,class:`text-accent-red text-lg`},An={key:4,class:`text-content-secondary dark:text-content-muted`},jn={key:5,class:`mt-3`},Mn={key:0,class:`text-secondary text-sm`},Nn={key:1,class:`text-accent-red text-sm`},Pn={key:0,class:`flex gap-3`},Fn={key:1,class:`text-content-muted text-sm`},In=Z(T({name:`AdvertModal`,__name:`AdvertModal`,props:{isOpen:{type:Boolean},isLoading:{type:Boolean},isSuccess:{type:Boolean},error:{default:null}},emits:[`close`,`send`],setup(e,{emit:t}){let n=e,r=t,i=q(!1),a=q(!1),o=q(!1);k(()=>n.isOpen,e=>{e?(i.value=!0,setTimeout(()=>{a.value=!0},50)):(a.value=!1,o.value=!1,setTimeout(()=>{i.value=!1},300))},{immediate:!0}),k(()=>n.isLoading,e=>{e||setTimeout(()=>{o.value=!1},1e3)});let s=()=>{n.isLoading||r(`close`)},c=()=>{n.isLoading||(o.value=!0,r(`send`))},l=e=>e?.includes(`Network error - no response received`)||e?.includes(`timeout`);return(t,n)=>(K(),v(D,{to:`body`},[i.value?(K(),G(`div`,{key:0,class:`fixed inset-0 z-50 flex items-center justify-center p-4`,onClick:It(s,[`self`])},[V(`div`,{class:x([`absolute inset-0 bg-black/60 backdrop-blur-sm transition-opacity duration-300`,a.value?`opacity-100`:`opacity-0`])},null,2),V(`div`,{class:x([`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`,a.value?`scale-100 opacity-100`:`scale-95 opacity-0`])},[e.isLoading?A(``,!0):(K(),G(`button`,{key:0,onClick:s,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`},[...n[0]||=[V(`svg`,{class:`w-5 h-5`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[V(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])),V(`div`,vn,[n[6]||=V(`h2`,{class:`text-content-primary dark:text-content-primary text-xl font-semibold mb-6`},` Send Advertisement `,-1),V(`div`,yn,[V(`div`,bn,[V(`div`,xn,[M(_n,{class:x([`w-16 h-16 transition-all duration-500`,[e.isLoading?`animate-pulse`:``,e.isSuccess?`text-accent-green`:e.error&&!l(e.error)?`text-accent-red`:`text-primary`]]),style:w({filter:e.isLoading?`drop-shadow(0 0 8px currentColor)`:e.isSuccess?`drop-shadow(0 0 8px #A5E5B6)`:e.error&&!l(e.error)?`drop-shadow(0 0 8px #FB787B)`:`drop-shadow(0 0 4px #AAE8E8)`})},null,8,[`class`,`style`])]),e.isLoading||e.isSuccess?(K(),G(`div`,Sn,[V(`div`,{class:x([`absolute w-16 h-16 rounded-full border-2 animate-ping`,[e.isSuccess?`border-accent-green/60`:`border-primary/60`]]),style:{"animation-duration":`1.5s`}},null,2),V(`div`,{class:x([`absolute w-24 h-24 rounded-full border-2 animate-ping`,[e.isSuccess?`border-accent-green/40`:`border-primary/40`]]),style:{"animation-duration":`2s`,"animation-delay":`0.3s`}},null,2),V(`div`,{class:x([`absolute w-32 h-32 rounded-full border-2 animate-ping`,[e.isSuccess?`border-accent-green/20`:`border-primary/20`]]),style:{"animation-duration":`2.5s`,"animation-delay":`0.6s`}},null,2)])):A(``,!0),o.value?(K(),G(`div`,Cn,[...n[1]||=[V(`div`,{class:`absolute w-8 h-8 rounded-full border-4 border-secondary animate-ping-fast`},null,-1),V(`div`,{class:`absolute w-16 h-16 rounded-full border-3 border-secondary/70 animate-ping-fast`,style:{"animation-delay":`0.1s`}},null,-1),V(`div`,{class:`absolute w-24 h-24 rounded-full border-2 border-secondary/50 animate-ping-fast`,style:{"animation-delay":`0.2s`}},null,-1),V(`div`,{class:`absolute w-32 h-32 rounded-full border-2 border-secondary/30 animate-ping-fast`,style:{"animation-delay":`0.3s`}},null,-1)]])):A(``,!0),e.isLoading||e.isSuccess?(K(),G(`div`,wn,[V(`div`,{class:x([`absolute top-2 right-2 w-4 h-4 rounded-full transition-all duration-500 animate-pulse`,[e.isSuccess?`bg-accent-green shadow-lg shadow-accent-green/50`:`bg-primary/70 shadow-lg shadow-primary/30`]]),style:{"animation-delay":`0.5s`}},[...n[2]||=[V(`div`,{class:`w-2 h-2 bg-white rounded-full mx-auto mt-1`},null,-1)]],2),V(`div`,{class:x([`absolute bottom-2 left-2 w-4 h-4 rounded-full transition-all duration-500 animate-pulse`,[e.isSuccess?`bg-accent-green shadow-lg shadow-accent-green/50`:`bg-primary/70 shadow-lg shadow-primary/30`]]),style:{"animation-delay":`1s`}},[...n[3]||=[V(`div`,{class:`w-2 h-2 bg-white rounded-full mx-auto mt-1`},null,-1)]],2),V(`div`,{class:x([`absolute top-1/2 right-1 w-4 h-4 rounded-full transition-all duration-500 animate-pulse`,[e.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%)`}},[...n[4]||=[V(`div`,{class:`w-2 h-2 bg-white rounded-full mx-auto mt-1`},null,-1)]],2),V(`div`,{class:x([`absolute top-3 left-3 w-4 h-4 rounded-full transition-all duration-500 animate-pulse`,[e.isSuccess?`bg-accent-green shadow-lg shadow-accent-green/50`:`bg-primary/70 shadow-lg shadow-primary/30`]]),style:{"animation-delay":`2s`}},[...n[5]||=[V(`div`,{class:`w-2 h-2 bg-white rounded-full mx-auto mt-1`},null,-1)]],2)])):A(``,!0)])]),V(`div`,Tn,[e.isLoading?(K(),G(`p`,En,` Broadcasting advertisement... `)):e.isSuccess?(K(),G(`p`,Dn,` Advertisement sent successfully! `)):e.error&&l(e.error)?(K(),G(`p`,On,` Advertisement likely sent `)):e.error?(K(),G(`p`,kn,`Failed to send advertisement`)):(K(),G(`p`,An,` This will broadcast your node's presence to nearby nodes. `)),e.error?(K(),G(`div`,jn,[l(e.error)?(K(),G(`p`,Mn,` Network timeout occurred, but the advertisement may have been successfully transmitted to nearby nodes. `)):(K(),G(`p`,Nn,L(e.error),1))])):A(``,!0)]),!e.isLoading&&!e.isSuccess?(K(),G(`div`,Pn,[V(`button`,{onClick:s,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 `),V(`button`,{onClick:c,class:x([`flex-1 rounded-[10px] px-6 py-3 font-medium transition-all duration-200 shadow-lg`,[e.error&&l(e.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`]])},L(e.error&&l(e.error)?`Try Again`:`Send Advertisement`),3)])):A(``,!0),e.isSuccess?(K(),G(`div`,Fn,`Closing automatically...`)):A(``,!0)])],2)])):A(``,!0)]))}}),[[`__scopeId`,`data-v-2c9f179a`]]),Ln={},Rn={width:`14`,height:`14`,viewBox:`0 0 14 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function zn(e,t){return K(),G(`svg`,Rn,[...t[0]||=[C(``,2)]])}var Bn=Z(Ln,[[`render`,zn]]),Vn={},Hn={width:`14`,height:`14`,viewBox:`0 0 14 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function Un(e,t){return K(),G(`svg`,Hn,[...t[0]||=[C(``,9)]])}var Wn=Z(Vn,[[`render`,Un]]),Gn={},Kn={width:`14`,height:`14`,viewBox:`0 0 14 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function qn(e,t){return K(),G(`svg`,Kn,[...t[0]||=[C(``,2)]])}var Jn=Z(Gn,[[`render`,qn]]),Yn={},Xn={width:`11`,height:`14`,viewBox:`0 0 11 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function Zn(e,t){return K(),G(`svg`,Xn,[...t[0]||=[V(`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)]])}var Qn=Z(Yn,[[`render`,Zn]]),$n={},er={width:`14`,height:`14`,viewBox:`0 0 14 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function tr(e,t){return K(),G(`svg`,er,[...t[0]||=[V(`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)]])}var nr=Z($n,[[`render`,tr]]),rr={},ir={width:`14`,height:`14`,viewBox:`0 0 14 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function ar(e,t){return K(),G(`svg`,ir,[...t[0]||=[C(``,2)]])}var or=Z(rr,[[`render`,ar]]),sr={name:`SystemIcon`},cr={width:`14`,height:`14`,viewBox:`0 0 14 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function lr(e,t,n,r,i,a){return K(),G(`svg`,cr,[...t[0]||=[C(``,5)]])}var ur=Z(sr,[[`render`,lr]]),dr={},fr={width:`11`,height:`14`,viewBox:`0 0 11 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function pr(e,t){return K(),G(`svg`,fr,[...t[0]||=[V(`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),V(`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)]])}var mr=Z(dr,[[`render`,pr]]),hr={},gr={width:`14`,height:`14`,viewBox:`0 0 14 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`};function _r(e,t){return K(),G(`svg`,gr,[...t[0]||=[C(``,2)]])}var vr=Z(hr,[[`render`,_r]]),yr={class:`w-[285px] flex-shrink-0 p-[15px] hidden lg:block`},br={class:`glass-card h-full p-6`},xr={class:`mb-12`},Sr={class:`text-content-secondary dark:text-content-muted text-sm`},Cr=[`title`],wr={class:`text-content-secondary dark:text-content-muted text-sm mt-1`},Tr={class:`mt-3 p-2 rounded-[10px] border border-stroke-subtle dark:border-white/10 bg-white dark:bg-white/5`},Er={class:`flex items-center justify-between`},Dr={class:`flex items-center gap-3 mt-1.5 text-[10px] text-content-muted dark:text-content-muted`},Or={class:`text-green-600 dark:text-green-400`},kr={class:`text-red-600 dark:text-red-400`},Ar={key:0,class:`text-orange-600 dark:text-orange-400`},jr={class:`mb-8`},Mr={class:`mb-8`},Nr={class:`space-y-2`},Pr=[`onClick`],Fr={class:`mb-8`},Ir={class:`space-y-2`},Lr=[`onClick`],Rr={class:`mb-8`},zr={class:`space-y-2`},Br=[`onClick`],Vr={class:`mb-8`},Hr={class:`space-y-2`},Ur=[`onClick`],Wr={class:`mb-4`},Gr={class:`flex rounded-[10px] overflow-hidden border border-stroke-subtle dark:border-white/10 bg-white dark:bg-white/5`},Kr=[`title`,`disabled`,`onClick`],qr=[`disabled`],Jr={class:`flex items-center gap-3`},Yr={class:`mb-4`},Xr={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`},Zr={class:`flex items-center gap-2`},Qr={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`},$r={class:`space-y-1`},ei={class:`flex items-center justify-between`},ti={class:`text-content-primary dark:text-content-primary font-mono`},ni={key:0,class:`pl-2 space-y-0.5 text-[10px] text-content-secondary dark:text-content-muted`},ri={key:0,class:`flex items-center gap-1`},ii={class:`bg-white/5 dark:bg-black/20 px-1 py-0.5 rounded`},ai={class:`space-y-1`},oi={class:`flex items-center justify-between`},si={class:`text-content-primary dark:text-content-primary font-mono`},ci={key:0,class:`pl-2 space-y-0.5 text-[10px] text-content-secondary dark:text-content-muted`},li={key:0,class:`flex items-center gap-1`},ui={class:`bg-white/5 dark:bg-black/20 px-1 py-0.5 rounded`},di={key:0,class:`mb-4`},fi={class:`text-content-secondary dark:text-content-muted text-xs mb-2`},pi={class:`text-content-primary dark:text-content-primary`},mi={class:`w-full h-1 bg-white/10 rounded-full overflow-hidden`},hi={class:`flex items-center gap-2 text-content-secondary dark:text-content-muted text-xs mb-3`},gi={class:`flex items-center justify-center gap-3`},_i={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`},vi={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`},yi=T({name:`SidebarNav`,__name:`Sidebar`,setup(e){let t=oe(),n=se(),r=me(),a=ge(),o=q(!1),l=q(!1),d=q(!1),f=q(!1),p=q(!1),m=q(null),h=q(`unknown`),g=q(0),_=q(0),y=q(0),b=async()=>{try{let e=(await Y.get(`/advert_rate_limit_stats`))?.data;h.value=typeof e?.adaptive?.current_tier==`string`?e.adaptive.current_tier:`unknown`,g.value=e?.stats?.adverts_allowed||0,_.value=e?.stats?.adverts_dropped||0,y.value=Object.keys(e?.active_penalties||{}).length}catch{h.value=`unknown`,g.value=0,_.value=0,y.value=0}};u(async()=>{await r.fetchStats(),await b()}),k(()=>a.isConnected,e=>{e&&r.fetchStats()}),qt(()=>r.fetchStats(),{intervalMs:5e3,enabled:()=>!a.isConnected,immediate:!1}),qt(b,{intervalMs:3e4,enabled:!0,immediate:!1});let S=P(()=>{switch(h.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`}}),C={dashboard:Wn,neighbors:mr,statistics:or,"system-stats":ur,sessions:ur,configuration:Bn,"room-servers":Bn,companions:Bn,logs:Qn,terminal:nr,help:Jn},T=[{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`}],E=[{id:`forward`,label:`Forward`,title:`Repeats packets and Room Server and Companion identities can TX.`},{id:`monitor`,label:`Monitor`,title:`Does not repeat packets, can Advert, Room Server and Companion identities can TX.`},{id:`no_tx`,label:`No TX`,title:`No packets transmitted.`}],D=P(()=>e=>n.path===e),O=e=>{t.push(e)},ee=async()=>{o.value=!0,m.value=null;try{await r.sendAdvert(),p.value=!0,setTimeout(()=>{j()},2e3)}catch(e){m.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Failed to send advert:`,e)}finally{o.value=!1}},j=()=>{f.value=!1,p.value=!1,m.value=null,o.value=!1},te=async e=>{if(!l.value&&r.currentMode!==e){l.value=!0;try{await r.setMode(e)}catch(e){console.error(`Failed to set mode:`,e)}finally{l.value=!1}}},ne=async()=>{if(!d.value){d.value=!0;try{await r.toggleDutyCycle()}catch(e){console.error(`Failed to toggle duty cycle:`,e)}finally{d.value=!1}}},N=q(new Date().toLocaleTimeString());setInterval(()=>{N.value=new Date().toLocaleTimeString()},1e3);let F=P(()=>{let e=r.dutyCyclePercentage,t=`#A5E5B6`;return e>90?t=`#FB787B`:e>70&&(t=`#FFC246`),{width:e===0?`2px`:`${Math.max(e,2)}%`,backgroundColor:t}}),R=q(!1),B=P(()=>r.version.includes(`dev`)||r.coreVersion.includes(`dev`)),H=e=>{let t=e.match(/^([\d.]+)(\.dev(\d+))?((\+g)([a-f0-9]+))?$/);return t?{base:t[1],isDev:!!t[2],devNumber:t[3]||null,commit:t[6]||null}:{base:e,isDev:!1,devNumber:null,commit:null}},U=P(()=>H(r.version)),W=P(()=>H(r.coreVersion));return(e,t)=>(K(),G(z,null,[V(`aside`,yr,[V(`div`,br,[V(`div`,xr,[t[3]||=V(`div`,{class:`mb-3 flex justify-center`},[V(`img`,{src:`/assets/pymclogo-ew909fnk.png`,alt:`pyMC`,class:`h-[6.5rem]`})],-1),V(`p`,Sr,[I(L(s(r).nodeName)+` `,1),V(`span`,{class:x([`inline-block w-2 h-2 rounded-full ml-2`,s(r).statusBadge.text===`Active`?`bg-accent-green`:s(r).statusBadge.text===`Monitor Mode`?`bg-secondary`:`bg-accent-red`]),title:s(r).statusBadge.title},null,10,Cr)]),V(`p`,wr,` <`+L(s(r).pubKey)+`> `,1),V(`div`,Tr,[V(`div`,Er,[t[2]||=V(`span`,{class:`text-content-muted dark:text-content-muted text-[10px] uppercase tracking-wide`},`Adaptive`,-1),V(`div`,{class:x([`inline-flex items-center px-2 py-0.5 rounded-full border text-[10px] font-semibold`,S.value])},L(h.value.toUpperCase()),3)]),V(`div`,Dr,[V(`span`,Or,`OK: `+L(g.value),1),V(`span`,kr,`Drop: `+L(_.value),1),y.value>0?(K(),G(`span`,Ar,`Pen: `+L(y.value),1)):A(``,!0)])])]),t[21]||=V(`div`,{class:`border-t border-stroke-subtle dark:border-stroke mb-6`},null,-1),V(`div`,jr,[t[5]||=V(`p`,{class:`text-content-muted dark:text-content-muted text-xs uppercase mb-4`},`Actions`,-1),V(`button`,{onClick:t[0]||=e=>f.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`},[...t[4]||=[V(`svg`,{class:`w-3.5 h-3.5`,viewBox:`0 0 14 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},[V(`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),I(` Send Advert `,-1)]])]),V(`div`,Mr,[t[6]||=V(`p`,{class:`text-content-muted dark:text-content-muted text-xs uppercase mb-4`},`Monitoring`,-1),V(`div`,Nr,[(K(!0),G(z,null,i(T.slice(0,3),e=>(K(),G(`button`,{key:e.name,onClick:t=>O(e.route),class:x([D.value(e.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`])},[(K(),v(c(C[e.icon]),{class:x(D.value(e.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`])),I(` `+L(e.name),1)],10,Pr))),128))])]),V(`div`,Fr,[t[7]||=V(`p`,{class:`text-content-muted dark:text-content-muted text-xs uppercase mb-4`},`System`,-1),V(`div`,Ir,[(K(!0),G(z,null,i(T.slice(3,7),e=>(K(),G(`button`,{key:e.name,onClick:t=>O(e.route),class:x([D.value(e.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`])},[(K(),v(c(C[e.icon]),{class:x(D.value(e.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`])),I(` `+L(e.name),1)],10,Lr))),128))])]),V(`div`,Rr,[t[8]||=V(`p`,{class:`text-content-muted dark:text-content-muted text-xs uppercase mb-4`},` Room Servers & Companions `,-1),V(`div`,zr,[(K(!0),G(z,null,i(T.slice(7,9),e=>(K(),G(`button`,{key:e.name,onClick:t=>O(e.route),class:x([D.value(e.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`])},[(K(),v(c(C[e.icon]),{class:x(D.value(e.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`])),I(` `+L(e.name),1)],10,Br))),128))])]),V(`div`,Vr,[t[9]||=V(`p`,{class:`text-content-muted dark:text-content-muted text-xs uppercase mb-4`},`Other`,-1),V(`div`,Hr,[(K(!0),G(z,null,i(T.slice(9),e=>(K(),G(`button`,{key:e.name,onClick:t=>O(e.route),class:x([D.value(e.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`])},[(K(),v(c(C[e.icon]),{class:x(D.value(e.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`])),I(` `+L(e.name),1)],10,Ur))),128))])]),M(pn,{"current-value":s(r).noiseFloorDbm||-116,"update-interval":3e3,class:`mb-6`},null,8,[`current-value`]),V(`div`,Wr,[t[10]||=V(`p`,{class:`text-content-muted dark:text-content-muted text-xs uppercase mb-2`},`Mode`,-1),V(`div`,Gr,[(K(),G(z,null,i(E,e=>V(`button`,{key:e.id,type:`button`,title:e.title,disabled:l.value,onClick:t=>te(e.id),class:x([`flex-1 py-2.5 px-2 text-xs font-medium transition-all duration-200 border-r border-stroke-subtle dark:border-white/10 last:border-r-0`,l.value?`opacity-60 cursor-not-allowed`:`cursor-pointer`,s(r).currentMode===e.id?e.id===`forward`?`bg-mode-segment-forward text-accent-green`:e.id===`monitor`?`bg-amber-500/20 text-amber-600 dark:text-amber-400`:`bg-mode-segment-no-tx text-accent-red`:`text-content-primary dark:text-content-primary hover:bg-white/10 dark:hover:bg-white/10`])},L(l.value&&s(r).currentMode!==e.id?`…`:e.label),11,Kr)),64))])]),V(`button`,{onClick:ne,disabled:d.value,class:x([`p-4 flex items-center justify-between mb-4 w-full transition-all duration-200 cursor-pointer group`,s(r).dutyCycleButtonState.warning?`glass-card-orange hover:bg-accent-red/10`:`glass-card-green hover:bg-accent-green/10`])},[V(`div`,Jr,[M(vr,{class:`w-3.5 h-3.5 text-content-primary dark:text-content-primary group-hover:text-primary transition-colors`}),t[11]||=V(`span`,{class:`text-content-primary dark:text-content-primary text-sm group-hover:text-primary transition-colors`},`Duty Cycle`,-1)]),V(`span`,{class:x([`text-xs font-medium group-hover:text-white transition-colors`,s(r).dutyCycleButtonState.warning?`text-accent-red`:`text-primary`])},L(d.value?`Changing...`:s(r).dutyCycleEnabled?`Enabled`:`Disabled`),3)],10,qr),V(`div`,Yr,[B.value?(K(),G(`div`,Xr,[...t[12]||=[V(`div`,{class:`flex items-center justify-center gap-2`},[V(`svg`,{class:`w-4 h-4 text-blue-500 dark:text-blue-400 flex-shrink-0`,viewBox:`0 0 20 20`,fill:`currentColor`},[V(`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`})]),V(`span`,{class:`text-blue-500 dark:text-blue-400 text-xs font-semibold`},`Development Build`)],-1)]])):A(``,!0),V(`div`,{onClick:t[1]||=e=>R.value=!R.value,class:`cursor-pointer transition-all duration-200 hover:scale-[1.02]`},[V(`div`,Zr,[V(`span`,{class:x([`glass-card px-2 py-1 text-xs font-medium rounded border transition-colors`,U.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`+L(U.value.base)+L(U.value.isDev?`-dev`+U.value.devNumber:``),3),V(`span`,{class:x([`glass-card px-2 py-1 text-xs font-medium rounded border transition-colors`,W.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`+L(W.value.base)+L(W.value.isDev?`-dev`+W.value.devNumber:``),3),(K(),G(`svg`,{class:x([`w-3 h-3 text-content-muted transition-transform duration-200`,R.value?`rotate-180`:``]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...t[13]||=[V(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M19 9l-7 7-7-7`},null,-1)]],2))]),R.value?(K(),G(`div`,Qr,[V(`div`,$r,[V(`div`,ei,[t[14]||=V(`span`,{class:`text-content-muted font-medium`},`Repeater:`,-1),V(`span`,ti,`v`+L(U.value.base),1)]),U.value.isDev?(K(),G(`div`,ni,[V(`div`,null,`Dev Build: `+L(U.value.devNumber),1),U.value.commit?(K(),G(`div`,ri,[t[15]||=V(`span`,null,`Commit:`,-1),V(`code`,ii,L(U.value.commit),1)])):A(``,!0)])):A(``,!0)]),t[18]||=V(`div`,{class:`border-t border-stroke-subtle dark:border-stroke/20`},null,-1),V(`div`,ai,[V(`div`,oi,[t[16]||=V(`span`,{class:`text-content-muted font-medium`},`Core:`,-1),V(`span`,si,`v`+L(W.value.base),1)]),W.value.isDev?(K(),G(`div`,ci,[V(`div`,null,`Dev Build: `+L(W.value.devNumber),1),W.value.commit?(K(),G(`div`,li,[t[17]||=V(`span`,null,`Commit:`,-1),V(`code`,ui,L(W.value.commit),1)])):A(``,!0)])):A(``,!0)])])):A(``,!0)])]),t[22]||=V(`div`,{class:`border-t border-accent-green mb-4`},null,-1),s(r).dutyCycleEnabled?(K(),G(`div`,di,[V(`p`,fi,[t[19]||=I(` Duty Cycle: `,-1),V(`span`,pi,L(s(r).dutyCycleUtilization.toFixed(1))+`% / `+L(s(r).dutyCycleMax.toFixed(1))+`%`,1)]),V(`div`,mi,[V(`div`,{class:`h-full rounded-full transition-all duration-300`,style:w(F.value)},null,4)])])):A(``,!0),V(`div`,hi,[t[20]||=V(`svg`,{class:`w-3 h-3`,viewBox:`0 0 13 13`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},[V(`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),I(` Last Updated: `+L(N.value),1)]),t[23]||=V(`div`,{class:`flex flex-col items-center justify-center mb-4`},[V(`p`,{class:`text-content-muted dark:text-content-muted text-[10px] mb-1 tracking-wide uppercase opacity-70`},` Powered by `),V(`img`,{src:`/assets/meshcore-DQNtEl5I.svg`,alt:`MeshCore`,class:`h-4 opacity-70 dark:invert-0 invert`})],-1),V(`div`,gi,[V(`a`,_i,[M(Zt,{class:`w-5 h-5 text-white group-hover:text-primary transition-colors`})]),V(`a`,vi,[M(tn,{class:`w-5 h-5 text-white group-hover:text-yellow-500 transition-colors`})])])])]),M(In,{isOpen:f.value,isLoading:o.value,isSuccess:p.value,error:m.value,onClose:j,onSend:ee},null,8,[`isOpen`,`isLoading`,`isSuccess`,`error`])],64))}}),bi={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`},xi={class:`mb-6 flex items-center justify-between`},Si={class:`text-content-secondary dark:text-[#C3C3C3] text-sm`},Ci=[`title`],wi={class:`text-content-secondary dark:text-[#C3C3C3] text-sm mt-1`},Ti={class:`mt-3 p-2 rounded-[10px] border border-stroke-subtle dark:border-white/10 bg-white dark:bg-white/5`},Ei={class:`flex items-center justify-between`},Di={class:`flex items-center gap-3 mt-1.5 text-[10px] text-content-muted`},Oi={class:`text-green-600 dark:text-green-400`},ki={class:`text-red-600 dark:text-red-400`},Ai={key:0,class:`text-orange-600 dark:text-orange-400`},ji={class:`mb-4`},Mi={class:`mb-4`},Ni={class:`space-y-2 mb-3`},Pi=[`onClick`],Fi={class:`mb-4`},Ii={class:`space-y-2 mb-3`},Li=[`onClick`],Ri={class:`mb-4`},zi={class:`space-y-2 mb-3`},Bi=[`onClick`],Vi={class:`mb-4`},Hi={class:`space-y-2 mb-3`},Ui=[`onClick`],Wi={class:`mb-3`},Gi={class:`flex rounded-[.625rem] overflow-hidden border border-stroke dark:border-white/10 bg-white dark:bg-white/5`},Ki=[`title`,`disabled`,`onClick`],qi=[`disabled`],Ji={class:`flex items-center gap-3`},Yi={class:`mb-4`},Xi={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`},Zi={class:`space-y-1`},Qi={class:`flex items-center justify-between`},$i={class:`text-content-primary dark:text-content-primary font-mono`},ea={key:0,class:`pl-2 space-y-0.5 text-[10px] text-content-secondary dark:text-content-muted`},ta={key:0,class:`flex items-center gap-1`},na={class:`bg-white/5 dark:bg-black/20 px-1 py-0.5 rounded`},ra={class:`space-y-1`},ia={class:`flex items-center justify-between`},aa={class:`text-content-primary dark:text-content-primary font-mono`},oa={key:0,class:`pl-2 space-y-0.5 text-[10px] text-content-secondary dark:text-content-muted`},sa={key:0,class:`flex items-center gap-1`},ca={class:`bg-white/5 dark:bg-black/20 px-1 py-0.5 rounded`},la={key:1,class:`mb-4`},ua={class:`text-content-muted text-xs mb-2`},da={class:`text-content-primary dark:text-white`},fa={class:`w-full h-1 bg-stroke-subtle dark:bg-white/10 rounded-full overflow-hidden`},pa={class:`text-content-muted text-xs`},ma=T({name:`MobileSidebar`,__name:`MobileSidebar`,props:{showMobileSidebar:{type:Boolean}},emits:[`update:showMobileSidebar`,`close`],setup(e,{emit:t}){let r=E(()=>le(()=>import(`./RFNoiseFloor-DhLKjd9G.js`),[])),a=q(!1),o=e,l=t,d=oe(),f=se(),p=me();k(()=>o.showMobileSidebar,e=>{e&&!a.value?setTimeout(()=>{a.value=!0},100):e||(a.value=!1)});let m=q(!1),h=q(!1),g=q(!1),_=q(!1),y=q(!1),b=q(null),S=null,C=null,T=q(`unknown`),D=q(0),O=q(0),ee=q(0);u(()=>{S=window.setInterval(()=>{ce.value=new Date().toLocaleTimeString()},1e3),j(),C=window.setInterval(()=>{j()},3e4)}),n(()=>{S&&clearInterval(S),C&&clearInterval(C)});let j=async()=>{try{let e=(await Y.get(`/advert_rate_limit_stats`))?.data;T.value=typeof e?.adaptive?.current_tier==`string`?e.adaptive.current_tier:`unknown`,D.value=e?.stats?.adverts_allowed||0,O.value=e?.stats?.adverts_dropped||0,ee.value=Object.keys(e?.active_penalties||{}).length}catch{T.value=`unknown`,D.value=0,O.value=0,ee.value=0}},te=P(()=>{switch(T.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`}}),ne={dashboard:Wn,neighbors:mr,statistics:or,"system-stats":ur,sessions:ur,configuration:Bn,"room-servers":Bn,companions:Bn,logs:Qn,terminal:nr,help:Jn},N=[{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`}],F=[{id:`forward`,label:`Forward`,title:`Repeats packets and Room Server and Companion identities can TX.`},{id:`monitor`,label:`Monitor`,title:`Does not repeat packets, can Advert, Room Server and Companion identities can TX.`},{id:`no_tx`,label:`No TX`,title:`No packets transmitted.`}],R=P(()=>e=>f.path===e),B=e=>{d.push(e),H()},H=()=>{l(`update:showMobileSidebar`,!1)},U=()=>{pe(),d.push(`/login`),H()},W=async()=>{m.value=!0,b.value=null;try{await p.sendAdvert(),y.value=!0,setTimeout(()=>{re()},2e3)}catch(e){b.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Failed to send advert:`,e)}finally{m.value=!1}},re=()=>{_.value=!1,y.value=!1,b.value=null,m.value=!1},ie=async e=>{if(!h.value&&p.currentMode!==e){h.value=!0;try{await p.setMode(e)}catch(e){console.error(`Failed to set mode:`,e)}finally{h.value=!1}}},ae=async()=>{if(!g.value){g.value=!0;try{await p.toggleDutyCycle()}catch(e){console.error(`Failed to toggle duty cycle:`,e)}finally{g.value=!1}}},ce=q(new Date().toLocaleTimeString()),ue=q(!1),de=P(()=>p.version.includes(`dev`)||p.coreVersion.includes(`dev`)),fe=e=>{let t=e.match(/^([\d.]+)(\.dev(\d+))?((\+g)([a-f0-9]+))?$/);return t?{base:t[1],isDev:!!t[2],devNumber:t[3]||null,commit:t[6]||null}:{base:e,isDev:!1,devNumber:null,commit:null}},J=P(()=>fe(p.version)),X=P(()=>fe(p.coreVersion)),he=P(()=>{let e=p.dutyCyclePercentage,t=`#A5E5B6`;return e>90?t=`#FB787B`:e>70&&(t=`#FFC246`),{width:e===0?`.125rem`:`${Math.max(e,2)}%`,backgroundColor:t}});return(t,n)=>(K(),G(z,null,[V(`div`,{class:x([`fixed inset-0 z-[1010] lg:hidden transition-opacity duration-300`,e.showMobileSidebar?`opacity-100 pointer-events-auto`:`opacity-0 pointer-events-none`])},[V(`div`,{class:`absolute inset-0 bg-black/30 backdrop-blur-sm dark:bg-black/30`,onClick:H}),V(`div`,{class:x([`absolute left-0 top-0 bottom-0 w-72 p-4 transition-transform duration-300`,e.showMobileSidebar?`translate-x-0`:`-translate-x-full`])},[V(`div`,bi,[V(`div`,xi,[V(`div`,null,[n[3]||=V(`div`,{class:`mb-2`},[V(`img`,{src:`/assets/pymclogo-ew909fnk.png`,alt:`pyMC`,class:`h-[5.2rem]`})],-1),V(`p`,Si,[I(L(s(p).nodeName)+` `,1),V(`span`,{class:x([`inline-block w-2 h-2 rounded-full ml-2`,s(p).statusBadge.text===`Active`?`bg-accent-green`:s(p).statusBadge.text===`Monitor Mode`?`bg-secondary`:`bg-accent-red`]),title:s(p).statusBadge.title},null,10,Ci)]),V(`p`,wi,` <`+L(s(p).pubKey)+`> `,1),V(`div`,Ti,[V(`div`,Ei,[n[2]||=V(`span`,{class:`text-content-muted text-[10px] uppercase tracking-wide`},`Adaptive`,-1),V(`div`,{class:x([`inline-flex items-center px-2 py-0.5 rounded-full border text-[10px] font-semibold`,te.value])},L(T.value.toUpperCase()),3)]),V(`div`,Di,[V(`span`,Oi,`OK: `+L(D.value),1),V(`span`,ki,`Drop: `+L(O.value),1),ee.value>0?(K(),G(`span`,Ai,`Pen: `+L(ee.value),1)):A(``,!0)])])]),V(`button`,{onClick:H,class:`text-content-primary dark:text-content-muted hover:text-content-heading dark:hover:text-white`},` ✕ `)]),n[20]||=V(`div`,{class:`border-t border-stroke dark:border-dark-border mb-4`},null,-1),V(`div`,ji,[n[5]||=V(`p`,{class:`text-content-muted text-xs uppercase mb-2`},`Actions`,-1),V(`button`,{onClick:n[0]||=e=>{_.value=!0,H()},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`},[...n[4]||=[V(`svg`,{class:`w-3.5 h-3.5`,viewBox:`0 0 14 14`,fill:`none`,xmlns:`http://www.w3.org/2000/svg`},[V(`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),I(` Send Advert `,-1)]])]),V(`div`,Mi,[n[6]||=V(`p`,{class:`text-content-muted text-xs uppercase mb-2`},`Monitoring`,-1),V(`div`,Ni,[(K(!0),G(z,null,i(N.slice(0,3),e=>(K(),G(`button`,{key:e.name,onClick:t=>B(e.route),class:x([R.value(e.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`])},[(K(),v(c(ne[e.icon]),{class:`w-3.5 h-3.5`})),I(` `+L(e.name),1)],10,Pi))),128))])]),V(`div`,Fi,[n[7]||=V(`p`,{class:`text-content-muted text-xs uppercase mb-2`},`System`,-1),V(`div`,Ii,[(K(!0),G(z,null,i(N.slice(3,7),e=>(K(),G(`button`,{key:e.name,onClick:t=>B(e.route),class:x([R.value(e.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`])},[(K(),v(c(ne[e.icon]),{class:`w-3.5 h-3.5`})),I(` `+L(e.name),1)],10,Li))),128))])]),V(`div`,Ri,[n[8]||=V(`p`,{class:`text-content-muted text-xs uppercase mb-2`},`Room Servers & Companions`,-1),V(`div`,zi,[(K(!0),G(z,null,i(N.slice(7,9),e=>(K(),G(`button`,{key:e.name,onClick:t=>B(e.route),class:x([R.value(e.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`])},[(K(),v(c(ne[e.icon]),{class:`w-3.5 h-3.5`})),I(` `+L(e.name),1)],10,Bi))),128))])]),V(`div`,Vi,[n[9]||=V(`p`,{class:`text-content-muted text-xs uppercase mb-2`},`Other`,-1),V(`div`,Hi,[(K(!0),G(z,null,i(N.slice(9),e=>(K(),G(`button`,{key:e.name,onClick:t=>B(e.route),class:x([R.value(e.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`])},[(K(),v(c(ne[e.icon]),{class:`w-3.5 h-3.5`})),I(` `+L(e.name),1)],10,Ui))),128))])]),a.value?(K(),v(s(r),{key:0,"current-value":s(p).noiseFloorDbm||-116,"update-interval":3e3,limit:50,class:`mb-4`},null,8,[`current-value`])):A(``,!0),V(`div`,Wi,[n[10]||=V(`p`,{class:`text-content-muted text-xs uppercase mb-2`},`Mode`,-1),V(`div`,Gi,[(K(),G(z,null,i(F,e=>V(`button`,{key:e.id,type:`button`,title:e.title,disabled:h.value,onClick:t=>ie(e.id),class:x([`flex-1 py-2.5 px-2 text-xs font-medium transition-all duration-200 border-r border-stroke dark:border-white/10 last:border-r-0`,h.value?`opacity-60 cursor-not-allowed`:`cursor-pointer`,s(p).currentMode===e.id?e.id===`forward`?`bg-mode-segment-forward text-accent-green`:e.id===`monitor`?`bg-amber-500/20 text-amber-600 dark:text-amber-400`:`bg-mode-segment-no-tx text-accent-red`:`text-content-primary dark:text-white hover:bg-white/10 dark:hover:bg-white/10`])},L(h.value&&s(p).currentMode!==e.id?`…`:e.label),11,Ki)),64))])]),V(`button`,{onClick:ae,disabled:g.value,class:x([`p-4 flex items-center justify-between mb-3 w-full transition-all duration-200 cursor-pointer group`,s(p).dutyCycleButtonState.warning?`glass-card-orange hover:bg-accent-red/10`:`glass-card-green hover:bg-accent-green/10`])},[V(`div`,Ji,[M(vr,{class:`w-3.5 h-3.5 text-content-primary dark:text-white group-hover:text-primary transition-colors`}),n[11]||=V(`span`,{class:`text-content-primary dark:text-white text-sm group-hover:text-primary transition-colors`},`Duty Cycle`,-1)]),V(`span`,{class:x([`text-xs font-medium group-hover:text-primary dark:group-hover:text-white transition-colors`,s(p).dutyCycleButtonState.warning?`text-accent-red`:`text-primary`])},L(g.value?`Changing...`:s(p).dutyCycleEnabled?`Enabled`:`Disabled`),3)],10,qi),V(`button`,{onClick:U,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`},[...n[12]||=[V(`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`},[V(`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),I(` Logout `,-1)]]),V(`div`,Yi,[V(`div`,{onClick:n[1]||=e=>ue.value=!ue.value,class:`flex items-center gap-2 cursor-pointer group`},[V(`span`,{class:x([`glass-card px-2 py-1 text-xs font-medium rounded border transition-all duration-200`,`border-stroke dark:border-dark-border`,J.value.isDev?`text-secondary bg-secondary-bg/20 dark:bg-secondary-bg/10 border-secondary/40`:`text-content-muted`])},` R:v`+L(J.value.base)+L(J.value.isDev?`.dev${J.value.devNumber}`:``),3),V(`span`,{class:x([`glass-card px-2 py-1 text-xs font-medium rounded border transition-all duration-200`,`border-stroke dark:border-dark-border`,X.value.isDev?`text-secondary bg-secondary-bg/20 dark:bg-secondary-bg/10 border-secondary/40`:`text-content-muted`])},` C:v`+L(X.value.base)+L(X.value.isDev?`.dev${X.value.devNumber}`:``),3),de.value?(K(),G(`svg`,{key:0,class:x([`w-3 h-3 text-content-muted transition-transform duration-200`,ue.value?`rotate-180`:``]),fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[...n[13]||=[V(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M19 9l-7 7-7-7`},null,-1)]],2)):A(``,!0)]),ue.value?(K(),G(`div`,Xi,[V(`div`,Zi,[V(`div`,Qi,[n[14]||=V(`span`,{class:`text-content-muted font-medium`},`Repeater:`,-1),V(`span`,$i,`v`+L(J.value.base),1)]),J.value.isDev?(K(),G(`div`,ea,[V(`div`,null,`Dev Build: `+L(J.value.devNumber),1),J.value.commit?(K(),G(`div`,ta,[n[15]||=V(`span`,null,`Commit:`,-1),V(`code`,na,L(J.value.commit),1)])):A(``,!0)])):A(``,!0)]),n[18]||=V(`div`,{class:`border-t border-stroke-subtle dark:border-stroke/20`},null,-1),V(`div`,ra,[V(`div`,ia,[n[16]||=V(`span`,{class:`text-content-muted font-medium`},`Core:`,-1),V(`span`,aa,`v`+L(X.value.base),1)]),X.value.isDev?(K(),G(`div`,oa,[V(`div`,null,`Dev Build: `+L(X.value.devNumber),1),X.value.commit?(K(),G(`div`,sa,[n[17]||=V(`span`,null,`Commit:`,-1),V(`code`,ca,L(X.value.commit),1)])):A(``,!0)])):A(``,!0)])])):A(``,!0)]),n[21]||=V(`div`,{class:`border-t border-accent-green mb-4`},null,-1),s(p).dutyCycleEnabled?(K(),G(`div`,la,[V(`p`,ua,[n[19]||=I(` Duty Cycle: `,-1),V(`span`,da,L(s(p).dutyCycleUtilization.toFixed(1))+`% / `+L(s(p).dutyCycleMax.toFixed(1))+`%`,1)]),V(`div`,fa,[V(`div`,{class:`h-full rounded-full transition-all duration-300`,style:w(he.value)},null,4)])])):A(``,!0),V(`p`,pa,`Last Updated: `+L(ce.value),1),n[22]||=V(`div`,{class:`flex flex-col items-center justify-center mt-4`},[V(`p`,{class:`text-content-muted text-[10px] mb-1 tracking-wide uppercase opacity-70`},` Powered by `),V(`img`,{src:`/assets/meshcore-DQNtEl5I.svg`,alt:`MeshCore`,class:`h-4 opacity-70 dark:invert-0 invert`})],-1)])],2)],2),M(In,{isOpen:_.value,isLoading:m.value,isSuccess:y.value,error:b.value,onClose:re,onSend:W},null,8,[`isOpen`,`isLoading`,`isSuccess`,`error`])],64))}}),ha=[`aria-label`,`title`],ga={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`},_a={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`},va=T({__name:`ThemeToggle`,setup(e){let{theme:t,toggleTheme:n}=_e();return(e,r)=>(K(),G(`button`,{onClick:r[0]||=(...e)=>s(n)&&s(n)(...e),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":s(t)===`dark`?`Switch to light mode`:`Switch to dark mode`,title:s(t)===`dark`?`Switch to light mode`:`Switch to dark mode`},[s(t)===`dark`?(K(),G(`svg`,ga,[...r[1]||=[V(`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)]])):(K(),G(`svg`,_a,[...r[2]||=[V(`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,ha))}}),ya={class:`flex items-center justify-between p-6 pb-0 shrink-0`},ba={class:`p-6 space-y-5 overflow-y-auto flex-1`},xa={class:`grid grid-cols-2 gap-3`},Sa={class:`bg-background-mute dark:bg-background-mute rounded-xl p-3 border border-stroke-subtle dark:border-stroke/10`},Ca={class:`text-sm font-mono font-medium text-content-primary dark:text-content-primary`},wa={key:0,class:`flex items-center gap-1.5 mt-1`},Ta={key:0,class:`flex items-start gap-3 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 rounded-xl p-3`},Ea={class:`text-xs text-amber-800 dark:text-amber-300`},Da={class:`font-mono font-semibold`},Oa={key:1,class:`flex items-center gap-2 bg-green-50 dark:bg-surface border border-green-200 dark:border-accent-green/30 border-l-2 border-l-green-600 dark:border-l-accent-green rounded-xl p-3 text-sm text-green-800 dark:text-content-primary`},ka={key:2,class:`space-y-1`},Aa={class:`flex items-center gap-1`},ja={key:0,class:`w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin inline-block`},Ma={key:1,class:`text-content-muted`},Na={key:0,class:`bg-background-mute dark:bg-black/30 rounded-xl border border-stroke-subtle dark:border-stroke/10 overflow-hidden`},Pa={class:`max-h-52 overflow-y-auto divide-y divide-stroke-subtle dark:divide-stroke/10`},Fa=[`href`],Ia={class:`font-mono text-[10px] text-content-muted shrink-0 mt-0.5 pt-px`},La={class:`min-w-0 flex-1`},Ra={class:`text-xs text-content-primary truncate group-hover:text-primary transition-colors`},za={class:`text-[10px] text-content-muted mt-0.5`},Ba={class:`space-y-2`},Va={class:`flex gap-2`},Ha=[`disabled`],Ua=[`value`],Wa=[`disabled`],Ga={key:0,class:`text-xs text-accent-green`},Ka={key:1,class:`text-xs text-accent-red`},qa={key:3,class:`space-y-2`},Ja={class:`flex items-center justify-between`},Ya={key:0,class:`flex items-center gap-1 text-xs text-primary`},Xa={key:1,class:`flex items-center gap-1 text-xs text-primary`},Za={key:2,class:`flex items-center gap-1 text-xs text-yellow-500`},Qa={key:3,class:`text-xs text-accent-green font-medium`},$a={key:4,class:`text-xs text-accent-red font-medium`},eo={key:0,class:`w-2 h-4 bg-green-400 animate-pulse inline-block ml-1`},to={key:1,class:`flex items-center gap-2 mt-2 text-primary`},no={key:2,class:`flex items-center gap-2 mt-2 text-yellow-400`},ro={key:3,class:`text-content-muted animate-pulse`},io={key:0,class:`text-xs text-accent-red`},ao={key:4,class:`flex items-center gap-3 bg-primary/5 dark:bg-primary/10 border border-primary/20 rounded-xl p-3 text-sm text-primary`},oo={key:5},so={class:`flex items-center gap-3 bg-yellow-50 dark:bg-yellow-500/10 border border-yellow-200 dark:border-yellow-500/30 rounded-xl p-3 text-sm text-yellow-800 dark:text-yellow-400`},co={class:`font-medium`},lo={class:`text-xs opacity-70 mt-0.5`},uo={key:6,class:`bg-green-50 dark:bg-surface-elevated border border-green-200 dark:border-accent-green/40 rounded-xl p-4`},fo={class:`flex items-center gap-3`},po={class:`text-xs text-gray-600 dark:text-content-muted mt-0.5`},mo={class:`font-mono font-semibold`},ho={key:7,class:`bg-red-50 dark:bg-accent-red/10 border border-accent-red/40 rounded-xl p-4 space-y-3`},go={class:`flex items-center gap-3`},_o={class:`flex-1 min-w-0`},vo={class:`text-xs text-accent-red/80 mt-0.5`},yo={key:0,class:`grid grid-cols-2 gap-2 text-xs`},bo={class:`bg-white/50 dark:bg-black/20 rounded-lg px-3 py-2`},xo={class:`font-mono font-semibold text-content-primary`},So={class:`bg-white/50 dark:bg-black/20 rounded-lg px-3 py-2`},Co={class:`font-mono font-semibold text-accent-red`},wo={class:`p-6 pt-0 flex items-center gap-3 shrink-0`},To=[`disabled`],Eo={key:0,class:`flex items-center justify-center gap-2`},Do={key:1,class:`flex items-center justify-center gap-2`},Oo={key:2},ko=T({__name:`UpdateModal`,props:{show:{type:Boolean},currentVersion:{default:``},latestVersion:{default:``},hasUpdate:{type:Boolean,default:!1},rateLimitUntil:{default:null}},emits:[`close`,`installed`,`version-updated`],setup(e,{emit:t}){let r=e,a=t,o=q(r.currentVersion),s=q(r.latestVersion),c=q(r.hasUpdate);k(()=>r.currentVersion,e=>{o.value=e}),k(()=>r.latestVersion,e=>{s.value=e}),k(()=>r.hasUpdate,e=>{c.value=e});let l=q([`main`]),u=q(`main`),d=q(``),f=q(``),p=q(!1),m=q(!1),h=q([]),g=q(!1),_=q(!0),y=q(`idle`),b=q(null),S=q([]),C=q(null),w=q(!1),T=q(null),E=q(null),O=null,j=q(!1),M=null,te=P(()=>y.value===`idle`||y.value===`error`||y.value===`verify-failed`),ne=P(()=>{switch(y.value){case`installing`:return`Installing…`;case`restarting`:return`Restarting…`;case`verified`:return`Installed ✓`;case`verify-failed`:return`Retry Install`;case`complete`:return`Installed ✓`;case`error`:return`Retry Install`;default:return c.value?`Install Update`:`Force Reinstall`}});function N(){C.value&&(C.value.scrollTop=C.value.scrollHeight)}function F(e){S.value.push(e),S.value.length>500&&S.value.splice(0,S.value.length-500),setTimeout(N,20)}function R(){O&&=(O.close(),null)}async function B(){g.value=!0,h.value=[];try{let e=await Y.get(`/update/changelog`);e.success&&Array.isArray(e.commits)&&(h.value=e.commits)}catch{}finally{g.value=!1}}async function H(){p.value=!0,f.value=``;try{let e=await Y.get(`/update/channels`);e.success&&Array.isArray(e.channels)&&(l.value=e.channels,u.value=e.current_channel??`main`)}catch{l.value=[`main`],f.value=`Could not load channels from GitHub`}finally{p.value=!1}}async function U(){if(u.value){d.value=``,f.value=``;try{let e=await Y.post(`/update/set_channel`,{channel:u.value});if(!e.success){f.value=e.error??`Failed to set channel`;return}d.value=`Switched to '${u.value}' — checking version…`,y.value=`idle`,b.value=null,S.value=[],m.value=!0,s.value=``,c.value=!1;try{await Y.post(`/update/check`);for(let e=0;e<24;e++){let e=await Y.get(`/update/status`);if(e.success&&e.state!==`checking`){o.value=e.current_version??o.value,s.value=e.latest_version??``,c.value=!!e.has_update,d.value=`Switched to '${u.value}'`,a(`version-updated`,{currentVersion:o.value,latestVersion:s.value,hasUpdate:c.value}),B();break}await new Promise(e=>setTimeout(e,500))}}catch{d.value=`Switched to '${u.value}' (version check failed)`}finally{m.value=!1}}catch(e){f.value=e?.message??`Failed to set channel`}}}async function W(){if(!te.value)return;y.value=`installing`,b.value=null,S.value=[];try{let e=await Y.post(`/update/install`,{force:!c.value});if(!e.success){y.value=`error`,b.value=e.error??`Failed to start install`;return}}catch(e){y.value=`error`,b.value=e?.message??`Network error`;return}R();let e=ue(),t=e?`/api/update/progress?token=${encodeURIComponent(e)}`:`/api/update/progress`;O=new EventSource(t),O.onmessage=e=>{try{let t=JSON.parse(e.data);switch(t.type){case`line`:{let e=t.line??``;F(e),e.includes(`Restarting service`)&&(j.value=!0,M||=setTimeout(()=>{M=null,(y.value===`installing`||y.value===`complete`)&&(R(),y.value=`restarting`,F(`[pyMC updater] Service is restarting — waiting for it to come back…`),re())},8e3));break}case`status`:t.state===`error`?y.value=`error`:t.state===`complete`&&(j.value=!0,y.value=`complete`);break;case`done`:R(),M&&=(clearTimeout(M),null),t.state===`complete`?(y.value=`restarting`,re()):(y.value=`error`,t.error&&(b.value=t.error));break}}catch{}},O.onerror=()=>{if(R(),M&&=(clearTimeout(M),null),j.value&&y.value!==`error`){y.value=`restarting`,F(`[pyMC updater] Connection lost — waiting for service restart…`),re();return}y.value===`installing`&&(y.value=`error`,b.value=`Progress stream disconnected`)}}async function re(){let e=s.value;E.value=`going-down`;let t=Date.now()+2e4,n=!1;for(;Date.now()setTimeout(e,1e3));try{await Y.get(`/update/status`)}catch{n=!0;break}}n||F(`[pyMC updater] Service did not appear to stop — assuming fast restart`),E.value=`coming-up`;let r=Date.now()+6e4;for(;Date.now()setTimeout(e,2e3));try{let t=await Y.get(`/update/status`);if(!t?.success)continue;E.value=`verifying`,await new Promise(e=>setTimeout(e,1200));let n=t.current_version??``;T.value=n,o.value=n||o.value,a(`version-updated`,{currentVersion:o.value,latestVersion:s.value,hasUpdate:!!t.has_update}),n&&e&&n===e?(y.value=`verified`,c.value=!1,E.value=null,a(`installed`)):(y.value=`verify-failed`,E.value=null);return}catch{}}y.value=`verify-failed`,E.value=null,b.value=`Service did not respond after restart — check logs`}k(()=>r.show,e=>{e?(y.value=`idle`,b.value=null,S.value=[],w.value=!1,T.value=null,E.value=null,j.value=!1,M&&=(clearTimeout(M),null),d.value=``,f.value=``,o.value=r.currentVersion,s.value=r.latestVersion,c.value=r.hasUpdate,H(),B()):R()}),n(()=>{R(),M&&=(clearTimeout(M),null)});function ie(e){e.target===e.currentTarget&&y.value!==`installing`&&y.value!==`restarting`&&a(`close`)}function ae(){window.location.reload()}return(e,t)=>(K(),v(D,{to:`body`},[r.show?(K(),G(`div`,{key:0,class:`fixed inset-0 bg-black/50 backdrop-blur-sm z-[99999] flex items-center justify-center p-4`,onClick:ie},[V(`div`,{class:`bg-white dark:bg-surface-elevated rounded-[20px] w-full max-w-lg border border-stroke-subtle dark:border-white/10 shadow-2xl flex flex-col max-h-[90vh]`,onClick:t[5]||=It(()=>{},[`stop`])},[V(`div`,ya,[t[7]||=V(`div`,{class:`flex items-center gap-3`},[V(`div`,{class:`w-10 h-10 rounded-xl bg-primary/10 flex items-center justify-center`},[V(`svg`,{class:`w-5 h-5 text-primary`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[V(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12`})])]),V(`div`,null,[V(`h3`,{class:`text-lg font-semibold text-content-primary dark:text-content-primary`},` OTA Update `),V(`p`,{class:`text-xs text-content-muted dark:text-content-muted`},` Update over the air from GitHub `)])],-1),y.value!==`installing`&&y.value!==`restarting`?(K(),G(`button`,{key:0,onClick:t[0]||=e=>a(`close`),class:`text-content-secondary hover:text-content-primary transition-colors`},[...t[6]||=[V(`svg`,{class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[V(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`})],-1)]])):A(``,!0)]),V(`div`,ba,[V(`div`,xa,[V(`div`,Sa,[t[8]||=V(`p`,{class:`text-xs text-content-muted mb-1`},`Installed`,-1),V(`p`,Ca,L(o.value||`—`),1)]),V(`div`,{class:x([`bg-background-mute dark:bg-background-mute rounded-xl p-3 border border-stroke-subtle dark:border-stroke/10`,c.value?`border-l-2 border-l-accent-red`:`border-l-2 border-l-accent-green`])},[t[10]||=V(`p`,{class:`text-xs text-content-muted mb-1`},`On GitHub`,-1),m.value?(K(),G(`div`,wa,[...t[9]||=[V(`span`,{class:`w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin inline-block`},null,-1),V(`span`,{class:`text-xs text-content-muted`},`Checking…`,-1)]])):(K(),G(`p`,{key:1,class:x([`text-sm font-mono font-medium`,c.value?`text-accent-red`:`text-accent-green`])},L(s.value||`—`),3))],2)]),r.rateLimitUntil?(K(),G(`div`,Ta,[t[14]||=V(`svg`,{class:`w-4 h-4 shrink-0 mt-0.5 text-amber-600 dark:text-amber-400`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[V(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z`})],-1),V(`div`,Ea,[t[13]||=V(`p`,{class:`font-semibold mb-0.5`},`GitHub API rate limit reached`,-1),V(`p`,null,[t[11]||=I(` Version checks are paused until `,-1),V(`span`,Da,L(new Date(r.rateLimitUntil).toLocaleTimeString([],{hour:`2-digit`,minute:`2-digit`})),1),t[12]||=I(`. This is a GitHub limit, not a software issue. You can still install or switch channels manually. `,-1)])])])):A(``,!0),!c.value&&o.value&&!m.value?(K(),G(`div`,Oa,[...t[15]||=[V(`svg`,{class:`w-4 h-4 shrink-0`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[V(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`})],-1),I(` You are up to date. Use `,-1),V(`em`,{class:`mx-1`},`Force Reinstall`,-1),I(` to reinstall anyway. `,-1)]])):A(``,!0),h.value.length>0||g.value?(K(),G(`div`,ka,[V(`button`,{onClick:t[1]||=e=>_.value=!_.value,class:`flex items-center justify-between w-full text-xs font-medium text-content-secondary dark:text-content-secondary uppercase tracking-wide py-1 hover:text-content-primary transition-colors`},[t[17]||=V(`span`,null,`What's New`,-1),V(`span`,Aa,[g.value?(K(),G(`span`,ja)):(K(),G(`span`,Ma,L(h.value.length)+` commit`+L(h.value.length===1?``:`s`),1)),(K(),G(`svg`,{class:x([`w-3.5 h-3.5 text-content-muted transition-transform`,_.value?`rotate-180`:``]),viewBox:`0 0 20 20`,fill:`currentColor`},[...t[16]||=[V(`path`,{"fill-rule":`evenodd`,d:`M5.23 7.21a.75.75 0 011.06.02L10 11.168l3.71-3.938a.75.75 0 111.08 1.04l-4.25 4.5a.75.75 0 01-1.08 0l-4.25-4.5a.75.75 0 01.02-1.06z`,"clip-rule":`evenodd`},null,-1)]],2))])]),_.value?(K(),G(`div`,Na,[V(`div`,Pa,[(K(!0),G(z,null,i(h.value,e=>(K(),G(`a`,{key:e.sha,href:e.url,target:`_blank`,class:`flex gap-3 px-3 py-2.5 hover:bg-background-soft dark:hover:bg-surface/50 transition-colors group`},[V(`span`,Ia,L(e.short_sha),1),V(`div`,La,[V(`p`,Ra,L(e.title),1),V(`p`,za,L(e.author)+` · `+L(e.date?new Date(e.date).toLocaleDateString():``),1)]),t[18]||=V(`svg`,{class:`w-3 h-3 text-content-muted shrink-0 mt-1 opacity-0 group-hover:opacity-100 transition-opacity`,fill:`none`,viewBox:`0 0 24 24`,stroke:`currentColor`},[V(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14`})],-1)],8,Fa))),128))])])):A(``,!0)])):A(``,!0),V(`div`,Ba,[t[19]||=V(`label`,{class:`text-xs font-medium text-content-secondary dark:text-content-secondary uppercase tracking-wide`},` Release Channel `,-1),V(`div`,Va,[ee(V(`select`,{"onUpdate:modelValue":t[2]||=e=>u.value=e,disabled:p.value||y.value===`installing`||m.value,class:`flex-1 bg-background-mute dark:bg-surface border border-stroke-subtle dark:border-stroke/20 rounded-xl px-3 py-2 text-sm text-content-primary dark:text-content-primary disabled:opacity-50 focus:outline-none focus:ring-1 focus:ring-primary`},[(K(!0),G(z,null,i(l.value,e=>(K(),G(`option`,{key:e,value:e},L(e),9,Ua))),128))],8,Ha),[[At,u.value]]),V(`button`,{onClick:U,disabled:p.value||y.value===`installing`||m.value,class:`px-4 py-2 bg-primary/10 hover:bg-primary/20 text-primary rounded-xl text-sm font-medium disabled:opacity-50 transition-colors`},L(p.value||m.value?`…`:`Apply`),9,Wa)]),d.value?(K(),G(`p`,Ga,L(d.value),1)):A(``,!0),f.value?(K(),G(`p`,Ka,L(f.value),1)):A(``,!0),t[20]||=V(`p`,{class:`text-xs text-content-muted`},[V(`em`,null,`main`),I(` = stable releases \xA0|\xA0 `),V(`em`,null,`dev`),I(` = latest commits (may be unstable) `)],-1)]),y.value===`installing`||y.value===`restarting`||S.value.length>0&&(w.value||y.value===`error`)?(K(),G(`div`,qa,[V(`div`,Ja,[t[24]||=V(`label`,{class:`text-xs font-medium text-content-secondary uppercase tracking-wide`},`Install Log`,-1),y.value===`installing`?(K(),G(`span`,Ya,[...t[21]||=[V(`span`,{class:`inline-block w-2 h-2 rounded-full bg-primary animate-pulse`},null,-1),I(` Running… `,-1)]])):y.value===`restarting`&&E.value===`verifying`?(K(),G(`span`,Xa,[...t[22]||=[V(`span`,{class:`inline-block w-2 h-2 rounded-full bg-primary animate-pulse`},null,-1),I(` Checking version… `,-1)]])):y.value===`restarting`?(K(),G(`span`,Za,[t[23]||=V(`span`,{class:`inline-block w-2 h-2 rounded-full bg-yellow-500 animate-pulse`},null,-1),I(` `+L(E.value===`going-down`?`Stopping service…`:`Waiting for service…`),1)])):y.value===`verified`?(K(),G(`span`,Qa,`Complete ✓`)):y.value===`verify-failed`||y.value===`error`?(K(),G(`span`,$a,`Failed ✗`)):A(``,!0)]),V(`div`,{ref_key:`logContainer`,ref:C,class:`bg-zinc-900 dark:bg-black/60 rounded-xl p-3 h-52 overflow-y-auto font-mono text-xs text-green-400 leading-relaxed border border-stroke/20`},[(K(!0),G(z,null,i(S.value,(e,t)=>(K(),G(`div`,{key:t,class:x([`whitespace-pre-wrap break-all`,{"text-accent-red":e.includes(`✗`)||e.includes(`error`)||e.includes(`ERROR`)||e.includes(`Failed`),"text-yellow-400":e.includes(`WARNING`)||e.includes(`⚠`),"text-accent-green":e.includes(`✓`)||e.includes(`Successfully`),"text-content-muted/60":e.includes(`keepalive`)}])},L(e),3))),128)),y.value===`installing`?(K(),G(`div`,eo)):A(``,!0),y.value===`restarting`&&E.value===`verifying`?(K(),G(`div`,to,[...t[25]||=[V(`span`,{class:`w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin inline-block`},null,-1),I(` Service is back — verifying version… `,-1)]])):y.value===`restarting`?(K(),G(`div`,no,[t[26]||=V(`span`,{class:`w-3 h-3 border-2 border-yellow-400 border-t-transparent rounded-full animate-spin inline-block`},null,-1),I(` `+L(E.value===`going-down`?`Waiting for service to stop…`:`Waiting for service to come back up…`),1)])):A(``,!0),S.value.length===0&&y.value===`installing`?(K(),G(`div`,ro,` Waiting for output… `)):A(``,!0)],512),b.value?(K(),G(`p`,io,L(b.value),1)):A(``,!0)])):A(``,!0),y.value===`restarting`&&E.value===`verifying`?(K(),G(`div`,ao,[...t[27]||=[V(`span`,{class:`w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin shrink-0`},null,-1),V(`div`,null,[V(`p`,{class:`font-medium`},`Checking version…`),V(`p`,{class:`text-xs opacity-70 mt-0.5`},` Confirming the installed version matches the target `)],-1)]])):y.value===`restarting`&&S.value.length===0?(K(),G(`div`,oo,[V(`div`,so,[t[28]||=V(`span`,{class:`w-4 h-4 border-2 border-yellow-500 border-t-transparent rounded-full animate-spin shrink-0`},null,-1),V(`div`,null,[V(`p`,co,L(E.value===`going-down`?`Stopping service…`:`Starting service…`),1),V(`p`,lo,L(E.value===`going-down`?`Waiting for the old process to exit`:`Waiting for the service to become healthy`),1)])])])):A(``,!0),y.value===`verified`?(K(),G(`div`,uo,[V(`div`,fo,[t[31]||=V(`div`,{class:`w-9 h-9 rounded-full bg-accent-green flex items-center justify-center shrink-0`},[V(`svg`,{class:`w-5 h-5 text-white`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[V(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2.5`,d:`M5 13l4 4L19 7`})])],-1),V(`div`,null,[t[30]||=V(`p`,{class:`font-semibold text-gray-900 dark:text-content-primary`},` Installed successfully! `,-1),V(`p`,po,[t[29]||=I(` Running version `,-1),V(`span`,mo,L(T.value),1)])])]),V(`button`,{onClick:ae,class:`mt-3 w-full py-2 px-4 rounded-xl text-sm font-semibold text-white bg-primary hover:bg-primary/90 transition-colors`},` Refresh Page to Load New Version `)])):A(``,!0),y.value===`verify-failed`?(K(),G(`div`,ho,[V(`div`,go,[t[33]||=V(`div`,{class:`w-9 h-9 rounded-full bg-accent-red/15 flex items-center justify-center shrink-0`},[V(`svg`,{class:`w-5 h-5 text-accent-red`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},[V(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2.5`,d:`M6 18L18 6M6 6l12 12`})])],-1),V(`div`,_o,[t[32]||=V(`p`,{class:`font-semibold text-accent-red`},`Installation may have failed`,-1),V(`p`,vo,L(b.value||`Version mismatch after restart`),1)])]),T.value||s.value?(K(),G(`div`,yo,[V(`div`,bo,[t[34]||=V(`p`,{class:`text-content-muted mb-0.5`},`Expected`,-1),V(`p`,xo,L(s.value||`—`),1)]),V(`div`,So,[t[35]||=V(`p`,{class:`text-content-muted mb-0.5`},`Reported`,-1),V(`p`,Co,L(T.value||`unknown`),1)])])):A(``,!0),S.value.length>0?(K(),G(`button`,{key:1,onClick:t[3]||=e=>w.value=!w.value,class:`w-full text-xs text-accent-red/70 hover:text-accent-red underline underline-offset-2 hover:no-underline transition-all`},L(w.value?`Hide install log`:`View install log`),1)):A(``,!0)])):A(``,!0)]),V(`div`,wo,[V(`button`,{onClick:W,disabled:!te.value,class:x([`flex-1 py-3 rounded-xl font-semibold text-sm transition-colors disabled:opacity-50 disabled:cursor-not-allowed`,y.value===`verified`||y.value===`complete`?`bg-accent-green/20 text-accent-green cursor-default`:y.value===`error`||y.value===`verify-failed`?`bg-accent-red hover:bg-accent-red/80 text-white`:y.value===`restarting`?`bg-yellow-500/20 text-yellow-600 cursor-default`:`bg-primary hover:bg-primary/80 text-white`])},[y.value===`installing`?(K(),G(`span`,Eo,[...t[36]||=[V(`span`,{class:`w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin`},null,-1),I(` Installing… `,-1)]])):y.value===`restarting`?(K(),G(`span`,Do,[...t[37]||=[V(`span`,{class:`w-4 h-4 border-2 border-yellow-600 border-t-transparent rounded-full animate-spin`},null,-1),I(` Restarting service… `,-1)]])):(K(),G(`span`,Oo,L(ne.value),1))],10,To),y.value!==`installing`&&y.value!==`restarting`?(K(),G(`button`,{key:0,onClick:t[4]||=e=>a(`close`),class:`px-6 py-3 rounded-xl border border-stroke-subtle dark:border-stroke/20 text-content-secondary hover:text-content-primary hover:bg-background-mute transition-colors text-sm`},` Close `)):A(``,!0)])])])):A(``,!0)]))}}),Ao={class:`glass-card p-3 sm:p-6 mb-5 rounded-[20px] relative z-10`},jo={class:`flex justify-between items-center`},Mo={class:`flex items-center gap-3`},No={class:`hidden sm:block`},Po={class:`text-content-primary dark:text-content-primary text-2xl lg:text-[35px] font-bold mb-1 sm:mb-2`},Fo={class:`flex items-center gap-3 sm:gap-4`},Io={class:`text-right`,style:{"min-width":`180px`}},Lo={key:0,class:`flex items-center gap-2 justify-end`},Ro={key:1,class:`space-y-1`},zo={class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},Bo={class:`text-primary font-medium`},Vo={key:0,class:`text-xs text-content-muted dark:text-content-muted/80`,style:{"min-height":`16px`}},Ho={key:0},Uo={key:2},Wo={key:0,class:`text-xs text-content-muted dark:text-content-muted/60 hidden sm:block`,style:{"min-height":`16px`}},Go={key:0,class:`absolute right-0 top-10 z-[100] w-48 bg-surface dark:bg-surface-elevated border border-stroke-subtle dark:border-stroke/20 rounded-xl shadow-2xl overflow-hidden`},Ko=[`disabled`],qo={key:0,class:`w-4 h-4 text-content-secondary`,viewBox:`0 0 20 20`,fill:`none`,stroke:`currentColor`,"stroke-width":`1.5`,xmlns:`http://www.w3.org/2000/svg`},Jo={key:1,class:`animate-spin rounded-full h-4 w-4 border-b-2 border-primary`},Yo={class:`flex items-center justify-between mb-3`},Xo={class:`flex items-center gap-2`},Zo=[`disabled`],Qo=[`disabled`],$o={class:`space-y-3 text-sm`},es={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`},ts={class:`flex items-center justify-between`},ns={class:`text-accent-red font-bold`},rs={class:`text-xs text-content-muted dark:text-content-muted mt-1`},is={class:`mt-2 flex items-center gap-2`},as=[`disabled`],os={key:1,class:`flex items-start gap-2 bg-amber-50 dark:bg-amber-500/10 border border-amber-200 dark:border-amber-500/30 border-l-2 border-l-amber-500 rounded-lg p-3 text-xs text-amber-800 dark:text-amber-300`},ss={key:2,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`},cs={class:`flex items-center justify-between`},ls={class:`text-accent-green font-bold`},us={key:0,class:`text-xs text-content-muted dark:text-content-muted mt-1`},ds={class:`mt-2 flex items-center gap-2`},fs=[`disabled`],ps={key:3,class:`bg-background-mute dark:bg-background-mute p-3 rounded-lg border border-stroke-subtle dark:border-stroke/10`},ms={key:4,class:`bg-red-50 dark:bg-background-mute p-3 rounded-lg border border-accent-red/30 border-l-2 border-l-accent-red`},hs={class:`text-xs text-content-secondary dark:text-content-muted`},gs={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`},_s={class:`flex items-center justify-between`},vs={class:`text-primary font-bold`},ys={key:0,class:`text-xs text-content-muted dark:text-content-muted mt-1`},bs={class:`flex items-center justify-between`},xs={class:`text-content-primary dark:text-content-primary font-medium`},Ss={key:0,class:`mt-2`},Cs={class:`text-xs text-content-muted dark:text-content-muted`},ws={class:`text-content-secondary dark:text-content-secondary`},Ts={key:5,class:`bg-background-mute dark:bg-background-mute p-4 rounded-lg border border-stroke-subtle dark:border-stroke/10 text-center`},Es={key:6,class:`bg-background-mute dark:bg-background-mute p-3 rounded-lg border border-stroke-subtle dark:border-stroke/10 text-center`},Ds={key:0,class:`fixed inset-0 z-[9999] bg-black/60 backdrop-blur-sm flex items-center justify-center`},Os={class:`bg-surface dark:bg-surface-elevated rounded-2xl p-8 shadow-2xl max-w-sm w-full mx-4 text-center border border-stroke-subtle dark:border-stroke/20`},ks={key:0,class:`mb-4`},As={key:1,class:`mb-4`},js={class:`text-sm text-content-secondary dark:text-content-muted`},Ms={key:2,class:`mt-4 flex items-center justify-center gap-3`},Ns=Z(T({name:`TopBar`,__name:`TopBar`,emits:[`toggleMobileSidebar`],setup(e,{emit:t}){let n=t;oe();let r=me(),a=J(),o=q(!1),s=q(null),c=q(!1),l=q(!1),d=q(null),f=q(!1),p=q(``),m=q(!1),h=q({hasUpdate:!1,currentVersion:``,latestVersion:``,isChecking:!1,lastChecked:null,error:null,rateLimitUntil:null}),g=q({}),_=q(!0),y=q(null),b=q(fe()||`User`),S=[`Chat Node`,`Repeater`,`Room Server`];function w(e){let t=e.target;s.value&&!s.value.contains(t)&&(o.value=!1),d.value&&!d.value.contains(t)&&(l.value=!1)}let T=async()=>{try{_.value=!0;let e={};for(let t of S)try{let n=await Y.get(`/adverts_by_contact_type?contact_type=${encodeURIComponent(t)}&hours=168`);n.success&&Array.isArray(n.data)?e[t]=n.data:e[t]=[]}catch(n){console.error(`Error fetching ${t} nodes:`,n),e[t]=[]}g.value=e,y.value=new Date}catch(e){console.error(`Error updating tracked nodes:`,e)}finally{_.value=!1}},E=async(e=!1)=>{if(!h.value.isChecking)try{h.value.isChecking=!0,h.value.error=null,await Y.post(`/update/check`,e?{force:!0}:{});for(let e=0;e<20;e++){let e=await Y.get(`/update/status`);if(e.success&&e.state!==`checking`){h.value.currentVersion=e.current_version??``,h.value.latestVersion=e.latest_version??``,h.value.hasUpdate=!!e.has_update,h.value.lastChecked=new Date,h.value.error=e.error??null,h.value.rateLimitUntil=e.rate_limit_until??null;return}await new Promise(e=>setTimeout(e,500))}h.value.error=`Version check timed out`}catch(e){console.error(`Error checking for updates:`,e),h.value.error=e instanceof Error?e.message:`Failed to check for updates`}finally{h.value.isChecking=!1}},O=()=>{o.value=!1,E(),r.fetchStats()},ee=e=>{h.value.currentVersion=e.currentVersion,h.value.latestVersion=e.latestVersion,h.value.hasUpdate=e.hasUpdate,h.value.lastChecked=new Date},k=()=>{a.stopSession(`logout`)},j=async(e=20,t=2e3)=>{for(let n=0;nsetTimeout(e,t))}return!1},te=async()=>{if(!f.value){f.value=!0,m.value=!1,p.value=`Sending restart request...`,l.value=!1;try{let e=await Y.post(`/restart_service`,{});e.success?(p.value=`Service restarting, waiting for it to come back...`,await j()?(p.value=`Service is back! Reloading...`,setTimeout(()=>{window.location.reload()},500)):(p.value=`Service did not respond in time. Try reloading manually.`,m.value=!0)):(p.value=e.error||`Restart request failed`,m.value=!0)}catch(e){e.code===`ERR_NETWORK`||e.message?.includes(`Network error`)||e.response?.status===500||e.message?.includes(`500`)?(p.value=`Service restarting, waiting for it to come back...`,await j()?(p.value=`Service is back! Reloading...`,setTimeout(()=>{window.location.reload()},500)):(p.value=`Service did not respond in time. Try reloading manually.`,m.value=!0)):(p.value=e.message||`Restart request failed`,m.value=!0)}}},ne=()=>{f.value=!1,p.value=``,m.value=!1},N=()=>{window.location.reload()},F=P(()=>Object.values(g.value).reduce((e,t)=>e+t.length,0)),R=P(()=>S.map(e=>({type:e,count:g.value[e]?.length||0})).filter(e=>e.count>0)),B=P(()=>!0),H=e=>({"Chat Node":`text-blue-600 dark:text-blue-400`,Repeater:`text-accent-green`,"Room Server":`text-accent-purple`})[e]||`text-gray-400`,U=e=>{let t=g.value[e]||[];return t.length===0?`None`:t.reduce((e,t)=>t.last_seen>e.last_seen?t:e,t[0]).node_name||`Unknown Node`};u(()=>{document.addEventListener(`click`,w),T(),E()}),ie(()=>{document.removeEventListener(`click`,w)}),qt(T,{intervalMs:3e4,enabled:!0,immediate:!1}),qt(()=>E(),{intervalMs:6e5,enabled:!0,immediate:!1});let W=()=>{n(`toggleMobileSidebar`)};return(e,t)=>(K(),G(z,null,[V(`div`,Ao,[V(`div`,jo,[V(`div`,Mo,[V(`button`,{onClick:W,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`},[...t[10]||=[V(`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`},[V(`path`,{d:`M3 6h14M3 10h14M3 14h14`,stroke:`currentColor`,"stroke-width":`1.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`})],-1)]]),V(`div`,No,[V(`h1`,Po,` Hi `+L(b.value)+`👋 `,1)])]),V(`div`,Fo,[V(`div`,Io,[_.value?(K(),G(`div`,Lo,[...t[11]||=[V(`div`,{class:`animate-spin rounded-full h-3 w-3 border-b-2 border-primary`},null,-1),V(`p`,{class:`text-content-secondary dark:text-content-muted text-xs sm:text-sm`},` Loading... `,-1)]])):F.value>0?(K(),G(`div`,Ro,[V(`p`,zo,[t[12]||=I(` Tracking: `,-1),V(`span`,Bo,L(F.value)+` node`+L(F.value===1?``:`s`),1)]),R.value.length>0?(K(),G(`div`,Vo,[(K(!0),G(z,null,i(R.value,(e,t)=>(K(),G(`span`,{key:e.type,class:`inline`},[I(L(e.count)+` `+L(e.type)+L(e.count===1?``:`s`),1),t