merge: reconcile companion cleanup with fix-general-tidy

Merge the maintainer's fix-general-tidy branch (neighbor discovery,
keygen, API endpoints, web-asset rebuild, HTTP server config/control
commands, and an independent #286 room-server push/ACK/guest fix) into
the companion cleanup branch.

Both branches fixed #286 in parallel with byte-identical push-ACK CRC
logic. In the six overlapping files the maintainer's implementation is
kept (ACL replay-detection/session helpers, encoded path-len with legacy
fallback, expected_crc/ack_timeout_s injector API, dispatcher ACK
helpers); this branch's unique companion work is preserved on top
(sender_prefix persistence + migration, boot-state hardening /
CompanionStateLoadError, MessageQueue.max_size, older-core fallbacks).

Conflict resolution took the maintainer's side across the overlap, then
fixed two integration seams the merge introduced and updated this
branch's tests to the maintainer's API:
- room_server: timeout used undefined `hops`; aligned to `path_len`.
- packet_router: PATH helper was invoked twice (maintainer's
  unconditional call plus this branch's conditional local-identity
  call); dropped the now-redundant conditional block.

Pin openhop_core to @dev (was @feature/publish-workflow-message-handling)
so this can merge to the repeater's dev; core dev carries the required
sender_prefix and PathUtils.is_valid_path_len APIs.

Full suite green (1040 passed) against openhop_core dev and
refactor/companion-housekeeping; ruff clean.
This commit is contained in:
agessaman
2026-07-07 12:56:43 -07:00
120 changed files with 1881 additions and 350 deletions
+22
View File
@@ -611,6 +611,28 @@ logging:
# Log format
format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# HTTP server configuration (web UI + API)
http:
# Enable/disable the embedded HTTP server at runtime.
# Changes are applied live via ConfigManager (no daemon restart required).
enabled: true
# Listen address and port for the web UI/API server.
host: "0.0.0.0"
port: 8000
# CherryPy worker/thread tuning.
# thread_pool is the initial worker count (minimum enforced by server: 2).
thread_pool: 8
# thread_pool_max is the max worker count (always >= thread_pool).
thread_pool_max: 16
# Socket tuning.
# socket_timeout closes idle sockets so worker threads can be reused (min: 15 seconds).
socket_timeout: 65
# socket_queue_size controls pending TCP connection backlog (min: 10).
socket_queue_size: 100
# Web interface configuration
web:
# Enable Cross-Origin Resource Sharing (CORS) headers
+2 -2
View File
@@ -29,7 +29,7 @@ keywords = ["mesh", "networking", "lora", "repeater", "daemon", "iot"]
dependencies = [
"openhop_core[hardware]",
"openhop_core[hardware] @ git+https://github.com/openhop-dev/openhop_core.git@dev",
"pyyaml>=6.0.0",
"cherrypy>=18.0.0",
"paho-mqtt>=1.6.0",
@@ -45,7 +45,7 @@ dependencies = [
[project.optional-dependencies]
# SX1262/SPI support (Linux only; required for Raspberry Pi HATs)
hardware = [
"openhop_core[hardware]",
"openhop_core[hardware] @ git+https://github.com/openhop-dev/openhop_core.git@dev",
]
# RRD metrics (Performance Metrics chart); system librrd required (e.g. apt install rrdtool)
rrd = [
+39 -19
View File
@@ -133,35 +133,39 @@
"zebra-duo-hat-r0": {
"name": "ZebraHatDuo-R0-1W",
"bus_id": 0,
"busy_pin": 23,
"cs_id": 0,
"cs_pin": -1,
"reset_pin": 18,
"busy_pin": 23,
"irq_pin": 24,
"txen_pin": -1,
"dio3_tcxo_voltage": 1.8,
"en_pin": -1,
"irq_pin": 18,
"is_waveshare": false,
"reset_pin": 24,
"rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 18,
"use_dio3_tcxo": true,
"txen_pin": -1,
"txled_pin": -1,
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"preamble_length": 32
},
"zebra-duo-hat-r1": {
"name": "ZebraHatDuo-R1-1W",
"bus_id": 0,
"busy_pin": 27,
"cs_id": 1,
"cs_pin": -1,
"reset_pin": 17,
"busy_pin": 27,
"dio3_tcxo_voltage": 1.8,
"en_pin": -1,
"irq_pin": 22,
"txen_pin": -1,
"is_waveshare": false,
"reset_pin": 17,
"rxen_pin": -1,
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 18,
"use_dio3_tcxo": true,
"txen_pin": -1,
"txled_pin": -1,
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"preamble_length": 32
},
"femtofox-1W-SX": {
@@ -312,9 +316,7 @@
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 32,
"use_gpiod_backend": true,
"gpio_chip": 1
"preamble_length": 32
},
"rak6421-13300x-slot2": {
"name": "Rak Wireless RAK6421 with RAK1330x on IO Slot 2",
@@ -333,9 +335,27 @@
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 32,
"use_gpiod_backend": true,
"gpio_chip": 1
"preamble_length": 32
},
"rak6421-13300x-slot2": {
"name": "Rak Wireless RAK6421 with RAK1330x on IO Slot 2",
"description": "Use this radio settings preset if you have modded your pi hat to work with dual radios see https://discord.com/channels/1495203904898728149/1512614022913200159 .",
"bus_id": 0,
"cs_id": 1,
"cs_pin": -1,
"reset_pin": 27,
"busy_pin": 19,
"irq_pin": 18,
"txen_pin": -1,
"rxen_pin": -1,
"en_pins": [26, 23],
"txled_pin": -1,
"rxled_pin": -1,
"tx_power": 22,
"use_dio2_rf": true,
"use_dio3_tcxo": true,
"dio3_tcxo_voltage": 1.8,
"preamble_length": 32
},
"ultrapeaterzero-e22": {
"name": "Zindello Industries UltraPeaterZero E22",
+12 -2
View File
@@ -230,6 +230,13 @@ def load_config(config_path: Optional[str] = None) -> Dict[str, Any]:
if "mesh" not in config:
config["mesh"] = {}
if "http" not in config:
config["http"] = {
"enabled": True,
"host": "0.0.0.0",
"port": 8000,
}
if "glass" not in config:
config["glass"] = {
"enabled": False,
@@ -422,8 +429,11 @@ def _load_or_create_identity_key(path: Optional[str] = None) -> bytes:
except Exception as e:
logger.warning(f"Failed to load identity key: {e}")
# Generate new random key
key = os.urandom(32)
# Generate new key via MeshCore-compatible keygen; store 32-byte private scalar.
from repeater.keygen import generate_meshcore_keypair
_, private_key = generate_meshcore_keypair()
key = private_key[:32]
# Save it
try:
+52 -2
View File
@@ -1,7 +1,8 @@
import logging
import os
from typing import Any, Dict, List, Optional
import yaml
from typing import Optional, Dict, Any, List
logger = logging.getLogger("ConfigManager")
@@ -145,6 +146,52 @@ class ConfigManager:
logger.error(f"Failed to apply live radio config: {e}", exc_info=True)
return False
@staticmethod
def _parse_bool(value: Any, default: bool = True) -> bool:
if value is None:
return default
if isinstance(value, str):
return value.strip().lower() in {"1", "true", "yes", "on"}
return bool(value)
def _apply_live_http_config(self) -> bool:
if not self.daemon:
logger.warning("Daemon not available for HTTP live update")
return False
http_server = getattr(self.daemon, "http_server", None)
if http_server is None:
# Early in daemon lifecycle, there is nothing to control yet.
logger.info("HTTP server not initialized yet; skipping live HTTP update")
return True
http_cfg = self.config.get("http", {}) if isinstance(self.config, dict) else {}
enabled = self._parse_bool(http_cfg.get("enabled", True), default=True)
host = str(http_cfg.get("host", "0.0.0.0") or "0.0.0.0")
try:
port = int(http_cfg.get("port", 8000))
except (TypeError, ValueError):
logger.warning("Invalid http.port=%r, falling back to 8000", http_cfg.get("port"))
port = 8000
# Keep runtime server settings aligned with config before start/restart.
http_server.host = host
http_server.port = port
from repeater.service_utils import start_http_server, stop_http_server
if enabled:
success, message = start_http_server(self.daemon)
else:
success, message = stop_http_server(self.daemon)
if success:
logger.info("Applied live HTTP config: %s", message)
else:
logger.warning("Failed live HTTP config apply: %s", message)
return success
def save_to_file(self) -> bool:
"""
Save current config to YAML file.
@@ -193,7 +240,7 @@ class ConfigManager:
# Default sections to update if not specified
if sections is None:
sections = ["repeater", "delays", "radio", "acl", "identities", "glass"]
sections = ["repeater", "delays", "radio", "acl", "identities", "glass", "http"]
# Update each section
for section in sections:
@@ -247,6 +294,9 @@ class ConfigManager:
if "radio" in sections:
live_update_ok = self._apply_live_radio_config() and live_update_ok
if "http" in sections:
live_update_ok = self._apply_live_http_config() and live_update_ok
return live_update_ok
except Exception as e:
+47 -14
View File
@@ -696,7 +696,7 @@ class SQLiteHandler:
except Exception:
fwd_path_val = str(fwd_path)
conn.execute(
cursor = conn.execute(
"""
INSERT INTO packets (
timestamp, type, route, length, rssi, snr, score,
@@ -739,6 +739,7 @@ class SQLiteHandler:
),
)
self._invalidate_hot_caches()
return cursor.lastrowid
except Exception as e:
logger.error(f"Failed to store packet in SQLite: {e}")
@@ -1017,6 +1018,7 @@ class SQLiteHandler:
packets = conn.execute(
"""
SELECT
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
transport_codes, payload, payload_length,
@@ -1069,6 +1071,7 @@ class SQLiteHandler:
base_query = """
SELECT
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
transport_codes, payload, payload_length,
@@ -1201,6 +1204,7 @@ class SQLiteHandler:
packet = conn.execute(
"""
SELECT
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
header, transport_codes, payload, payload_length,
@@ -1218,6 +1222,32 @@ class SQLiteHandler:
logger.error(f"Failed to get packet by hash: {e}")
return None
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
try:
with self._connect() as conn:
conn.row_factory = sqlite3.Row
packet = conn.execute(
"""
SELECT
id,
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,
lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy
FROM packets
WHERE id = ?
""",
(packet_id,),
).fetchone()
return dict(packet) if packet else None
except Exception as e:
logger.error(f"Failed to get packet by id: {e}")
return None
def get_packet_type_stats(self, hours: int = 24) -> dict:
try:
now = time.time()
@@ -2164,29 +2194,32 @@ class SQLiteHandler:
return []
def upsert_client_sync(self, room_hash: str, client_pubkey: str, **kwargs) -> bool:
"""Insert or update client sync state using single upsert operation."""
"""Insert or update client sync state without clobbering unspecified fields."""
try:
with self._connect() as conn:
now = time.time()
kwargs["updated_at"] = now
update_fields = dict(kwargs)
update_fields["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)
# INSERT must satisfy NOT NULL columns (last_activity), while
# ON CONFLICT updates should only touch supplied fields.
insert_fields = dict(update_fields)
if insert_fields.get("last_activity") is None:
insert_fields["last_activity"] = now
columns = ["room_hash", "client_pubkey"] + list(kwargs.keys())
columns = ["room_hash", "client_pubkey"] + list(insert_fields.keys())
placeholders = ["?"] * len(columns)
values = [room_hash, client_pubkey] + list(kwargs.values())
values = [room_hash, client_pubkey] + list(insert_fields.values())
# Use INSERT OR REPLACE for single atomic upsert
# Update only supplied columns on conflict so partial updates don't
# reset counters/state such as push_failures.
update_set = ", ".join(f"{col}=excluded.{col}" for col in update_fields.keys())
conn.execute(
f"""
INSERT OR REPLACE INTO room_client_sync ({", ".join(columns)})
INSERT INTO room_client_sync ({", ".join(columns)})
VALUES ({", ".join(placeholders)})
ON CONFLICT(room_hash, client_pubkey)
DO UPDATE SET {update_set}
""",
values,
)
+16 -5
View File
@@ -39,11 +39,15 @@ class StorageCollector:
self.sqlite_handler = SQLiteHandler(self.storage_dir)
self.rrd_handler = RRDToolHandler(self.storage_dir)
# Initialize MQTT handler if configured
# Initialize MQTT handler only when at least one broker is configured
self.mqtt_handler = None
if (
config.get("mqtt_brokers", {}) or config.get("letsmesh", {}) or config.get("mqtt", {})
) and local_identity:
mqtt_brokers_config = config.get("mqtt_brokers", {}) or {}
letsmesh_config = config.get("letsmesh", {}) or {}
mqtt_config = config.get("mqtt", {}) or {}
has_brokers_configured = (
bool(mqtt_brokers_config.get("brokers")) or bool(letsmesh_config) or bool(mqtt_config)
)
if has_brokers_configured and local_identity:
try:
# Pass local_identity directly (supports both standard and firmware keys)
self.mqtt_handler = MeshCoreToMqttPusher(
@@ -58,6 +62,8 @@ class StorageCollector:
except Exception as e:
logger.error(f"Failed to initialize MQTT handler: {e}")
self.mqtt_handler = None
else:
logger.info("MQTT handler disabled - no brokers configured")
# Initialize hardware stats collector
from .hardware_stats import HardwareStatsCollector
@@ -209,7 +215,9 @@ class StorageCollector:
def _record_packet_blocking(self, packet_record: dict, skip_mqtt: bool):
"""Store, aggregate, update metrics, and publish one packet (writer thread)."""
self.sqlite_handler.store_packet(packet_record)
packet_id = self.sqlite_handler.store_packet(packet_record)
if packet_id is not None:
packet_record["id"] = packet_id
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
self._publish_packet_sync(packet_record, skip_mqtt)
@@ -416,6 +424,9 @@ class StorageCollector:
def get_packet_by_hash(self, packet_hash: str) -> Optional[dict]:
return self.sqlite_handler.get_packet_by_hash(packet_hash)
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
return self.sqlite_handler.get_packet_by_id(packet_id)
def get_rrd_data(
self,
start_time: Optional[int] = None,
+2 -2
View File
@@ -1,7 +1,7 @@
"""Storage utility classes and functions for data acquisition."""
from dataclasses import asdict, dataclass
from datetime import datetime
from datetime import datetime, timezone
from typing import Optional
@@ -57,7 +57,7 @@ class PacketRecord:
# Extract timestamp and format date/time
timestamp = packet_record.get("timestamp", 0)
dt = datetime.fromtimestamp(timestamp)
dt = datetime.fromtimestamp(timestamp, tz=timezone.utc)
# Format route type (1=Flood->F, 2=Direct->D, etc)
route_map = {1: "F", 2: "D"}
+30 -2
View File
@@ -24,6 +24,7 @@ _connected_clients = set()
PING_INTERVAL = 30 # seconds
_heartbeat_thread = None
_heartbeat_running = False
_websocket_plugin = None
class PacketWebSocket(WebSocket):
@@ -152,9 +153,20 @@ def _heartbeat_loop():
def init_websocket():
"""Initialize WebSocket plugin and start heartbeat"""
global _heartbeat_thread, _heartbeat_running
global _heartbeat_thread, _heartbeat_running, _websocket_plugin
WebSocketPlugin(cherrypy.engine).subscribe()
# Re-initialize plugin safely across CherryPy stop/start cycles.
# ws4py's manager thread cannot be started twice, so always tear down
# any previously subscribed plugin instance before creating a new one.
if _websocket_plugin is not None:
try:
_websocket_plugin.unsubscribe()
except Exception as e:
logger.debug(f"WebSocket plugin unsubscribe during init failed: {e}")
_websocket_plugin = None
_websocket_plugin = WebSocketPlugin(cherrypy.engine)
_websocket_plugin.subscribe()
cherrypy.tools.websocket = WebSocketTool()
# Start heartbeat thread
@@ -165,3 +177,19 @@ def init_websocket():
logger.info(f"WebSocket initialized with {PING_INTERVAL}s heartbeat")
else:
logger.info("WebSocket initialized")
def shutdown_websocket():
"""Stop websocket heartbeat and unsubscribe plugin for clean restart."""
global _heartbeat_running, _heartbeat_thread, _websocket_plugin
_heartbeat_running = False
_heartbeat_thread = None
_connected_clients.clear()
if _websocket_plugin is not None:
try:
_websocket_plugin.unsubscribe()
except Exception as e:
logger.debug(f"WebSocket plugin unsubscribe failed: {e}")
_websocket_plugin = None
+8
View File
@@ -400,6 +400,14 @@ class RepeaterHandler(BaseHandler):
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
self.direct_dup_count += 1
# Expose effective drop reason to PacketRouter.
# Some Packet implementations are slot-based and cannot accept dynamic attrs.
metadata["_repeater_drop_reason"] = drop_reason
try:
setattr(packet, "_repeater_drop_reason", drop_reason)
except (AttributeError, TypeError):
pass
display_hashes = (
original_path_hashes if original_path_hashes else packet.get_path_hashes_hex()
)
+34 -26
View File
@@ -48,6 +48,30 @@ class ACL:
self.allow_read_only = allow_read_only
self.clients: Dict[bytes, ClientInfo] = {}
def _is_replay(self, client: ClientInfo, timestamp: int) -> bool:
if timestamp <= client.last_timestamp:
logger.warning(
f"Possible replay attack! timestamp={timestamp}, last={client.last_timestamp}"
)
return True
return False
def _touch_client_session(
self,
client: ClientInfo,
shared_secret: bytes,
timestamp: int,
sync_since: int = None,
) -> None:
now = int(time.time())
client.last_timestamp = timestamp
client.last_activity = now
client.last_login_success = now
client.shared_secret = shared_secret
if sync_since is not None:
client.sync_since = sync_since
logger.debug(f"Stored sync_since={sync_since} for client")
def authenticate_client(
self,
client_identity: Identity,
@@ -110,23 +134,19 @@ class ACL:
logger.info("Blank password, sender not in ACL and read-only disabled")
return False, 0
if len(self.clients) >= self.max_clients:
logger.warning("ACL full, cannot add read-only guest")
logger.warning("ACL full, cannot add client")
return False, 0
# Read-only guests must still land in the ACL: the room server's
# text handler and sync loop only see ACL members, so an absent
# entry means the client's posts are silently dropped (never
# decrypted or ACKed) and posts are never pushed to it.
client = ClientInfo(client_identity, PERM_ACL_GUEST)
self.clients[pub_key] = client
logger.info(f"Blank password, added read-only guest {pub_key[:6].hex()}...")
logger.info("Blank password, allowing read-only guest access")
else:
logger.info(f"ACL-based login for {pub_key[:6].hex()}...")
client.last_activity = int(time.time())
client.last_login_success = int(time.time())
client.shared_secret = shared_secret
if sync_since is not None:
client.sync_since = sync_since
logger.debug(f"Stored sync_since={sync_since} for client")
if self._is_replay(client, timestamp):
return False, 0
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
if (client.permissions & PERM_ACL_ROLE_MASK) == 0:
client.permissions |= PERM_ACL_GUEST
return True, client.permissions
permissions = 0
@@ -154,23 +174,11 @@ class ACL:
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}"
)
if self._is_replay(client, timestamp):
return False, 0
client.last_timestamp = timestamp
client.last_activity = int(time.time())
client.last_login_success = int(time.time())
self._touch_client_session(client, shared_secret, timestamp, sync_since=sync_since)
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
+259
View File
@@ -8,6 +8,10 @@ allowing other nodes to discover repeaters on the mesh network.
import asyncio
import logging
import secrets
import threading
import time
import uuid
from typing import Any, Callable, Optional
from openhop_core.node.handlers.control import ControlHandler
@@ -22,6 +26,15 @@ logger = logging.getLogger("DiscoveryHelper")
# 60s (firmware pending_discover_until = futureMillis(60000)).
DEFAULT_DISCOVERY_RESPONSE_JITTER_MS = 2000
DEFAULT_DISCOVERY_TIMEOUT_SECONDS = 10.0
DISCOVERY_EVENT_BACKLOG_LIMIT = 512
NODE_TYPE_NAMES = {
1: "Chat Node",
2: "Repeater",
3: "Room Server",
}
class DiscoveryHelper:
"""Helper class for processing discovery requests in the repeater."""
@@ -60,11 +73,257 @@ class DiscoveryHelper:
debug_log_fn=debug_log_fn,
)
self._pending_tasks = set()
self._sessions: dict[str, dict[str, Any]] = {}
self._sessions_lock = threading.Lock()
# Set up the request callback
self.control_handler.set_request_callback(self._on_discovery_request)
logger.debug("Discovery handler initialized")
def create_session(
self,
*,
timeout: float = DEFAULT_DISCOVERY_TIMEOUT_SECONDS,
filter_mask: int,
since: int = 0,
prefix_only: bool = False,
result_enricher: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None,
) -> dict[str, Any]:
"""Create a new discovery session and return its public metadata."""
session_id = uuid.uuid4().hex
tag = secrets.randbits(32)
created_at = time.time()
session = {
"session_id": session_id,
"tag": tag,
"timeout": max(1.0, float(timeout)),
"filter_mask": int(filter_mask) & 0xFF,
"since": max(0, int(since)),
"prefix_only": bool(prefix_only),
"created_at": created_at,
"started_at": None,
"completed_at": None,
"status": "created",
"results": {},
"events": [],
"next_event_id": 1,
"error": None,
"result_enricher": result_enricher,
}
with self._sessions_lock:
self._sessions[session_id] = session
return self.get_session_snapshot(session_id) or {}
def get_session_snapshot(self, session_id: str) -> Optional[dict[str, Any]]:
"""Return a public snapshot for a discovery session."""
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session:
return None
return self._public_session_snapshot(session)
def get_events_since(self, session_id: str, last_event_id: int = 0) -> Optional[dict[str, Any]]:
"""Return all session events newer than last_event_id."""
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session:
return None
events = [event for event in session["events"] if event["id"] > last_event_id]
return {
"events": events,
"status": session["status"],
"completed": session["status"] in {"completed", "timed_out", "error", "cancelled"},
"latest_event_id": session["next_event_id"] - 1,
}
async def execute_session(self, session_id: str) -> None:
"""Send a discovery request and stream responses into the session."""
session = self._get_session(session_id)
if not session:
raise ValueError(f"Unknown discovery session: {session_id}")
if session["status"] != "created":
return
session["started_at"] = time.time()
session["status"] = "running"
self._emit_event(
session_id,
"started",
{
"session_id": session_id,
"tag": session["tag"],
"timeout": session["timeout"],
"filter_mask": session["filter_mask"],
"since": session["since"],
"prefix_only": session["prefix_only"],
"started_at": session["started_at"],
},
)
try:
from openhop_core.protocol.packet_builder import PacketBuilder
packet = PacketBuilder.create_discovery_request(
tag=session["tag"],
filter_mask=session["filter_mask"],
since=session["since"],
prefix_only=session["prefix_only"],
)
def _response_callback(response_data: dict[str, Any]) -> None:
self._record_response(session_id, response_data)
self.control_handler.set_response_callback(session["tag"], _response_callback)
if not self.packet_injector:
raise RuntimeError("No packet injector available")
success = await self.packet_injector(packet, wait_for_ack=False)
if not success:
raise RuntimeError("Failed to send discovery request")
logger.info(
"Discovery request sent for session %s tag 0x%08X filter=0x%02X",
session_id,
session["tag"],
session["filter_mask"],
)
await asyncio.sleep(session["timeout"])
self._finish_session(session_id, "completed")
except asyncio.CancelledError:
self._finish_session(session_id, "cancelled")
raise
except Exception as e:
logger.error("Discovery session %s failed: %s", session_id, e, exc_info=True)
self._finish_session(session_id, "error", error=str(e))
finally:
self.control_handler.clear_response_callback(session["tag"])
def start_session_task(self, session_id: str) -> None:
"""Schedule a discovery session on the current event loop."""
task = asyncio.create_task(self.execute_session(session_id))
self._track_task(task)
def cleanup_sessions(self, max_age_seconds: int = 120) -> None:
"""Remove old completed sessions to keep memory bounded."""
cutoff = time.time() - max_age_seconds
with self._sessions_lock:
stale_ids = [
session_id
for session_id, session in self._sessions.items()
if session["status"] in {"completed", "timed_out", "error", "cancelled"}
and (session.get("completed_at") or session.get("created_at", 0)) < cutoff
]
for session_id in stale_ids:
self._sessions.pop(session_id, None)
def _get_session(self, session_id: str) -> Optional[dict[str, Any]]:
with self._sessions_lock:
return self._sessions.get(session_id)
def _record_response(self, session_id: str, response_data: dict[str, Any]) -> None:
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session or session["status"] != "running":
return
result = dict(response_data)
result["node_type_name"] = NODE_TYPE_NAMES.get(
result.get("node_type"), f"Unknown({result.get('node_type', 0)})"
)
result["discovered_at"] = time.time()
enricher = session.get("result_enricher")
if enricher:
try:
result = enricher(result)
except Exception as e:
logger.debug("Discovery result enrichment failed: %s", e)
result_key = str(result.get("pub_key") or "")
if not result_key:
return
existing = session["results"].get(result_key)
session["results"][result_key] = result
payload = {
"session_id": session_id,
"tag": session["tag"],
"result": result,
"is_update": existing is not None,
"count": len(session["results"]),
}
self._append_event_unlocked(session, "discovery_result", payload)
def _finish_session(self, session_id: str, status: str, error: Optional[str] = None) -> None:
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session or session["status"] in {"completed", "timed_out", "error", "cancelled"}:
return
session["completed_at"] = time.time()
session["status"] = status
session["error"] = error
payload = {
"session_id": session_id,
"tag": session["tag"],
"status": status,
"error": error,
"count": len(session["results"]),
"duration_ms": round(
(
(session["completed_at"] or session["created_at"])
- (session["started_at"] or session["created_at"])
)
* 1000,
2,
),
"completed_at": session["completed_at"],
"results": list(session["results"].values()),
}
event_type = "error" if status == "error" else "completed"
self._append_event_unlocked(session, event_type, payload)
def _emit_event(self, session_id: str, event_type: str, payload: dict[str, Any]) -> None:
with self._sessions_lock:
session = self._sessions.get(session_id)
if not session:
return
self._append_event_unlocked(session, event_type, payload)
def _append_event_unlocked(
self, session: dict[str, Any], event_type: str, payload: dict[str, Any]
) -> None:
event_id = session["next_event_id"]
session["next_event_id"] += 1
session["events"].append(
{
"id": event_id,
"event": event_type,
"data": payload,
}
)
if len(session["events"]) > DISCOVERY_EVENT_BACKLOG_LIMIT:
session["events"] = session["events"][-DISCOVERY_EVENT_BACKLOG_LIMIT:]
def _public_session_snapshot(self, session: dict[str, Any]) -> dict[str, Any]:
return {
"session_id": session["session_id"],
"tag": session["tag"],
"status": session["status"],
"timeout": session["timeout"],
"filter_mask": session["filter_mask"],
"since": session["since"],
"prefix_only": session["prefix_only"],
"created_at": session["created_at"],
"started_at": session["started_at"],
"completed_at": session["completed_at"],
"count": len(session["results"]),
"error": session["error"],
}
def _track_task(self, task: asyncio.Task) -> None:
self._pending_tasks.add(task)
+21 -1
View File
@@ -122,7 +122,7 @@ class LoginHelper:
def auth_callback_with_context(
client_identity, shared_secret, password, timestamp, sync_since=None
):
return identity_acl.authenticate_client(
success, permissions = identity_acl.authenticate_client(
client_identity=client_identity,
shared_secret=shared_secret,
password=password,
@@ -132,6 +132,26 @@ class LoginHelper:
target_identity_name=name,
target_identity_config=config,
)
if success and identity_type == "room_server" and self.sqlite_handler is not None:
try:
sync_kwargs = {}
if sync_since is not None:
sync_kwargs["sync_since"] = sync_since
self.sqlite_handler.upsert_client_sync(
room_hash=f"0x{hash_byte:02X}",
client_pubkey=client_identity.get_public_key().hex(),
pending_ack_crc=0,
push_post_timestamp=0,
ack_timeout_time=0,
push_failures=0,
last_activity=time.time(),
**sync_kwargs,
)
except Exception as e:
logger.warning(
f"Failed to reset room sync guard state after login for hash=0x{hash_byte:02X}: {e}"
)
return success, permissions
handler = LoginServerHandler(
local_identity=identity,
+31 -3
View File
@@ -2,7 +2,6 @@ import logging
from pathlib import Path
from typing import Any, Callable, Dict, Optional
logger = logging.getLogger(__name__)
@@ -81,6 +80,10 @@ class MeshCLI:
return self._cmd_clock(command)
elif command.startswith("time "):
return self._cmd_time(command)
elif command == "http start":
return self._cmd_http_start()
elif command == "http stop":
return self._cmd_http_stop()
elif command == "start ota":
return "Error: OTA not supported in Python repeater"
elif command.startswith("password "):
@@ -156,6 +159,8 @@ class MeshCLI:
" advert Send self advertisement",
" clock Show current UTC time",
" clock sync Sync clock (no-op, uses system time)",
" http start Start the HTTP server",
" http stop Stop the HTTP server",
" ver Show version info",
" password <pw> Change admin password",
" clear stats Clear statistics",
@@ -226,6 +231,9 @@ class MeshCLI:
"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).",
"http": "http start|stop - Control the HTTP server.",
"http start": "Start the HTTP server.",
"http stop": "Stop the HTTP server.",
"ver": "Show repeater version and identity type.",
"password": "password <new_password> \u2014 Change the admin password.",
"tempradio": (
@@ -296,6 +304,26 @@ class MeshCLI:
"""Set time - not supported in Python (use system time)."""
return "Error: Time setting not supported (system time is used)"
def _cmd_http_start(self) -> str:
"""Start HTTP server."""
from repeater.service_utils import start_http_server
daemon_instance = getattr(self.config_manager, "daemon", None)
success, message = start_http_server(daemon_instance)
if success:
return f"OK - {message}"
return f"Error: {message}"
def _cmd_http_stop(self) -> str:
"""Stop HTTP server."""
from repeater.service_utils import stop_http_server
daemon_instance = getattr(self.config_manager, "daemon", None)
success, message = stop_http_server(daemon_instance)
if success:
return f"OK - {message}"
return f"Error: {message}"
def _cmd_password(self, command: str) -> str:
"""Change admin password."""
new_password = command[9:].strip()
@@ -329,8 +357,8 @@ class MeshCLI:
def _cmd_version(self) -> str:
"""Get version information."""
role = "room_server" if self.identity_type == "room_server" else "repeater"
version = self.config.get("version", "1.0.0")
return f"pyMC_{role} v{version}"
version = self.config.get("version", "13")
return f"openHop_{role} v{version}"
# ==================== Get Commands ====================
+45 -36
View File
@@ -1,3 +1,4 @@
import asyncio
import logging
import time
@@ -5,17 +6,31 @@ logger = logging.getLogger("PathHelper")
class PathHelper:
def __init__(self, acl_dict=None, log_fn=None, ack_received_fn=None):
def __init__(self, acl_dict=None, log_fn=None, ack_received_callback=None):
self.acl_dict = acl_dict or {}
self.log_fn = log_fn or logger.info
# Async callback fed with ACK CRCs found embedded in PATH payloads
# (dispatcher._register_ack_received) so local waiters resolve.
self.ack_received_fn = ack_received_fn
self.ack_received_callback = ack_received_callback
async def _register_ack_crc(self, ack_crc: int) -> None:
"""Propagate an ACK CRC to the configured callback."""
if ack_crc is None:
return
callback = self.ack_received_callback
if callback is None:
return
try:
result = callback(ack_crc)
if asyncio.iscoroutine(result):
await result
except Exception as e:
logger.debug(f"ACK callback failed for CRC {ack_crc:08X}: {e}")
async def process_path_packet(self, packet):
from openhop_core.protocol.constants import PAYLOAD_TYPE_ACK
from openhop_core.protocol.crypto import CryptoUtils
from openhop_core.protocol.packet_utils import PathUtils
try:
if len(packet.payload) < 2:
@@ -63,22 +78,19 @@ class PathHelper:
return False
# Parse decrypted PATH data
# Format: path_len(1) + path[...] + extra_type(1) + extra[...]
# path_len is the ENCODED wire byte (bits 0-5 = hash count, bits
# 6-7 = hash size - 1), matching Packet.path_len — with 3-byte
# hashes an empty path is 0x80, not 0x00. Reading it as a raw byte
# count made every such path return look truncated.
# Format: path_len(1) + path[path_byte_len] + extra_type(1) + extra[...]
if len(decrypted) < 1:
logger.debug("Decrypted PATH data too short")
return False
from openhop_core.protocol.packet_utils import PathUtils
path_len_byte = decrypted[0]
if not PathUtils.is_valid_path_len(path_len_byte):
logger.debug(f"Invalid encoded path_len 0x{path_len_byte:02X} in PATH data")
return False
path_byte_len = PathUtils.get_path_byte_len(path_len_byte)
if PathUtils.is_valid_path_len(path_len_byte):
path_byte_len = PathUtils.get_path_byte_len(path_len_byte)
path_hops = PathUtils.get_path_hash_count(path_len_byte)
else:
# Legacy fallback for malformed/old packets: treat first byte as raw path bytes.
path_byte_len = path_len_byte
path_hops = path_byte_len
if len(decrypted) < 1 + path_byte_len:
logger.debug(
f"PATH data truncated: need {1 + path_byte_len} bytes, got {len(decrypted)}"
@@ -90,34 +102,31 @@ class PathHelper:
# Update client's out_path (same as C++ memcpy); out_path_len keeps
# the encoded byte so direct sends put it on the wire as-is.
client.out_path = bytearray(path_data)
client.out_path_len = path_len_byte
client.out_path_len = (
path_len_byte if PathUtils.is_valid_path_len(path_len_byte) else path_byte_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=0x{path_len_byte:02X}, path={[hex(b) for b in path_data]}"
f"path_len_byte=0x{path_len_byte:02X}, hops={path_hops}, "
f"path={[hex(b) for b in path_data]}"
)
# Extra section after the path: extra_type(1) + extra[...]. Firmware
# answers a flood-received DM with a path return that embeds the
# delivery ACK here (createPathReturn, extra_type=PAYLOAD_TYPE_ACK);
# register it so local waiters (e.g. room server pushes) resolve.
from openhop_core.protocol.constants import PAYLOAD_TYPE_ACK
# Handle bundled ACK in PATH extra section.
ack_crc = None
extra_start = 1 + path_byte_len
if (
self.ack_received_fn is not None
and len(decrypted) >= extra_start + 5
and decrypted[extra_start] == PAYLOAD_TYPE_ACK
):
ack_crc = int.from_bytes(
bytes(decrypted[extra_start + 1 : extra_start + 5]), "little"
)
await self.ack_received_fn(ack_crc)
logger.info(
f"PATH from 0x{src_hash:02X} carried embedded ACK CRC={ack_crc:08X}"
)
if len(decrypted) > extra_start:
extra_type = decrypted[extra_start] & 0x0F
extra_payload = decrypted[extra_start + 1 :]
if extra_type == PAYLOAD_TYPE_ACK and len(extra_payload) >= 4:
ack_crc = int.from_bytes(extra_payload[:4], "little")
logger.info(
f"PATH bundled ACK extracted for client 0x{src_hash:02X}: CRC={ack_crc:08X}"
)
if ack_crc is not None:
await self._register_ack_crc(ack_crc)
# Don't mark as do_not_retransmit - let it forward normally
return False
+34 -17
View File
@@ -329,6 +329,12 @@ class RoomServer:
if sync_state:
failures = sync_state.get("push_failures", 0)
if failures >= MAX_PUSH_FAILURES:
logger.debug(
f"Room '{self.room_name}': Client 0x{client_info.id.get_public_key()[0]:02X} "
f"at max failures ({failures}), skipping push"
)
return False
if failures > 0:
# Apply exponential backoff
backoff_idx = min(failures, len(RETRY_BACKOFF_SCHEDULE) - 1)
@@ -357,11 +363,8 @@ class RoomServer:
plaintext = (
timestamp.to_bytes(4, "little") + bytes([flags]) + author_prefix + message_bytes
)
# Expected ACK: signed-plain receivers (firmware
# BaseChatMesh::onPeerDataRecv, and openhop_core's text handler)
# ack with sha256(decrypted[0 : 9 + strlen(text)] || client_pubkey)[:4]
# — exactly the timestamp+flags+author_prefix+text span built above.
# Calculate expected ACK (MeshCore signed text):
# sha256(timestamp + flags + author_prefix + text + recipient_pubkey)[:4]
ack_hash = CryptoUtils.sha256(plaintext + client_info.id.get_public_key())[:4]
expected_ack_crc = int.from_bytes(ack_hash, "little")
@@ -382,30 +385,44 @@ class RoomServer:
# wire byte (bits 0-5 = hash count, bits 6-7 = hash size - 1);
# out_path already holds exactly the path bytes.
if route_type == "direct" and len(client_info.out_path) > 0:
packet.path = bytearray(client_info.out_path)
packet.path_len = client_info.out_path_len
if PathUtils.is_valid_path_len(client_info.out_path_len):
path_byte_len = PathUtils.get_path_byte_len(client_info.out_path_len)
packet.path = bytearray(client_info.out_path[:path_byte_len])
packet.path_len = client_info.out_path_len
else:
# Legacy fallback: treat stored path as 1-byte-hop path and clamp to
# valid encoded range (0-63 hops).
legacy_hops = min(len(client_info.out_path), 63)
packet.path = bytearray(client_info.out_path[:legacy_hops])
packet.path_len = legacy_hops
# Calculate ACK timeout from the HOP count, not the encoded byte
# (0x80 = empty 3-byte-hash path would otherwise give a ~4min wait)
if route_type == "flood":
ack_timeout = PUSH_ACK_TIMEOUT_FLOOD_MS / 1000.0
else:
hops = (
PathUtils.get_path_hash_count(client_info.out_path_len)
if client_info.out_path_len >= 0
else 0
)
if PathUtils.is_valid_path_len(client_info.out_path_len):
path_len = PathUtils.get_path_hash_count(client_info.out_path_len)
else:
path_len = min(len(client_info.out_path), 63)
ack_timeout = (
PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (hops + 1)
PUSH_TIMEOUT_BASE_MS + PUSH_ACK_TIMEOUT_FACTOR_MS * (path_len + 1)
) / 1000.0
# Update client sync state with pending ACK
current_sync_since = (
sync_state.get("sync_since", 0)
if sync_state
else getattr(client_info, "sync_since", 0)
)
self.db.upsert_client_sync(
room_hash=f"0x{self.room_hash:02X}",
client_pubkey=client_info.id.get_public_key().hex(),
sync_since=current_sync_since,
pending_ack_crc=expected_ack_crc,
push_post_timestamp=post["post_timestamp"],
ack_timeout_time=time.time() + ack_timeout,
last_activity=time.time(),
)
# Send and wait for the client's delivery ACK. The injector must be
# told the crypto ACK CRC we computed above — its default
@@ -414,8 +431,8 @@ class RoomServer:
success = await self.packet_injector(
packet,
wait_for_ack=True,
expected_ack_crc=expected_ack_crc,
ack_timeout=ack_timeout,
expected_crc=expected_ack_crc,
ack_timeout_s=ack_timeout,
)
# SAFETY: Release transmission lock AFTER send completes
@@ -476,7 +493,7 @@ class RoomServer:
pending_ack_crc=0,
)
if failures >= 3:
if failures >= MAX_PUSH_FAILURES:
logger.warning(
f"Room '{self.room_name}': Client 0x{client_pubkey[0]:02X} "
f"has {failures} consecutive failures"
@@ -641,7 +658,7 @@ class RoomServer:
)
continue
if push_failures >= 3:
if push_failures >= MAX_PUSH_FAILURES:
logger.debug(
f"Skipping client 0x{client.id.get_public_key()[0]:02X} (max failures)"
)
+1
View File
@@ -457,6 +457,7 @@ class TextHelper:
"advert",
"clock",
"time ",
"http ",
"password ",
"clear ",
"ver",
+27 -14
View File
@@ -14,6 +14,11 @@ from typing import Optional, Tuple
from nacl.bindings import crypto_scalarmult_ed25519_base_noclamp
def _has_disallowed_public_prefix(public_key: bytes) -> bool:
"""Return True when public key starts with a forbidden prefix byte."""
return bool(public_key) and public_key[0] in (0x00, 0xFF)
def generate_meshcore_keypair() -> Tuple[bytes, bytes]:
"""Generate a MeshCore-compatible Ed25519 keypair.
@@ -21,25 +26,30 @@ def generate_meshcore_keypair() -> Tuple[bytes, bytes]:
(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)
while True:
# 1. Random 32-byte seed
seed = secrets.token_bytes(32)
# 2. SHA-512 hash
digest = hashlib.sha512(seed).digest()
# 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
# 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))
# 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]
# Disallow keys with 0x00 / 0xFF leading byte.
if _has_disallowed_public_prefix(public_key):
continue
return public_key, private_key
# 5. Private key = [clamped_scalar][sha512_upper_half]
private_key = bytes(clamped) + digest[32:64]
return public_key, private_key
def generate_vanity_key(
@@ -57,6 +67,9 @@ def generate_vanity_key(
"""
target = prefix.upper()
if target.startswith("00") or target.startswith("FF"):
return None
for attempt in range(1, max_iterations + 1):
pub, priv = generate_meshcore_keypair()
if pub.hex().upper().startswith(target):
+39 -13
View File
@@ -217,8 +217,12 @@ class RepeaterDaemon:
from openhop_core import LocalIdentity
from openhop_core.node.dispatcher import Dispatcher
self.dispatcher = Dispatcher(self.radio)
dedupe_enabled = bool(
self.config.get("repeater", {}).get("dispatcher_dedupe_enabled", False)
)
self.dispatcher = Dispatcher(self.radio, dedupe_enabled=dedupe_enabled)
logger.info("Dispatcher initialized")
logger.info("Dispatcher dedupe enabled: %s", dedupe_enabled)
# Initialize Identity Manager for additional identities (e.g., room servers)
self.identity_manager = IdentityManager(self.config)
@@ -412,9 +416,11 @@ class RepeaterDaemon:
self.path_helper = PathHelper(
acl_dict=self.login_helper.get_acl_dict(), # Per-identity ACLs
log_fn=logger.info,
# Embedded PATH ACKs must reach the dispatcher's ACK matching or
# wait_for_ack() (e.g. room server pushes) never resolves.
ack_received_fn=self.dispatcher._register_ack_received,
ack_received_callback=(
self.dispatcher._register_ack_received
if self.dispatcher and hasattr(self.dispatcher, "_register_ack_received")
else None
),
)
logger.info("PATH packet processing helper initialized")
@@ -445,9 +451,7 @@ class RepeaterDaemon:
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)
self._register_duplicate_logging_hook(dedupe_enabled)
# 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
@@ -980,6 +984,14 @@ class RepeaterDaemon:
except Exception as e:
logger.debug("Push RX raw to companion: %s", e)
def _register_duplicate_logging_hook(self, dedupe_enabled: bool) -> None:
"""Register pre-dedup duplicate logging only when dispatcher dedupe is active."""
if not self.dispatcher or not dedupe_enabled:
return
# When dispatcher dedupe is disabled, duplicates still flow through
# router -> repeater_handler and are already recorded there.
self.dispatcher.add_raw_packet_subscriber(self._on_raw_packet_for_dedup_logging)
def _on_raw_packet_for_dedup_logging(self, pkt, data: bytes, analysis: dict) -> None:
"""Record duplicate packets for UI visibility.
@@ -1449,8 +1461,19 @@ class RepeaterDaemon:
await self.initialize()
# 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") # nosec B104
http_config = self.config.get("http", {})
http_port = http_config.get("port", 8000)
http_host = http_config.get("host", "0.0.0.0") # nosec B104
http_enabled_raw = http_config.get("enabled", True)
if isinstance(http_enabled_raw, str):
http_enabled = http_enabled_raw.strip().lower() in (
"1",
"true",
"yes",
"on",
)
else:
http_enabled = bool(http_enabled_raw)
node_name = self.config.get("repeater", {}).get("node_name", "Repeater")
@@ -1479,10 +1502,13 @@ class RepeaterDaemon:
config_path=getattr(self, "config_path", "/etc/openhop_repeater/config.yaml"),
)
try:
self.http_server.start()
except Exception as e:
logger.error(f"Failed to start HTTP server: {e}")
if http_enabled:
try:
self.http_server.start()
except Exception as e:
logger.error(f"Failed to start HTTP server: {e}")
else:
logger.info("HTTP server startup skipped (http.enabled=false)")
# Run dispatcher (handles RX/TX via openhop_core)
try:
+51 -42
View File
@@ -8,6 +8,7 @@ from openhop_core.node.handlers.control import ControlHandler
from openhop_core.node.handlers.group_text import GroupTextHandler
from openhop_core.node.handlers.login_response import LoginResponseHandler
from openhop_core.node.handlers.login_server import LoginServerHandler
from openhop_core.node.handlers.multipart import MultipartAckHandler
from openhop_core.node.handlers.path import PathHandler
from openhop_core.node.handlers.protocol_request import ProtocolRequestHandler
from openhop_core.node.handlers.protocol_response import ProtocolResponseHandler
@@ -261,6 +262,16 @@ class PacketRouter:
except Exception as e:
logger.debug("Record for UI failed: %s", e)
async def _register_ack_with_dispatcher(self, ack_crc: int, context: str) -> None:
"""Best-effort ACK CRC registration with the dispatcher waiter path."""
dispatcher = getattr(self.daemon, "dispatcher", None)
if dispatcher is None or not hasattr(dispatcher, "_register_ack_received"):
return
try:
await dispatcher._register_ack_received(ack_crc)
except Exception as e:
logger.debug("Dispatcher %s registration error: %s", context, e)
async def enqueue(self, packet):
"""Add packet to router queue."""
if self.queue.full():
@@ -275,9 +286,9 @@ class PacketRouter:
self,
packet,
wait_for_ack: bool = False,
expected_crc=None,
origin_hash=None,
expected_ack_crc=None,
ack_timeout=None,
ack_timeout_s: float = 5.0,
):
try:
metadata = {
@@ -334,19 +345,20 @@ class PacketRouter:
dispatcher = getattr(self.daemon, "dispatcher", None)
if dispatcher and hasattr(dispatcher, "wait_for_ack"):
try:
# Callers that know the crypto ACK CRC (e.g. room
# server pushes) must pass it: packet.get_crc() is a
# packet-hash CRC that no ACK sender ever produces.
expected_crc = (
expected_ack_crc
if expected_ack_crc is not None
else packet.get_crc()
wait_crc = (
expected_crc if expected_crc is not None else packet.get_crc()
)
timeout = ack_timeout if ack_timeout is not None else 5.0
ack_ok = await dispatcher.wait_for_ack(expected_crc, timeout=timeout)
wait_timeout = (
float(ack_timeout_s)
if isinstance(ack_timeout_s, (int, float)) and ack_timeout_s > 0
else 5.0
)
ack_ok = await dispatcher.wait_for_ack(wait_crc, timeout=wait_timeout)
if not ack_ok:
logger.warning(
"Injected packet ACK timeout (crc=%08X)", expected_crc
"Injected packet ACK timeout (crc=%08X, timeout=%.1fs)",
wait_crc,
wait_timeout,
)
return False
except Exception as e:
@@ -478,20 +490,10 @@ class PacketRouter:
self._record_for_ui(packet, metadata)
elif payload_type == AckHandler.payload_type():
# Feed the dispatcher's ACK matching: the repeater registers no core
# AckHandler (all RX lands in this router via the fallback), so
# without this no dispatcher.wait_for_ack() — e.g. a room server
# push — can ever resolve. Covers RF ACKs and locally injected ones
# (companions hosted on this same instance never go over the air).
if packet.payload is not None and len(packet.payload) >= 4:
register_ack = getattr(
getattr(self.daemon, "dispatcher", None), "_register_ack_received", None
)
if register_ack is not None:
try:
await register_ack(int.from_bytes(bytes(packet.payload[:4]), "little"))
except Exception as e:
logger.debug("ACK registration failed: %s", e)
# Ensure ACK CRC reaches dispatcher waiter path even when only router fallback is active.
if len(getattr(packet, "payload", b"")) >= 4:
ack_crc = int.from_bytes(packet.payload[:4], "little")
await self._register_ack_with_dispatcher(ack_crc, "ACK")
# 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 = self._companion_bridges_for_packet(packet, metadata)
@@ -501,6 +503,15 @@ class PacketRouter:
except Exception as e:
logger.debug(f"Companion bridge ACK error: {e}")
elif payload_type == MultipartAckHandler.payload_type():
# MULTIPART ACK wrapper: low nibble of first byte is embedded payload type.
if (
len(getattr(packet, "payload", b"")) >= 5
and (packet.payload[0] & 0x0F) == AckHandler.payload_type()
):
ack_crc = int.from_bytes(packet.payload[1:5], "little")
await self._register_ack_with_dispatcher(ack_crc, "multi-ACK")
elif payload_type == TextMessageHandler.payload_type():
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
@@ -515,21 +526,19 @@ class PacketRouter:
self._record_for_ui(packet, metadata)
elif payload_type == PathHandler.payload_type():
# Always let PathHelper inspect/decrypt PATH first so out_path and bundled ACK state
# are updated even when companion routing fan-out also happens for this packet.
if self.daemon.path_helper:
try:
await self.daemon.path_helper.process_path_packet(packet)
except Exception as e:
logger.debug(f"Path helper processing error: {e}")
# The unconditional call above already covers PATH addressed to a
# local server identity (room server/repeater), so its out_path and
# any embedded ACK are handled before bridge delivery — the
# all-bridges branch below no longer swallows path returns for them.
dest_hash = packet.payload[0] if packet.payload else None
companion_bridges = self._companion_bridges_for_packet(packet, metadata)
# PATH addressed to a local server identity (room server/repeater):
# process for client out_path updates and any embedded ACK before
# bridge delivery. Previously the all-bridges branch below swallowed
# these whenever any companion was configured, so room servers never
# saw path returns — or the delivery ACKs firmware embeds in them.
path_helper = getattr(self.daemon, "path_helper", None)
is_local_identity_dest = (
path_helper is not None
and dest_hash is not None
and dest_hash in getattr(path_helper, "acl_dict", {})
)
if is_local_identity_dest:
await path_helper.process_path_packet(packet)
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)
@@ -548,8 +557,6 @@ class PacketRouter:
len(companion_bridges),
)
# Do not set processed_by_injection so packet also reaches engine for DIRECT forwarding when we're a middle hop.
elif path_helper and not is_local_identity_dest:
await 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.
@@ -662,7 +669,9 @@ class PacketRouter:
if self.daemon.repeater_handler and not processed_by_injection:
sent = await self.daemon.repeater_handler(packet, metadata)
if sent is False:
drop_reason = getattr(packet, "_repeater_drop_reason", None)
drop_reason = metadata.get("_repeater_drop_reason")
if not isinstance(drop_reason, str):
drop_reason = getattr(packet, "_repeater_drop_reason", None)
if not isinstance(drop_reason, str):
drop_reason = _drop_reason_from_recent_packets(
self.daemon.repeater_handler, packet
+54
View File
@@ -188,3 +188,57 @@ def restart_service() -> Tuple[bool, str]:
except Exception as e:
logger.error(f"Error executing sudo restart: {e}")
return False, f"Restart command failed: {str(e)}"
def _is_cherrypy_engine_running() -> Optional[bool]:
"""Return CherryPy engine running state when available."""
try:
import cherrypy
from cherrypy.process import wspbus
state = cherrypy.engine.state
return state in (wspbus.states.STARTING, wspbus.states.STARTED)
except Exception:
return None
def stop_http_server(daemon_instance) -> Tuple[bool, str]:
"""Stop the in-process HTTP stats server."""
if not daemon_instance:
return False, "Daemon instance not available"
http_server = getattr(daemon_instance, "http_server", None)
if not http_server:
return False, "HTTP server not initialized"
running = _is_cherrypy_engine_running()
if running is False:
return True, "HTTP server already stopped"
try:
http_server.stop()
return True, "HTTP server stopped"
except Exception as exc:
logger.error(f"Failed to stop HTTP server: {exc}", exc_info=True)
return False, f"Failed to stop HTTP server: {exc}"
def start_http_server(daemon_instance) -> Tuple[bool, str]:
"""Start the in-process HTTP stats server."""
if not daemon_instance:
return False, "Daemon instance not available"
http_server = getattr(daemon_instance, "http_server", None)
if not http_server:
return False, "HTTP server not initialized"
running = _is_cherrypy_engine_running()
if running is True:
return True, "HTTP server already running"
try:
http_server.start()
return True, "HTTP server started"
except Exception as exc:
logger.error(f"Failed to start HTTP server: {exc}", exc_info=True)
return False, f"Failed to start HTTP server: {exc}"
+311 -3
View File
@@ -122,6 +122,9 @@ POLICY_GROUP_KINDS = {
# 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
# POST /api/discover_neighbors_start - Start a live repeater discovery session
# GET /api/discover_neighbors_stream?session_id=X - Stream repeater discovery results over SSE
# POST /api/add_discovered_neighbor - Persist a discovered node into the neighbors/adverts table
# Identity Management
# GET /api/identities - List all identities
@@ -303,6 +306,81 @@ class APIEndpoints:
end_time = int(time.time())
return end_time - (hours * 3600), end_time
def _encode_sse_event(
self, payload, event_name: Optional[str] = None, event_id: Optional[int] = None
) -> str:
lines = []
if event_name:
lines.append(f"event: {event_name}")
if event_id is not None:
lines.append(f"id: {event_id}")
lines.append(f"data: {json.dumps(payload, default=str)}")
return "\n".join(lines) + "\n\n"
@staticmethod
def _normalize_discovery_node_name(value) -> Optional[str]:
"""Normalize placeholder discovery names to None.
Some peers explicitly advertise "Unknown"-style placeholder names.
Treat those as missing so callers can fall back to hash/prefix labels.
"""
text = str(value or "").strip()
if not text:
return None
normalized = text.lower()
if normalized in {"unknown", "unknown node"}:
return None
return text
def _enrich_discovery_result(self, result: dict) -> dict:
enriched = dict(result)
enriched["node_name"] = self._normalize_discovery_node_name(enriched.get("node_name"))
pub_key = str(enriched.get("pub_key") or "").lower()
if not pub_key:
return enriched
try:
enriched["node_hash"] = self._fmt_hash(bytes.fromhex(pub_key))
except ValueError:
enriched["node_hash"] = None
try:
storage = self._get_storage()
except Exception:
storage = None
if not storage:
enriched["known_neighbor"] = False
return enriched
neighbor_info = {}
try:
if hasattr(storage, "get_neighbors"):
neighbor_info = storage.get_neighbors().get(pub_key, {}) or {}
except Exception as exc:
logger.debug("Discovery enrichment could not load neighbors: %s", exc)
if not neighbor_info:
try:
node_name = storage.get_node_name_by_pubkey(pub_key)
except Exception:
node_name = None
node_name = self._normalize_discovery_node_name(node_name)
enriched["known_neighbor"] = bool(node_name)
if node_name:
enriched["node_name"] = node_name
return enriched
enriched["known_neighbor"] = True
enriched["node_name"] = self._normalize_discovery_node_name(neighbor_info.get("node_name"))
enriched["contact_type"] = neighbor_info.get("contact_type")
enriched["zero_hop"] = neighbor_info.get("zero_hop")
enriched["last_seen"] = neighbor_info.get("last_seen")
enriched["advert_count"] = neighbor_info.get("advert_count")
return enriched
def _process_counter_data(self, data_points, timestamps_ms):
rates = []
prev_value = None
@@ -2962,6 +3040,18 @@ class APIEndpoints:
logger.error(f"Error getting packet by hash: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def packet_by_id(self, packet_id=None):
try:
if packet_id is None:
return self._error("packet_id parameter required")
packet = self._get_storage().get_packet_by_id(int(packet_id))
return self._success(packet) if packet else self._error("Packet not found")
except Exception as e:
logger.error(f"Error getting packet by id: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def rrd_data(self):
@@ -4032,6 +4122,222 @@ class APIEndpoints:
logger.error(f"Error pinging neighbor: {e}", exc_info=True)
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def discover_neighbors_start(self):
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
data = cherrypy.request.json or {}
timeout = float(data.get("timeout", 5))
filter_mask = int(data.get("filter_mask", 1 << 2))
since = int(data.get("since", 0))
prefix_only = bool(data.get("prefix_only", False))
if timeout < 1 or timeout > 60:
return self._error("timeout must be between 1 and 60 seconds")
if filter_mask < 0 or filter_mask > 0xFF:
return self._error("filter_mask must be between 0x00 and 0xFF")
if since < 0:
return self._error("since must be non-negative")
if self.event_loop is None:
return self._error("Event loop not available")
discovery_helper = getattr(self.daemon_instance, "discovery_helper", None)
if not discovery_helper:
return self._error("Discovery helper not available")
discovery_helper.cleanup_sessions()
session = discovery_helper.create_session(
timeout=timeout,
filter_mask=filter_mask,
since=since,
prefix_only=prefix_only,
result_enricher=self._enrich_discovery_result,
)
self.event_loop.call_soon_threadsafe(
discovery_helper.start_session_task, session["session_id"]
)
return self._success(
session,
message="Discovery session started",
)
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error("Error starting discovery session: %s", e, exc_info=True)
return self._error(str(e))
@cherrypy.expose
def discover_neighbors_stream(self, session_id=None, last_event_id: Optional[str] = None):
self._set_cors_headers()
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"
discovery_helper = getattr(self.daemon_instance, "discovery_helper", None)
try:
cursor = int(last_event_id) if last_event_id is not None else 0
except (TypeError, ValueError):
cursor = 0
def generate():
if not session_id:
yield self._encode_sse_event(
{"type": "error", "error": "Missing session_id"},
event_name="error",
)
return
if not discovery_helper:
yield self._encode_sse_event(
{"type": "error", "error": "Discovery helper not available"},
event_name="error",
)
return
snapshot = discovery_helper.get_session_snapshot(session_id)
if not snapshot:
yield self._encode_sse_event(
{"type": "error", "error": f"Unknown discovery session: {session_id}"},
event_name="error",
)
return
yield self._encode_sse_event(
{
"type": "connected",
"session": snapshot,
},
event_name="connected",
)
current_cursor = cursor
try:
while True:
event_state = discovery_helper.get_events_since(session_id, current_cursor)
if event_state is None:
yield self._encode_sse_event(
{
"type": "error",
"error": f"Unknown discovery session: {session_id}",
},
event_name="error",
)
return
events = event_state.get("events", [])
if events:
for event in events:
current_cursor = max(current_cursor, int(event.get("id", 0)))
yield self._encode_sse_event(
event.get("data", {}),
event_name=event.get("event"),
event_id=event.get("id"),
)
if event_state.get("completed"):
return
else:
if event_state.get("completed"):
return
yield self._encode_sse_event(
{"type": "keepalive", "session_id": session_id},
event_name="keepalive",
event_id=current_cursor if current_cursor > 0 else None,
)
time.sleep(0.5)
except GeneratorExit:
logger.debug("Discovery SSE stream closed by client for session %s", session_id)
except Exception as exc:
logger.error("Discovery SSE stream error: %s", exc, exc_info=True)
yield self._encode_sse_event(
{"type": "error", "error": str(exc), "session_id": session_id},
event_name="error",
event_id=current_cursor if current_cursor > 0 else None,
)
return generate()
discover_neighbors_stream._cp_config = {"response.stream": True}
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
@require_auth
def add_discovered_neighbor(self):
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
data = cherrypy.request.json or {}
pub_key = str(data.get("pub_key") or "").strip().lower()
node_name = self._normalize_discovery_node_name(data.get("node_name"))
node_type = int(data.get("node_type", 0))
rssi = data.get("rssi")
snr = data.get("response_snr", data.get("snr"))
if not pub_key:
return self._error("pub_key is required")
if not re.fullmatch(r"[0-9a-f]{16}|[0-9a-f]{64}", pub_key):
return self._error("pub_key must be 8-byte or 32-byte hex")
contact_type = {
1: "Chat Node",
2: "Repeater",
3: "Room Server",
}.get(node_type, "Unknown")
advert_record = {
"timestamp": time.time(),
"pubkey": pub_key,
"node_name": node_name,
"is_repeater": node_type == 2,
"route_type": 2,
"contact_type": contact_type,
"latitude": None,
"longitude": None,
"rssi": int(rssi) if rssi is not None else None,
"snr": float(snr) if snr is not None else None,
"is_new_neighbor": True,
"zero_hop": True,
}
storage = self._get_storage()
storage.record_advert(advert_record)
enriched = self._enrich_discovery_result(
{
"pub_key": pub_key,
"node_name": node_name,
"node_type": node_type,
"node_type_name": contact_type,
"rssi": advert_record["rssi"],
"response_snr": advert_record["snr"],
}
)
return self._success(enriched, message="Neighbor added to adverts database")
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error("Error adding discovered neighbor: %s", e, exc_info=True)
return self._error(str(e))
# ========== Identity Management Endpoints ==========
@cherrypy.expose
@@ -4256,9 +4562,11 @@ class APIEndpoints:
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()
# Use MeshCore-compatible keygen and store 32-byte private scalar hex.
from repeater.keygen import generate_meshcore_keypair
_, private_key = generate_meshcore_keypair()
identity_key = private_key[:32].hex()
key_was_generated = True
logger.info(f"Auto-generated identity key for '{name}': {identity_key[:16]}...")
except Exception as gen_error:
+20
View File
@@ -39,6 +39,26 @@ def require_auth(func):
else:
logger.warning("Invalid or expired JWT token")
request_params = getattr(cherrypy.request, "params", None)
if request_params is None:
request_params = {}
query_token = request_params.get("token")
if query_token:
payload = jwt_handler.verify_jwt(query_token)
if payload:
cherrypy.request.user = {
"username": payload["sub"],
"client_id": payload["client_id"],
"auth_type": "jwt_query",
}
if hasattr(cherrypy.request, "params") and "token" in cherrypy.request.params:
del cherrypy.request.params["token"]
return func(*args, **kwargs)
else:
logger.warning("Invalid or expired JWT query token")
# Try API token authentication
api_key = cherrypy.request.headers.get("X-API-Key", "")
if api_key:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
.glass-card[data-v-4267adba]{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)}
.glass-card[data-v-f8312427]{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)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.ml-0[data-v-1b1421f8]{margin-left:0}.ml-4[data-v-1b1421f8]{margin-left:1rem}.ml-8[data-v-1b1421f8]{margin-left:2rem}.ml-12[data-v-1b1421f8]{margin-left:3rem}.ml-16[data-v-1b1421f8]{margin-left:4rem}.ml-20[data-v-1b1421f8]{margin-left:5rem}.ml-24[data-v-1b1421f8]{margin-left:6rem}.ml-28[data-v-1b1421f8]{margin-left:7rem}.ml-32[data-v-1b1421f8]{margin-left:8rem}.dropdown-enter-active[data-v-45cb296d],.dropdown-leave-active[data-v-45cb296d]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-45cb296d],.dropdown-leave-to[data-v-45cb296d]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-00e540ed],.expand-leave-active[data-v-00e540ed]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-00e540ed],.expand-leave-to[data-v-00e540ed]{opacity:0;max-height:0}.expand-enter-to[data-v-00e540ed],.expand-leave-from[data-v-00e540ed]{opacity:1;max-height:2000px}
@@ -0,0 +1 @@
.ml-0[data-v-164d2832]{margin-left:0}.ml-4[data-v-164d2832]{margin-left:1rem}.ml-8[data-v-164d2832]{margin-left:2rem}.ml-12[data-v-164d2832]{margin-left:3rem}.ml-16[data-v-164d2832]{margin-left:4rem}.ml-20[data-v-164d2832]{margin-left:5rem}.ml-24[data-v-164d2832]{margin-left:6rem}.ml-28[data-v-164d2832]{margin-left:7rem}.ml-32[data-v-164d2832]{margin-left:8rem}.dropdown-enter-active[data-v-e0c27f45],.dropdown-leave-active[data-v-e0c27f45]{transition:opacity .12s,transform .12s}.dropdown-enter-from[data-v-e0c27f45],.dropdown-leave-to[data-v-e0c27f45]{opacity:0;transform:translateY(-4px)}.expand-enter-active[data-v-5bf02bea],.expand-leave-active[data-v-5bf02bea]{transition:all .2s;overflow:hidden}.expand-enter-from[data-v-5bf02bea],.expand-leave-to[data-v-5bf02bea]{opacity:0;max-height:0}.expand-enter-to[data-v-5bf02bea],.expand-leave-from[data-v-5bf02bea]{opacity:1;max-height:2000px}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`flex items-center justify-between mb-4`},f={class:`text-xl font-semibold text-content-primary`},p={class:`mb-6`},m={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},g={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},_={class:`text-content-secondary dark:text-content-primary/opacity-heavy text-base leading-relaxed`},v={class:`flex gap-3`},y=r({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(r,{emit:y}){let b=r,x=y,S={danger:`bg-accent-red/opacity-light dark:bg-accent-red/opacity-medium border-accent-red/opacity-medium text-accent-red`,warning:`bg-accent-amber/opacity-light dark:bg-accent-amber/opacity-medium border-accent-amber/opacity-medium text-accent-amber`,info:`bg-primary/opacity-medium border-primary/opacity-medium text-primary`},C={danger:`bg-accent-red/opacity-light hover:bg-accent-red/opacity-light`,warning:`bg-accent-amber/opacity-light hover:bg-accent-amber/opacity-light`,info:`bg-primary/opacity-light hover:bg-primary/opacity-light`};return(r,y)=>(e(),n(a,{to:`body`},[b.show?(e(),c(`div`,{key:0,onClick:y[3]||=l(e=>x(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`h3`,f,t(b.title),1),s(`button`,{onClick:y[0]||=e=>x(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...y[4]||=[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`,p,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,S[b.variant]])},[b.variant===`danger`?(e(),c(`svg`,m,[...y[5]||=[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-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)]])):b.variant===`warning`?(e(),c(`svg`,h,[...y[6]||=[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-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)]])):(e(),c(`svg`,g,[...y[7]||=[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)]]))],2),s(`p`,_,t(b.message),1)]),s(`div`,v,[s(`button`,{onClick:y[1]||=e=>x(`close`),class:`flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/opacity-subtle hover:bg-stroke-subtle dark:hover:bg-white/opacity-light text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/opacity-light`},t(b.cancelText),1),s(`button`,{onClick:y[2]||=e=>x(`confirm`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,C[b.variant]])},t(b.confirmText),3)])])])):o(``,!0)]))}});export{y as t};
@@ -1 +0,0 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`flex items-center justify-between mb-4`},f={class:`text-xl font-semibold text-content-primary dark:text-content-primary`},p={class:`mb-6`},m={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},g={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},_={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},v={class:`flex gap-3`},y=r({__name:`ConfirmDialog`,props:{show:{type:Boolean},title:{default:`Confirm Action`},message:{},confirmText:{default:`Confirm`},cancelText:{default:`Cancel`},variant:{default:`warning`}},emits:[`close`,`confirm`],setup(r,{emit:y}){let b=r,x=y,S={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`},C={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(r,y)=>(e(),n(a,{to:`body`},[b.show?(e(),c(`div`,{key:0,onClick:y[3]||=l(e=>x(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`h3`,f,t(b.title),1),s(`button`,{onClick:y[0]||=e=>x(`close`),class:`text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors`},[...y[4]||=[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`,p,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,S[b.variant]])},[b.variant===`danger`?(e(),c(`svg`,m,[...y[5]||=[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-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)]])):b.variant===`warning`?(e(),c(`svg`,h,[...y[6]||=[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-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)]])):(e(),c(`svg`,g,[...y[7]||=[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)]]))],2),s(`p`,_,t(b.message),1)]),s(`div`,v,[s(`button`,{onClick:y[1]||=e=>x(`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`},t(b.cancelText),1),s(`button`,{onClick:y[2]||=e=>x(`confirm`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,C[b.variant]])},t(b.confirmText),3)])])])):o(``,!0)]))}});export{y as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
.globe-stage[data-v-9debe317]{border:1px solid var(--color-border-subtle);background:radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 34%), radial-gradient(circle at 48% 50%, color-mix(in srgb, var(--color-surface) 14%, transparent), transparent 20%), linear-gradient(145deg, var(--color-background-soft), var(--color-background));cursor:grab;touch-action:none;border-radius:10px;min-height:330px;position:relative;overflow:hidden}.globe-stage[data-v-9debe317]:active{cursor:grabbing}.globe-stage canvas[data-v-9debe317]{width:100%;height:330px;display:block}.globe-tooltip[data-v-9debe317]{z-index:2;border:1px solid var(--color-glass-border);background:color-mix(in srgb, var(--color-surface-elevated) 92%, transparent);min-width:154px;color:var(--color-heading);box-shadow:var(--color-glass-shadow);pointer-events:none;border-radius:12px;padding:10px 11px;position:absolute;transform:translate(-50%,calc(-100% - 22px))}.tooltip-title[data-v-9debe317]{color:var(--color-accent-green);letter-spacing:.06em;text-transform:uppercase;font-size:.82rem;font-weight:800}.tooltip-grid[data-v-9debe317]{grid-template-columns:auto 1fr;gap:4px 10px;margin-top:7px;font-size:.78rem;display:grid}.tooltip-key[data-v-9debe317]{color:var(--color-text-muted)}.tooltip-value[data-v-9debe317]{color:var(--color-heading);text-align:right;font-weight:700}.globe-fallback[data-v-9debe317]{padding:18px;position:absolute;inset:0}.fallback-sky[data-v-9debe317]{aspect-ratio:1;border:1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border-subtle));background:radial-gradient(circle, color-mix(in srgb, var(--color-primary) 18%, transparent) 0 2px, transparent 3px), repeating-radial-gradient(circle, transparent 0 31%, color-mix(in srgb, var(--color-border) 70%, transparent) 31.5% 32%, transparent 32.5% 49%), linear-gradient(90deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%), linear-gradient(0deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%);border-radius:50%;width:min(250px,72vw);margin:0 auto;position:relative}.fallback-sat[data-v-9debe317]{width:var(--size);height:var(--size);background:var(--color-primary);box-shadow:0 0 16px color-mix(in srgb, var(--color-primary) 70%, transparent);border-radius:999px;position:absolute;transform:translate(-50%,-50%)}.fallback-sat-used[data-v-9debe317]{background:var(--color-accent-green);box-shadow:0 0 16px color-mix(in srgb, var(--color-accent-green) 70%, transparent)}.fallback-sat span[data-v-9debe317]{color:var(--color-text-primary);white-space:nowrap;font-size:.65rem;font-weight:700;position:absolute;top:calc(100% + 4px);left:50%;transform:translate(-50%)}.sky-empty[data-v-9debe317]{color:var(--color-text-muted);pointer-events:none;place-items:center;font-size:.875rem;display:grid;position:absolute;inset:0}.sat-row[data-v-9debe317]{opacity:1;transition:opacity .6s,color .4s}.sat-row-stale[data-v-9debe317]{opacity:.35}.page-tabs[data-v-9debe317]{border-bottom:1px solid var(--color-border-subtle);gap:2px;padding-bottom:0;display:flex}.page-tab[data-v-9debe317]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;margin-bottom:-1px;padding:8px 18px;font-size:.875rem;font-weight:600;transition:color .18s,border-color .18s}.page-tab[data-v-9debe317]:hover{color:var(--color-text-primary)}.page-tab-active[data-v-9debe317]{color:var(--color-primary);border-bottom-color:var(--color-primary)}.inner-tabs[data-v-9debe317]{border:1px solid var(--color-border-subtle);background:color-mix(in srgb, var(--color-surface) 22%, transparent);border-radius:8px;gap:2px;padding:2px;display:flex}.inner-tab[data-v-9debe317]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-radius:6px;padding:4px 12px;font-size:.75rem;font-weight:600;transition:background .15s,color .15s}.inner-tab[data-v-9debe317]:hover{color:var(--color-text-primary)}.inner-tab-active[data-v-9debe317]{background:var(--color-primary);color:var(--color-heading)}.accordion-header[data-v-9debe317]{cursor:pointer;text-align:left;background:0 0;border:none;justify-content:space-between;align-items:center;width:100%;padding:14px 20px;font-size:.9rem;transition:background .15s;display:flex}.accordion-header[data-v-9debe317]:hover{background:color-mix(in srgb, var(--color-surface) 22%, transparent)}.accordion-chevron[data-v-9debe317]{color:var(--color-text-muted);flex-shrink:0;transition:transform .2s}.accordion-chevron-open[data-v-9debe317]{transform:rotate(180deg)}.accordion-body[data-v-9debe317]{padding:0 20px 16px}
.globe-stage[data-v-481d86b0]{border:1px solid var(--color-border-subtle);background:radial-gradient(circle at 50% 46%, color-mix(in srgb, var(--color-primary) 24%, transparent), transparent 34%), radial-gradient(circle at 48% 50%, color-mix(in srgb, var(--color-surface) 14%, transparent), transparent 20%), linear-gradient(145deg, var(--color-background-soft), var(--color-background));cursor:grab;touch-action:none;border-radius:10px;min-height:330px;position:relative;overflow:hidden}.globe-stage[data-v-481d86b0]:active{cursor:grabbing}.globe-stage canvas[data-v-481d86b0]{width:100%;height:330px;display:block}.globe-tooltip[data-v-481d86b0]{z-index:2;border:1px solid var(--color-glass-border);background:color-mix(in srgb, var(--color-surface-elevated) 92%, transparent);min-width:154px;color:var(--color-heading);box-shadow:var(--color-glass-shadow);pointer-events:none;border-radius:12px;padding:10px 11px;position:absolute;transform:translate(-50%,calc(-100% - 22px))}.tooltip-title[data-v-481d86b0]{color:var(--color-accent-green);letter-spacing:.06em;text-transform:uppercase;font-size:.82rem;font-weight:800}.tooltip-grid[data-v-481d86b0]{grid-template-columns:auto 1fr;gap:4px 10px;margin-top:7px;font-size:.78rem;display:grid}.tooltip-key[data-v-481d86b0]{color:var(--color-text-muted)}.tooltip-value[data-v-481d86b0]{color:var(--color-heading);text-align:right;font-weight:700}.globe-fallback[data-v-481d86b0]{padding:18px;position:absolute;inset:0}.fallback-sky[data-v-481d86b0]{aspect-ratio:1;border:1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border-subtle));background:radial-gradient(circle, color-mix(in srgb, var(--color-primary) 18%, transparent) 0 2px, transparent 3px), repeating-radial-gradient(circle, transparent 0 31%, color-mix(in srgb, var(--color-border) 70%, transparent) 31.5% 32%, transparent 32.5% 49%), linear-gradient(90deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%), linear-gradient(0deg, transparent 49.7%, color-mix(in srgb, var(--color-border) 78%, transparent) 49.7% 50.3%, transparent 50.3%);border-radius:50%;width:min(250px,72vw);margin:0 auto;position:relative}.fallback-sat[data-v-481d86b0]{width:var(--size);height:var(--size);background:var(--color-primary);box-shadow:0 0 16px color-mix(in srgb, var(--color-primary) 70%, transparent);border-radius:999px;position:absolute;transform:translate(-50%,-50%)}.fallback-sat-used[data-v-481d86b0]{background:var(--color-accent-green);box-shadow:0 0 16px color-mix(in srgb, var(--color-accent-green) 70%, transparent)}.fallback-sat span[data-v-481d86b0]{color:var(--color-text-primary);white-space:nowrap;font-size:.65rem;font-weight:700;position:absolute;top:calc(100% + 4px);left:50%;transform:translate(-50%)}.sky-empty[data-v-481d86b0]{color:var(--color-text-muted);pointer-events:none;place-items:center;font-size:.875rem;display:grid;position:absolute;inset:0}.sat-row[data-v-481d86b0]{opacity:1;transition:opacity .6s,color .4s}.sat-row-stale[data-v-481d86b0]{opacity:.35}.page-tabs[data-v-481d86b0]{border-bottom:1px solid var(--color-border-subtle);gap:2px;padding-bottom:0;display:flex}.page-tab[data-v-481d86b0]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-bottom:2px solid #0000;margin-bottom:-1px;padding:8px 18px;font-size:.875rem;font-weight:600;transition:color .18s,border-color .18s}.page-tab[data-v-481d86b0]:hover{color:var(--color-text-primary)}.page-tab-active[data-v-481d86b0]{color:var(--color-primary);border-bottom-color:var(--color-primary)}.inner-tabs[data-v-481d86b0]{border:1px solid var(--color-border-subtle);background:color-mix(in srgb, var(--color-surface) 22%, transparent);border-radius:8px;gap:2px;padding:2px;display:flex}.inner-tab[data-v-481d86b0]{color:var(--color-text-muted);cursor:pointer;background:0 0;border:none;border-radius:6px;padding:4px 12px;font-size:.75rem;font-weight:600;transition:background .15s,color .15s}.inner-tab[data-v-481d86b0]:hover{color:var(--color-text-primary)}.inner-tab-active[data-v-481d86b0]{background:var(--color-primary);color:var(--color-heading)}.accordion-header[data-v-481d86b0]{cursor:pointer;text-align:left;background:0 0;border:none;justify-content:space-between;align-items:center;width:100%;padding:14px 20px;font-size:.9rem;transition:background .15s;display:flex}.accordion-header[data-v-481d86b0]:hover{background:color-mix(in srgb, var(--color-surface) 22%, transparent)}.accordion-chevron[data-v-481d86b0]{color:var(--color-text-muted);flex-shrink:0;transition:transform .2s}.accordion-chevron-open[data-v-481d86b0]{transform:rotate(180deg)}.accordion-body[data-v-481d86b0]{padding:0 20px 16px}
@@ -0,0 +1 @@
import{T as e,f as t,h as n,u as r}from"./runtime-core.esm-bundler-CINEgm0a.js";var i=n({name:`HelpView`,__name:`Help`,setup(n){return(n,i)=>(e(),r(`div`,null,[...i[0]||=[t(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/opacity-light rounded-[15px] p-8"><h1 class="text-content-primary text-2xl font-semibold mb-6"> Help &amp; Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary text-xl font-medium mb-3"> Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/openhop-dev/openhope-repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 font-medium py-3 px-6 rounded-xl transition-colors bg-primary/opacity-medium hover:bg-primary/opacity-medium border border-primary/opacity-heavy text-primary"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
@@ -1 +0,0 @@
import{T as e,f as t,h as n,u as r}from"./runtime-core.esm-bundler-CINEgm0a.js";var i=n({name:`HelpView`,__name:`Help`,setup(n){return(n,i)=>(e(),r(`div`,null,[...i[0]||=[t(`<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6"> Help &amp; Documentation </h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3"> Repeater Wiki </h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 font-medium py-3 px-6 rounded-xl transition-colors bg-primary/20 hover:bg-primary/30 border border-primary/50 text-primary"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><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"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>`,1)]]))}});export{i as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
.bg-gradient-light[data-v-87cdf9e4]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 25%, transparent), color-mix(in srgb, var(--color-secondary) 15%, transparent))}.bg-gradient-dark[data-v-87cdf9e4]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 12%, transparent), color-mix(in srgb, var(--color-secondary) 8%, transparent))}.login-card[data-v-87cdf9e4]{-webkit-backdrop-filter:blur(40px)saturate(180%);box-shadow:var(--color-glass-shadow);background:color-mix(in srgb, var(--color-surface) 85%, transparent)}.dark .login-card[data-v-87cdf9e4]{background:color-mix(in srgb, var(--color-surface-elevated) 80%, transparent)}.input-glass[data-v-87cdf9e4]{-webkit-backdrop-filter:blur(20px);background:color-mix(in srgb, var(--color-surface) 90%, transparent);border:1px solid var(--color-border)}.dark .input-glass[data-v-87cdf9e4]{background:color-mix(in srgb, var(--color-surface-elevated) 55%, transparent);border-color:var(--color-border-subtle)}.input-glass[data-v-87cdf9e4]:focus{background:var(--color-surface)}.dark .input-glass[data-v-87cdf9e4]:focus{background:color-mix(in srgb, var(--color-surface-elevated) 70%, transparent)}.input-glass[data-v-87cdf9e4]:focus{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 0 20px color-mix(in srgb, var(--color-accent-cyan) 15%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.input-glow[data-v-87cdf9e4]{opacity:0;box-shadow:inset 0 1px 0 color-mix(in srgb, var(--color-surface) 35%, transparent);transition:opacity .3s}.input-glass:focus+.input-glow[data-v-87cdf9e4]{opacity:1;box-shadow:0 0 20px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-87cdf9e4]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-87cdf9e4]:before{content:"";background:linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--color-accent-cyan) 30%, transparent) 50%, transparent 100%);-webkit-mask:linear-gradient(var(--color-surface) 0 0) content-box, linear-gradient(var(--color-surface) 0 0);-webkit-mask-composite:xor;border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-composite:xor;mask-composite:exclude}.button-glass[data-v-87cdf9e4]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-87cdf9e4]{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 4px 16px color-mix(in srgb, var(--color-background) 35%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-87cdf9e4]:hover:not(:disabled){box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 40%, transparent), 0 0 30px color-mix(in srgb, var(--color-accent-cyan) 30%, transparent), 0 4px 20px color-mix(in srgb, var(--color-background) 45%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 55%, transparent)}@keyframes float-87cdf9e4{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-87cdf9e4{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-87cdf9e4{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-87cdf9e4{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-87cdf9e4]{animation:8s ease-in-out infinite pulse-slow-87cdf9e4}.animate-pulse-slower[data-v-87cdf9e4]{animation:10s ease-in-out infinite pulse-slower-87cdf9e4}.animate-pulse-slowest[data-v-87cdf9e4]{animation:12s ease-in-out infinite pulse-slowest-87cdf9e4}@keyframes shake-87cdf9e4{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-87cdf9e4]{animation:.5s ease-in-out shake-87cdf9e4}.form-group[data-v-87cdf9e4]{position:relative}.form-group:hover label[data-v-87cdf9e4]{color:var(--color-accent-cyan);transition:color .3s}
.bg-gradient-light[data-v-eec727a3]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 25%, transparent), color-mix(in srgb, var(--color-secondary) 15%, transparent))}.bg-gradient-dark[data-v-eec727a3]{background:linear-gradient(to bottom, color-mix(in srgb, var(--color-primary) 12%, transparent), color-mix(in srgb, var(--color-secondary) 8%, transparent))}.login-card[data-v-eec727a3]{-webkit-backdrop-filter:blur(40px)saturate(180%);box-shadow:var(--color-glass-shadow);background:color-mix(in srgb, var(--color-surface) 85%, transparent)}.dark .login-card[data-v-eec727a3]{background:color-mix(in srgb, var(--color-surface-elevated) 80%, transparent)}.input-glass[data-v-eec727a3]{-webkit-backdrop-filter:blur(20px);background:color-mix(in srgb, var(--color-surface) 90%, transparent);border:1px solid var(--color-border)}.dark .input-glass[data-v-eec727a3]{background:color-mix(in srgb, var(--color-surface-elevated) 55%, transparent);border-color:var(--color-border-subtle)}.input-glass[data-v-eec727a3]:focus{background:var(--color-surface)}.dark .input-glass[data-v-eec727a3]:focus{background:color-mix(in srgb, var(--color-surface-elevated) 70%, transparent)}.input-glass[data-v-eec727a3]:focus{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 0 20px color-mix(in srgb, var(--color-accent-cyan) 15%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.input-glow[data-v-eec727a3]{opacity:0;box-shadow:inset 0 1px 0 color-mix(in srgb, var(--color-surface) 35%, transparent);transition:opacity .3s}.input-glass:focus+.input-glow[data-v-eec727a3]{opacity:1;box-shadow:0 0 20px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-eec727a3]{-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-eec727a3]:before{content:"";background:linear-gradient(90deg, transparent 0%, color-mix(in srgb, var(--color-accent-cyan) 30%, transparent) 50%, transparent 100%);-webkit-mask:linear-gradient(var(--color-surface) 0 0) content-box, linear-gradient(var(--color-surface) 0 0);-webkit-mask-composite:xor;border-radius:12px;padding:1px;transition:transform 1s;position:absolute;inset:0;transform:translate(-100%);-webkit-mask-composite:xor;mask-composite:exclude}.button-glass[data-v-eec727a3]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-eec727a3]{box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 20%, transparent), 0 4px 16px color-mix(in srgb, var(--color-background) 35%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 45%, transparent)}.button-glass[data-v-eec727a3]:hover:not(:disabled){box-shadow:0 0 0 1px color-mix(in srgb, var(--color-accent-cyan) 40%, transparent), 0 0 30px color-mix(in srgb, var(--color-accent-cyan) 30%, transparent), 0 4px 20px color-mix(in srgb, var(--color-background) 45%, transparent), inset 0 1px 0 color-mix(in srgb, var(--color-surface) 55%, transparent)}@keyframes float-eec727a3{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-eec727a3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-eec727a3{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-eec727a3{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-eec727a3]{animation:8s ease-in-out infinite pulse-slow-eec727a3}.animate-pulse-slower[data-v-eec727a3]{animation:10s ease-in-out infinite pulse-slower-eec727a3}.animate-pulse-slowest[data-v-eec727a3]{animation:12s ease-in-out infinite pulse-slowest-eec727a3}@keyframes shake-eec727a3{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-eec727a3]{animation:.5s ease-in-out shake-eec727a3}.form-group[data-v-eec727a3]{position:relative}.form-group:hover label[data-v-eec727a3]{color:var(--color-accent-cyan);transition:color .3s}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`mb-6`},f={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={class:`text-content-secondary dark:text-content-primary/opacity-heavy text-base leading-relaxed`},g={class:`flex`},_=r({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(r,{emit:_}){let v=r,y=_,b={success:`bg-accent-green/opacity-light dark:bg-accent-green/opacity-medium border-accent-green/opacity-heavy dark:border-accent-green/opacity-medium text-accent-green`,error:`bg-accent-red/opacity-light dark:bg-accent-red/opacity-medium border-accent-red/opacity-medium text-accent-red`,info:`bg-primary/opacity-medium border-primary/opacity-medium text-primary`},x={success:`bg-accent-green/opacity-light hover:bg-accent-green/opacity-light`,error:`bg-accent-red/opacity-light hover:bg-accent-red/opacity-light`,info:`bg-primary/opacity-light hover:bg-primary/opacity-light`};return(r,_)=>(e(),n(a,{to:`body`},[v.show?(e(),c(`div`,{key:0,onClick:_[1]||=l(e=>y(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,b[v.variant]])},[v.variant===`success`?(e(),c(`svg`,f,[..._[2]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):v.variant===`error`?(e(),c(`svg`,p,[..._[3]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(e(),c(`svg`,m,[..._[4]||=[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)]]))],2),s(`p`,h,t(v.message),1)]),s(`div`,g,[s(`button`,{onClick:_[0]||=e=>y(`close`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[v.variant]])},` OK `,2)])])])):o(``,!0)]))}});export{_ as t};
@@ -1 +0,0 @@
import{T as e,_t as t,c as n,h as r,ht as i,i as a,l as o,s,u as c}from"./runtime-core.esm-bundler-CINEgm0a.js";import{l}from"./runtime-dom.esm-bundler-B3VeUO8l.js";var u={class:`modal-card max-w-md`},d={class:`mb-6`},f={key:0,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},p={key:1,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},m={key:2,class:`w-6 h-6`,fill:`none`,stroke:`currentColor`,viewBox:`0 0 24 24`},h={class:`text-content-secondary dark:text-content-primary/80 text-base leading-relaxed`},g={class:`flex`},_=r({__name:`MessageDialog`,props:{show:{type:Boolean},message:{},variant:{default:`success`}},emits:[`close`],setup(r,{emit:_}){let v=r,y=_,b={success:`bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400`,error:`bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400`,info:`bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400`},x={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(r,_)=>(e(),n(a,{to:`body`},[v.show?(e(),c(`div`,{key:0,onClick:_[1]||=l(e=>y(`close`),[`self`]),class:`modal-backdrop`},[s(`div`,u,[s(`div`,d,[s(`div`,{class:i([`inline-flex p-3 rounded-xl mb-4`,b[v.variant]])},[v.variant===`success`?(e(),c(`svg`,f,[..._[2]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M5 13l4 4L19 7`},null,-1)]])):v.variant===`error`?(e(),c(`svg`,p,[..._[3]||=[s(`path`,{"stroke-linecap":`round`,"stroke-linejoin":`round`,"stroke-width":`2`,d:`M6 18L18 6M6 6l12 12`},null,-1)]])):(e(),c(`svg`,m,[..._[4]||=[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)]]))],2),s(`p`,h,t(v.message),1)]),s(`div`,g,[s(`button`,{onClick:_[0]||=e=>y(`close`),class:i([`flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200`,x[v.variant]])},` OK `,2)])])])):o(``,!0)]))}});export{_ as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.modal-enter-active[data-v-e95d3629]{transition:all .3s cubic-bezier(.4,0,.2,1)}.modal-leave-active[data-v-e95d3629]{transition:all .2s ease-in}.modal-enter-from[data-v-e95d3629]{opacity:0;transform:scale(.95)translateY(-10px)}.modal-leave-to[data-v-e95d3629]{opacity:0;transform:scale(1.05)}.custom-scrollbar[data-v-e95d3629]{scrollbar-width:thin;scrollbar-color:color-mix(in srgb, var(--color-surface) 35%, transparent) transparent}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar{width:6px}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar-track{background:color-mix(in srgb, var(--color-surface) 12%, transparent);border-radius:3px}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar-thumb{background:color-mix(in srgb, var(--color-surface) 35%, transparent);border-radius:3px}.custom-scrollbar[data-v-e95d3629]::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb, var(--color-surface) 45%, transparent)}.glass-card[data-v-e95d3629]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px)}
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.modal-enter-active[data-v-1921baee]{transition:all .3s cubic-bezier(.4,0,.2,1)}.modal-leave-active[data-v-1921baee]{transition:all .2s ease-in}.modal-enter-from[data-v-1921baee]{opacity:0;transform:scale(.95)translateY(-10px)}.modal-leave-to[data-v-1921baee]{opacity:0;transform:scale(1.05)}.custom-scrollbar[data-v-1921baee]{scrollbar-width:thin;scrollbar-color:color-mix(in srgb, var(--color-surface) 35%, transparent) transparent}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar{width:6px}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar-track{background:color-mix(in srgb, var(--color-surface) 12%, transparent);border-radius:3px}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar-thumb{background:color-mix(in srgb, var(--color-surface) 35%, transparent);border-radius:3px}.custom-scrollbar[data-v-1921baee]::-webkit-scrollbar-thumb:hover{background:color-mix(in srgb, var(--color-surface) 45%, transparent)}.glass-card[data-v-1921baee]{-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px)}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.fade-enter-active[data-v-1709062a],.fade-leave-active[data-v-1709062a]{transition:opacity .2s}.fade-enter-from[data-v-1709062a],.fade-leave-to[data-v-1709062a]{opacity:0}
@@ -1 +0,0 @@
.fade-enter-active[data-v-2abc6745],.fade-leave-active[data-v-2abc6745]{transition:opacity .2s}.fade-enter-from[data-v-2abc6745],.fade-leave-to[data-v-2abc6745]{opacity:0}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{D as e,T as t,_t as n,h as r,ht as i,l as a,o,r as s,s as c,u as l}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as u}from"./system-BwYDm56e.js";import{t as d}from"./index-Cijj_ZXo.js";var f={class:`space-y-4`},p={class:`glass-card rounded-[15px] p-4 sm:p-6`},m={class:`mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4`},h={class:`text-xs uppercase tracking-wide text-content-muted`},g={class:`mt-2 text-lg font-semibold text-content-heading dark:text-white`},_={key:0,class:`glass-card rounded-[15px] p-5 text-content-muted`},v={class:`flex flex-wrap items-center justify-between gap-3`},y={class:`text-lg font-semibold text-content-heading dark:text-white`},b={class:`text-sm text-content-muted`},x={class:`mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2`},S={class:`text-sm`},C={class:`ml-2 text-content-heading dark:text-white`},w={key:0,class:`text-sm`},T={class:`ml-2 text-red-600 dark:text-red-300`},E={class:`mt-4 overflow-x-auto rounded-[12px] border border-stroke-subtle dark:border-white/10`},D={class:`min-w-full text-sm`},O={class:`px-3 py-2 font-medium text-content-heading dark:text-white`},k={class:`px-3 py-2 text-content-muted break-all`},A={key:0},j={key:1,class:`glass-card rounded-[15px] p-5 text-content-muted`},M=r({name:`SensorsView`,__name:`Sensors`,setup(r){let M=u(),N=o(()=>M.stats?.sensors??null),P=o(()=>N.value?.readings??[]),F=o(()=>{let e=N.value;return e?[{label:`Enabled`,value:e.enabled?`Yes`:`No`},{label:`Running`,value:e.running?`Yes`:`No`},{label:`Configured / Loaded`,value:`${e.configured??0} / ${e.loaded??0}`},{label:`Poll Interval`,value:typeof e.poll_interval_seconds==`number`?`${e.poll_interval_seconds.toFixed(1)}s`:`n/a`}]:[{label:`Enabled`,value:`n/a`},{label:`Running`,value:`n/a`},{label:`Configured`,value:`n/a`},{label:`Poll Interval`,value:`n/a`}]}),I=e=>{if(e==null)return`n/a`;if(typeof e==`boolean`)return e?`true`:`false`;if(typeof e==`number`)return Number.isFinite(e)?String(e):`n/a`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}},L=e=>{if(!e)return`n/a`;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()},R=async()=>{await M.fetchStats()};return d(async()=>{await M.fetchStats()},{intervalMs:1e4,immediate:!0}),(r,o)=>(t(),l(`div`,f,[c(`div`,p,[c(`div`,{class:`flex items-start justify-between gap-4`},[o[0]||=c(`div`,null,[c(`h1`,{class:`text-xl sm:text-2xl font-semibold text-content-heading dark:text-white`},`Sensors`),c(`p`,{class:`mt-1 text-sm text-content-muted`},` Live sensor summary from the existing stats API. `)],-1),c(`button`,{class:`rounded-[10px] border border-stroke-subtle dark:border-white/10 px-3 py-2 text-sm hover:bg-black/5 dark:hover:bg-white/5`,onClick:R},` Refresh `)]),c(`div`,m,[(t(!0),l(s,null,e(F.value,e=>(t(),l(`div`,{key:e.label,class:`rounded-[12px] border border-stroke-subtle dark:border-white/10 p-3`},[c(`p`,h,n(e.label),1),c(`p`,g,n(e.value),1)]))),128))])]),N.value?a(``,!0):(t(),l(`div`,_,` Sensor data is not available yet. Ensure the repeater has started and stats are loading. `)),(t(!0),l(s,null,e(P.value,(r,u)=>(t(),l(`div`,{key:`${r.name||`sensor`}-${u}`,class:`glass-card rounded-[15px] p-4 sm:p-5`},[c(`div`,v,[c(`div`,null,[c(`h2`,y,n(r.name||`Sensor ${u+1}`),1),c(`p`,b,`Type: `+n(r.type||`unknown`),1)]),c(`span`,{class:i([`rounded-full px-3 py-1 text-xs font-semibold`,r.ok?`bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300`:`bg-red-100 text-red-700 dark:bg-red-500/20 dark:text-red-300`])},n(r.ok?`OK`:`Error`),3)]),c(`div`,x,[c(`div`,S,[o[1]||=c(`span`,{class:`text-content-muted`},`Timestamp:`,-1),c(`span`,C,n(L(r.timestamp)),1)]),r.error?(t(),l(`div`,w,[o[2]||=c(`span`,{class:`text-content-muted`},`Error:`,-1),c(`span`,T,n(r.error),1)])):a(``,!0)]),c(`div`,E,[c(`table`,D,[o[4]||=c(`thead`,{class:`bg-black/5 dark:bg-white/5`},[c(`tr`,null,[c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Field`),c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Value`)])],-1),c(`tbody`,null,[(t(!0),l(s,null,e(r.data||{},(e,r)=>(t(),l(`tr`,{key:String(r),class:`border-t border-stroke-subtle dark:border-white/10`},[c(`td`,O,n(r),1),c(`td`,k,n(I(e)),1)]))),128)),!r.data||Object.keys(r.data).length===0?(t(),l(`tr`,A,[...o[3]||=[c(`td`,{class:`px-3 py-3 text-content-muted`,colspan:`2`},`No fields in payload`,-1)]])):a(``,!0)])])])]))),128)),N.value&&P.value.length===0?(t(),l(`div`,j,` Sensors are configured but no readings are available yet. `)):a(``,!0)]))}});export{M as default};
@@ -0,0 +1 @@
import{D as e,T as t,_t as n,h as r,ht as i,l as a,o,r as s,s as c,u as l}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as u}from"./system-BfRyusGC.js";import{t as d}from"./index-C_venKRo.js";var f={class:`space-y-4`},p={class:`glass-card rounded-[15px] p-4 sm:p-6`},m={class:`mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4`},h={class:`text-xs uppercase tracking-wide text-content-muted`},g={class:`mt-2 text-lg font-semibold text-content-heading`},_={key:0,class:`glass-card rounded-[15px] p-5 text-content-muted`},v={class:`flex flex-wrap items-center justify-between gap-3`},y={class:`text-lg font-semibold text-content-heading`},b={class:`text-sm text-content-muted`},x={class:`mt-3 grid grid-cols-1 gap-2 sm:grid-cols-2`},S={class:`text-sm`},C={class:`ml-2 text-content-heading`},w={key:0,class:`text-sm`},T={class:`ml-2 text-accent-red`},E={class:`mt-4 overflow-x-auto rounded-[12px] border border-stroke-subtle dark:border-white/opacity-light`},D={class:`min-w-full text-sm`},O={class:`px-3 py-2 font-medium text-content-heading`},k={class:`px-3 py-2 text-content-muted break-all`},A={key:0},j={key:1,class:`glass-card rounded-[15px] p-5 text-content-muted`},M=r({name:`SensorsView`,__name:`Sensors`,setup(r){let M=u(),N=o(()=>M.stats?.sensors??null),P=o(()=>N.value?.readings??[]),F=o(()=>{let e=N.value;return e?[{label:`Enabled`,value:e.enabled?`Yes`:`No`},{label:`Running`,value:e.running?`Yes`:`No`},{label:`Configured / Loaded`,value:`${e.configured??0} / ${e.loaded??0}`},{label:`Poll Interval`,value:typeof e.poll_interval_seconds==`number`?`${e.poll_interval_seconds.toFixed(1)}s`:`n/a`}]:[{label:`Enabled`,value:`n/a`},{label:`Running`,value:`n/a`},{label:`Configured`,value:`n/a`},{label:`Poll Interval`,value:`n/a`}]}),I=e=>{if(e==null)return`n/a`;if(typeof e==`boolean`)return e?`true`:`false`;if(typeof e==`number`)return Number.isFinite(e)?String(e):`n/a`;if(typeof e==`string`)return e;try{return JSON.stringify(e)}catch{return String(e)}},L=e=>{if(!e)return`n/a`;let t=new Date(e);return Number.isNaN(t.getTime())?e:t.toLocaleString()},R=async()=>{await M.fetchStats()};return d(async()=>{await M.fetchStats()},{intervalMs:1e4,immediate:!0}),(r,o)=>(t(),l(`div`,f,[c(`div`,p,[c(`div`,{class:`flex items-start justify-between gap-4`},[o[0]||=c(`div`,null,[c(`h1`,{class:`text-xl sm:text-2xl font-semibold text-content-heading`},`Sensors`),c(`p`,{class:`mt-1 text-sm text-content-muted`},` Live sensor summary from the existing stats API. `)],-1),c(`button`,{class:`rounded-[10px] border border-stroke-subtle dark:border-white/opacity-light px-3 py-2 text-sm hover:bg-black/opacity-light dark:hover:bg-white/opacity-light`,onClick:R},` Refresh `)]),c(`div`,m,[(t(!0),l(s,null,e(F.value,e=>(t(),l(`div`,{key:e.label,class:`rounded-[12px] border border-stroke-subtle dark:border-white/opacity-light p-3`},[c(`p`,h,n(e.label),1),c(`p`,g,n(e.value),1)]))),128))])]),N.value?a(``,!0):(t(),l(`div`,_,` Sensor data is not available yet. Ensure the repeater has started and stats are loading. `)),(t(!0),l(s,null,e(P.value,(r,u)=>(t(),l(`div`,{key:`${r.name||`sensor`}-${u}`,class:`glass-card rounded-[15px] p-4 sm:p-5`},[c(`div`,v,[c(`div`,null,[c(`h2`,y,n(r.name||`Sensor ${u+1}`),1),c(`p`,b,`Type: `+n(r.type||`unknown`),1)]),c(`span`,{class:i([`rounded-full px-3 py-1 text-xs font-semibold`,r.ok?`bg-accent-green/opacity-light text-accent-green dark:bg-accent-green/opacity-medium dark:text-accent-green`:`bg-accent-red/opacity-light text-accent-red dark:bg-accent-red/opacity-medium dark:text-accent-red`])},n(r.ok?`OK`:`Error`),3)]),c(`div`,x,[c(`div`,S,[o[1]||=c(`span`,{class:`text-content-muted`},`Timestamp:`,-1),c(`span`,C,n(L(r.timestamp)),1)]),r.error?(t(),l(`div`,w,[o[2]||=c(`span`,{class:`text-content-muted`},`Error:`,-1),c(`span`,T,n(r.error),1)])):a(``,!0)]),c(`div`,E,[c(`table`,D,[o[4]||=c(`thead`,{class:`bg-black/opacity-light dark:bg-white/opacity-subtle`},[c(`tr`,null,[c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Field`),c(`th`,{class:`px-3 py-2 text-left text-content-muted`},`Value`)])],-1),c(`tbody`,null,[(t(!0),l(s,null,e(r.data||{},(e,r)=>(t(),l(`tr`,{key:String(r),class:`border-t border-stroke-subtle dark:border-white/opacity-light`},[c(`td`,O,n(r),1),c(`td`,k,n(I(e)),1)]))),128)),!r.data||Object.keys(r.data).length===0?(t(),l(`tr`,A,[...o[3]||=[c(`td`,{class:`px-3 py-3 text-content-muted`,colspan:`2`},`No fields in payload`,-1)]])):a(``,!0)])])])]))),128)),N.value&&P.value.length===0?(t(),l(`div`,j,` Sensors are configured but no readings are available yet. `)):a(``,!0)]))}});export{M as default};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
.glass-card[data-v-5bdf00ba]{background:color-mix(in srgb, var(--color-surface) 45%, transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-border-subtle)}.setup-dialog[data-v-5bdf00ba]{box-shadow:var(--color-glass-shadow)}.modal-enter-active[data-v-5bdf00ba],.modal-leave-active[data-v-5bdf00ba]{transition:opacity .3s}.modal-enter-from[data-v-5bdf00ba],.modal-leave-to[data-v-5bdf00ba]{opacity:0}.modal-enter-active .glass-card[data-v-5bdf00ba],.modal-leave-active .glass-card[data-v-5bdf00ba]{transition:transform .3s}.modal-enter-from .glass-card[data-v-5bdf00ba],.modal-leave-to .glass-card[data-v-5bdf00ba]{transform:scale(.9)}.slide-enter-active[data-v-5bdf00ba],.slide-leave-active[data-v-5bdf00ba]{transition:all .3s}.slide-enter-from[data-v-5bdf00ba],.slide-leave-to[data-v-5bdf00ba]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-5bdf00ba{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-5bdf00ba{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-5bdf00ba{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-5bdf00ba]{will-change:transform, opacity;animation:15s ease-in-out infinite float-slow-5bdf00ba}.animate-pulse-slower[data-v-5bdf00ba]{will-change:transform, opacity;animation:18s ease-in-out infinite float-slower-5bdf00ba}.animate-pulse-slowest[data-v-5bdf00ba]{will-change:transform, opacity;animation:20s ease-in-out infinite float-slowest-5bdf00ba}
@@ -1 +0,0 @@
.glass-card[data-v-ecd1d451]{background:color-mix(in srgb, var(--color-surface) 45%, transparent);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid var(--color-border-subtle)}.setup-dialog[data-v-ecd1d451]{box-shadow:var(--color-glass-shadow)}.modal-enter-active[data-v-ecd1d451],.modal-leave-active[data-v-ecd1d451]{transition:opacity .3s}.modal-enter-from[data-v-ecd1d451],.modal-leave-to[data-v-ecd1d451]{opacity:0}.modal-enter-active .glass-card[data-v-ecd1d451],.modal-leave-active .glass-card[data-v-ecd1d451]{transition:transform .3s}.modal-enter-from .glass-card[data-v-ecd1d451],.modal-leave-to .glass-card[data-v-ecd1d451]{transform:scale(.9)}.slide-enter-active[data-v-ecd1d451],.slide-leave-active[data-v-ecd1d451]{transition:all .3s}.slide-enter-from[data-v-ecd1d451],.slide-leave-to[data-v-ecd1d451]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-ecd1d451{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-ecd1d451{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-ecd1d451{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-ecd1d451]{will-change:transform, opacity;animation:15s ease-in-out infinite float-slow-ecd1d451}.animate-pulse-slower[data-v-ecd1d451]{will-change:transform, opacity;animation:18s ease-in-out infinite float-slower-ecd1d451}.animate-pulse-slowest[data-v-ecd1d451]{will-change:transform, opacity;animation:20s ease-in-out infinite float-slowest-ecd1d451}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{D as e,T as t,h as n,ht as r,o as i,r as a,s as o,u as s}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as c}from"./system-BwYDm56e.js";var l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},u=-116,d=8,f=5;function p(e,t){return e-t}function m(e){return l[e]??l[d]}function h(e,t){let n=t+f;if(e<=t){let n=e<=t-5?0:1;return{bars:n,color:`text-red-600 dark:text-red-400`,bgColor:`bg-accent-red`,snr:e,quality:n===0?`None`:`Poor`}}if(e<n){let n=(e-t)/f<.5?2:3;return{bars:n,color:n===2?`text-orange-600 dark:text-orange-400`:`text-yellow-600 dark:text-yellow-400`,bgColor:n===2?`bg-orange-600 dark:bg-orange-400`:`bg-yellow-600 dark:bg-yellow-400`,snr:e,quality:`Fair`}}let r=e-n>=10?5:4;return{bars:r,color:r===5?`text-green-600 dark:text-green-400`:`text-green-600 dark:text-green-300`,bgColor:`bg-accent-green`,snr:e,quality:r===5?`Excellent`:`Good`}}function g(){let e=c(),t=i(()=>e.noiseFloorDbm??u),n=i(()=>e.stats?.config?.radio?.spreading_factor??d),r=i(()=>m(n.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-gray-400 dark:text-gray-500`,bgColor:`bg-gray-400 dark:bg-gray-500`,snr:-999,quality:`None`};let n=p(e,t.value);return h(Math.max(-30,Math.min(20,n)),r.value)},noiseFloor:t,spreadingFactor:n,minSNR:r}}var _={class:`flex items-end gap-0.5`},v=n({name:`SignalBars`,__name:`SignalBars`,props:{bars:{},color:{},size:{default:`sm`}},setup(n){let i=n,c={sm:[`h-1.5`,`h-2`,`h-2.5`,`h-3`,`h-3.5`],md:[`h-2`,`h-2.5`,`h-3`,`h-3.5`,`h-4`]},l={sm:`w-1`,md:`w-1.5`};return(n,u)=>(t(),s(`div`,_,[(t(),s(a,null,e(5,e=>o(`div`,{key:e,class:r([`transition-colors`,l[i.size],c[i.size][e-1],e<=i.bars?i.color:`text-content-muted`])},[...u[0]||=[o(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],2)),64))]))}});export{g as n,v as t};
@@ -0,0 +1 @@
import{D as e,T as t,h as n,ht as r,o as i,r as a,s as o,u as s}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as c}from"./system-BfRyusGC.js";var l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},u=-116,d=8,f=4,p=5;function m(e,t){return e-t}function h(e){return l[e]??l[d]}function g(e,t){if(e<t)return{bars:0,color:`text-accent-red`,bgColor:`bg-accent-red`,snr:e,quality:`None`};let n=Math.min(p,Math.floor((e-t)/f)+1);return{bars:n,snr:e,...{1:{color:`text-accent-red`,bgColor:`bg-accent-red`,quality:`Poor`},2:{color:`text-accent-orange`,bgColor:`bg-accent-orange`,quality:`Poor`},3:{color:`text-accent-amber`,bgColor:`bg-accent-amber`,quality:`Fair`},4:{color:`text-accent-green-light`,bgColor:`bg-accent-green-light`,quality:`Good`},5:{color:`text-accent-green`,bgColor:`bg-accent-green`,quality:`Excellent`}}[n]}}function _(){let e=c(),t=i(()=>e.noiseFloorDbm??u),n=i(()=>e.stats?.config?.radio?.spreading_factor??d),r=i(()=>h(n.value));return{getSignalQuality:e=>{if(!e||e>0||e<-120)return{bars:0,color:`text-content-muted`,bgColor:`bg-content-muted`,snr:-999,quality:`None`};let n=m(e,t.value);return g(Math.max(-30,Math.min(20,n)),r.value)},getSignalQualityFromSNR:e=>e===null||!Number.isFinite(e)?{bars:0,color:`text-content-muted`,bgColor:`bg-content-muted`,snr:-999,quality:`None`}:g(Math.max(-30,Math.min(20,e)),r.value),noiseFloor:t,spreadingFactor:n,minSNR:r}}var v={class:`flex items-end gap-0.5`},y=n({name:`SignalBars`,__name:`SignalBars`,props:{bars:{},color:{},size:{default:`sm`}},setup(n){let i=n,c={sm:[`h-1.5`,`h-2`,`h-2.5`,`h-3`,`h-3.5`],md:[`h-2`,`h-2.5`,`h-3`,`h-3.5`,`h-4`]},l={sm:`w-1`,md:`w-1.5`};return(n,u)=>(t(),s(`div`,v,[(t(),s(a,null,e(5,e=>o(`div`,{key:e,class:r([`transition-colors`,l[i.size],c[i.size][e-1],e<=i.bars?i.color:`text-content-muted`])},[...u[0]||=[o(`div`,{class:`w-full h-full bg-current rounded-sm`},null,-1)]],2)),64))]))}});export{_ as n,y as t};
@@ -1 +1 @@
import{T as e,_t as t,c as n,gt as r,h as i,l as a,m as o,o as s,p as c,r as l,s as u,u as d}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as f}from"./Spinner-CMJUE3iy.js";import{d as p}from"./index-Cijj_ZXo.js";var m={class:`sparkline-card`},h={class:`card-header`},g={class:`card-title`},_={class:`card-subtitle`},v={key:0,class:`card-chart`},y={key:0,class:`chart-loader`},b={key:1,class:`chart-error`},x={key:2,class:`chart-text`},S={class:`percent-value`},C=[`id`,`viewBox`],w=[`d`,`fill`],T=[`d`,`stroke`],E=100,D=40,O=p(i({name:`SparklineChart`,__name:`Sparkline`,props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:`smooth`},loading:{type:Boolean,default:!1},error:{default:null},centerText:{default:``},subtitle:{default:``},minY:{default:void 0},maxY:{default:void 0}},emits:[`retry`],setup(i,{emit:p}){let O=i,k=p,A=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;r<e.length;r++){let i=Math.floor(t/2),a=Math.max(0,r-i),o=Math.min(e.length,r+i+1),s=e.slice(a,o);n.push(s.reduce((e,t)=>e+t,0)/s.length)}let r=Math.min(10,n.length),i=n.length/r,a=[];for(let e=0;e<r;e++){let t=Math.floor(e*i);a.push(n[t])}return a},j=s(()=>!O.data||O.data.length===0?[]:O.variant===`smooth`?A(O.data):O.data),M=e=>{if(e.length<2)return``;let t=O.maxY??Math.max(...e),n=O.minY??Math.min(...e),r=t-n||1,i=O.variant===`classic`?4:2,a=``;return e.forEach((t,o)=>{let s=o/(e.length-1)*E,c=(t-n)/r,l=i+(D-i*2)*(1-c);if(o===0)a+=`M ${s.toFixed(2)} ${l.toFixed(2)}`;else{let t=((o-1)/(e.length-1)*E+s)/2;a+=` Q ${t.toFixed(2)} ${l.toFixed(2)} ${s.toFixed(2)} ${l.toFixed(2)}`}}),a},N=s(()=>M(j.value)),P=s(()=>N.value?`${N.value} L ${E} ${D} L 0 ${D} Z`:``),F=s(()=>`sparkline-${O.title.replace(/\s+/g,`-`).toLowerCase()}`);return(s,p)=>(e(),d(`div`,m,[u(`div`,h,[u(`div`,null,[u(`p`,g,t(i.title),1),u(`p`,_,t(i.subtitle),1)]),u(`span`,{class:`card-value`,style:r({color:i.color})},[i.loading?(e(),n(f,{key:0,size:`sm`,color:`current`})):(e(),d(l,{key:1},[c(t(typeof i.value==`number`?i.value.toLocaleString():i.value),1)],64))],4)]),i.showChart?(e(),d(`div`,v,[i.loading&&i.variant===`classic`?(e(),d(`div`,y,[o(f,{size:`sm`})])):i.error?(e(),d(`div`,b,[u(`button`,{class:`chart-retry-btn`,onClick:p[0]||=e=>k(`retry`)},`↺ Retry`)])):i.centerText?(e(),d(`div`,x,[u(`span`,S,t(i.centerText),1)])):(e(),d(`svg`,{key:3,id:F.value,class:`chart-svg`,viewBox:`0 0 ${E} ${D}`,preserveAspectRatio:`none`},[i.variant===`classic`?(e(),d(l,{key:0},[j.value.length>1?(e(),d(`path`,{key:0,d:P.value,fill:i.color,"fill-opacity":`0.8`,class:`sparkline-path`},null,8,w)):a(``,!0)],64)):(e(),d(l,{key:1},[j.value.length>1?(e(),d(`path`,{key:0,d:N.value,stroke:i.color,"stroke-width":`2.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`,fill:`none`,class:`sparkline-path`},null,8,T)):a(``,!0)],64))],8,C))])):a(``,!0)]))}}),[[`__scopeId`,`data-v-eb0d809d`]]);export{O as t};
import{T as e,_t as t,c as n,gt as r,h as i,l as a,m as o,o as s,p as c,r as l,s as u,u as d}from"./runtime-core.esm-bundler-CINEgm0a.js";import{t as f}from"./Spinner-CMJUE3iy.js";import{d as p}from"./index-C_venKRo.js";var m={class:`sparkline-card`},h={class:`card-header`},g={class:`card-title`},_={class:`card-subtitle`},v={key:0,class:`card-chart`},y={key:0,class:`chart-loader`},b={key:1,class:`chart-error`},x={key:2,class:`chart-text`},S={class:`percent-value`},C=[`id`,`viewBox`],w=[`d`,`fill`],T=[`d`,`stroke`],E=100,D=40,O=p(i({name:`SparklineChart`,__name:`Sparkline`,props:{title:{},value:{},color:{},data:{default:()=>[]},showChart:{type:Boolean,default:!0},variant:{default:`smooth`},loading:{type:Boolean,default:!1},error:{default:null},centerText:{default:``},subtitle:{default:``},minY:{default:void 0},maxY:{default:void 0}},emits:[`retry`],setup(i,{emit:p}){let O=i,k=p,A=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;r<e.length;r++){let i=Math.floor(t/2),a=Math.max(0,r-i),o=Math.min(e.length,r+i+1),s=e.slice(a,o);n.push(s.reduce((e,t)=>e+t,0)/s.length)}let r=Math.min(10,n.length),i=n.length/r,a=[];for(let e=0;e<r;e++){let t=Math.floor(e*i);a.push(n[t])}return a},j=s(()=>!O.data||O.data.length===0?[]:O.variant===`smooth`?A(O.data):O.data),M=e=>{if(e.length<2)return``;let t=O.maxY??Math.max(...e),n=O.minY??Math.min(...e),r=t-n||1,i=O.variant===`classic`?4:2,a=``;return e.forEach((t,o)=>{let s=o/(e.length-1)*E,c=(t-n)/r,l=i+(D-i*2)*(1-c);if(o===0)a+=`M ${s.toFixed(2)} ${l.toFixed(2)}`;else{let t=((o-1)/(e.length-1)*E+s)/2;a+=` Q ${t.toFixed(2)} ${l.toFixed(2)} ${s.toFixed(2)} ${l.toFixed(2)}`}}),a},N=s(()=>M(j.value)),P=s(()=>N.value?`${N.value} L ${E} ${D} L 0 ${D} Z`:``),F=s(()=>`sparkline-${O.title.replace(/\s+/g,`-`).toLowerCase()}`);return(s,p)=>(e(),d(`div`,m,[u(`div`,h,[u(`div`,null,[u(`p`,g,t(i.title),1),u(`p`,_,t(i.subtitle),1)]),u(`span`,{class:`card-value`,style:r({color:i.color})},[i.loading?(e(),n(f,{key:0,size:`sm`,color:`current`})):(e(),d(l,{key:1},[c(t(typeof i.value==`number`?i.value.toLocaleString():i.value),1)],64))],4)]),i.showChart?(e(),d(`div`,v,[i.loading&&i.variant===`classic`?(e(),d(`div`,y,[o(f,{size:`sm`})])):i.error?(e(),d(`div`,b,[u(`button`,{class:`chart-retry-btn`,onClick:p[0]||=e=>k(`retry`)},`↺ Retry`)])):i.centerText?(e(),d(`div`,x,[u(`span`,S,t(i.centerText),1)])):(e(),d(`svg`,{key:3,id:F.value,class:`chart-svg`,viewBox:`0 0 ${E} ${D}`,preserveAspectRatio:`none`},[i.variant===`classic`?(e(),d(l,{key:0},[j.value.length>1?(e(),d(`path`,{key:0,d:P.value,fill:i.color,"fill-opacity":`0.8`,class:`sparkline-path`},null,8,w)):a(``,!0)],64)):(e(),d(l,{key:1},[j.value.length>1?(e(),d(`path`,{key:0,d:N.value,stroke:i.color,"stroke-width":`2.5`,"stroke-linecap":`round`,"stroke-linejoin":`round`,fill:`none`,class:`sparkline-path`},null,8,T)):a(``,!0)],64))],8,C))])):a(``,!0)]))}}),[[`__scopeId`,`data-v-eb0d809d`]]);export{O as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
.chart-updating[data-v-e3234fe9]{animation:.8s ease-in-out subtle-pulse-e3234fe9}@keyframes subtle-pulse-e3234fe9{0%{transform:scale(1)}50%{transform:scale(1.02)}to{transform:scale(1)}}.process-row[data-v-e3234fe9]{transition:all .3s}.process-row[data-v-e3234fe9]:hover{background:color-mix(in srgb, var(--color-background) 15%, transparent);transform:translate(2px)}.dark .process-row[data-v-e3234fe9]:hover{background:color-mix(in srgb, var(--color-surface) 20%, transparent)}.process-row-enter-active[data-v-e3234fe9],.process-row-leave-active[data-v-e3234fe9]{transition:all .4s}.process-row-enter-from[data-v-e3234fe9]{opacity:0;transform:translateY(-10px)scale(.95)}.process-row-leave-to[data-v-e3234fe9]{opacity:0;transform:translateY(10px)scale(.95)}.process-row-move[data-v-e3234fe9]{transition:transform .4s}.cpu-value[data-v-e3234fe9],.memory-value[data-v-e3234fe9]{border-radius:4px;padding:2px 6px;transition:all .3s}.cpu-value[data-v-e3234fe9]:hover,.memory-value[data-v-e3234fe9]:hover{background:color-mix(in srgb, var(--color-secondary) 10%, transparent);transform:scale(1.05)}@keyframes value-update-e3234fe9{0%{background:color-mix(in srgb, var(--color-secondary) 30%, transparent)}to{background:0 0}}.value-updated[data-v-e3234fe9]{animation:.6s ease-out value-update-e3234fe9}
@@ -0,0 +1 @@
.chart-updating[data-v-66d09830]{animation:.8s ease-in-out subtle-pulse-66d09830}@keyframes subtle-pulse-66d09830{0%{transform:scale(1)}50%{transform:scale(1.02)}to{transform:scale(1)}}.process-row[data-v-66d09830]{transition:all .3s}.process-row[data-v-66d09830]:hover{background:color-mix(in srgb, var(--color-background) 15%, transparent);transform:translate(2px)}.dark .process-row[data-v-66d09830]:hover{background:color-mix(in srgb, var(--color-surface) 20%, transparent)}.process-row-enter-active[data-v-66d09830],.process-row-leave-active[data-v-66d09830]{transition:all .4s}.process-row-enter-from[data-v-66d09830]{opacity:0;transform:translateY(-10px)scale(.95)}.process-row-leave-to[data-v-66d09830]{opacity:0;transform:translateY(10px)scale(.95)}.process-row-move[data-v-66d09830]{transition:transform .4s}.cpu-value[data-v-66d09830],.memory-value[data-v-66d09830]{border-radius:4px;padding:2px 6px;transition:all .3s}.cpu-value[data-v-66d09830]:hover,.memory-value[data-v-66d09830]:hover{background:color-mix(in srgb, var(--color-secondary) 10%, transparent);transform:scale(1.05)}@keyframes value-update-66d09830{0%{background:color-mix(in srgb, var(--color-secondary) 30%, transparent)}to{background:0 0}}.value-updated[data-v-66d09830]{animation:.6s ease-out value-update-66d09830}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{t as e}from"./dataService-5Ok9aIVh.js";export{e as useDataService};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{t as e}from"./dataService-Cr4DtwcP.js";export{e as useDataService};
@@ -1 +0,0 @@
function e(e){return e==null?`N/A`:`${e} dBm`}function t(e){return e==null?`N/A`:`${e.toFixed(1)} dB`}var n={0:`Transport Flood`,1:`Flood`,2:`Direct`,3:`Transport Direct`};function r(e){return e==null?`Unknown`:n[e]??`Unknown`}function i(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`}}}function a(e){return new Date(e*1e3).toLocaleString()}function o(e){return e?new Date(e*1e3).toLocaleDateString(void 0,{month:`short`,day:`numeric`,year:`numeric`}):``}function s(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 c(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 l(e){return`${e.toFixed(1)} km`}function u(e){return`${e.slice(0,4)}...${e.slice(-4)}`}function d(e){return e?e.length<=16?e:`${e.slice(0,8)}...${e.slice(-8)}`:`No key`}export{e as a,s as c,d,u as i,a as l,o as n,r as o,l as r,t as s,c as t,i as u};
@@ -0,0 +1 @@
function e(e){return e==null?`N/A`:`${e} dBm`}function t(e){return e==null?`N/A`:`${e.toFixed(1)} dB`}var n={0:`Transport Flood`,1:`Flood`,2:`Direct`,3:`Transport Direct`};function r(e){return e==null?`Unknown`:n[e]??`Unknown`}function i(e){switch(e){case 2:return{text:`Direct`,bgColor:`bg-accent-green/opacity-light`,borderColor:`border-accent-green/opacity-medium`,textColor:`text-accent-green`};case 3:return{text:`Transport Direct`,bgColor:`bg-accent-green/opacity-medium`,borderColor:`border-accent-green/opacity-medium`,textColor:`text-accent-green`};case 1:return{text:`Flood`,bgColor:`bg-accent-amber/opacity-medium`,borderColor:`border-accent-amber/opacity-medium`,textColor:`text-accent-amber`};case 0:return{text:`Transport Flood`,bgColor:`bg-accent-orange/opacity-medium`,borderColor:`border-accent-orange/opacity-medium`,textColor:`text-accent-orange`};default:return{text:`Unknown`,bgColor:`bg-background-mute`,borderColor:`border-stroke-subtle`,textColor:`text-content-muted`}}}function a(e){return new Date(e*1e3).toLocaleString()}function o(e){return e?new Date(e*1e3).toLocaleDateString(void 0,{month:`short`,day:`numeric`,year:`numeric`}):``}function s(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 c(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 l(e){return`${e.toFixed(1)} km`}function u(e){return`${e.slice(0,4)}...${e.slice(-4)}`}function d(e){return e?e.length<=16?e:`${e.slice(0,8)}...${e.slice(-8)}`:`No key`}export{e as a,s as c,d,u as i,a as l,o as n,r as o,l as r,t as s,c as t,i as u};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
import{t as e}from"./packets-DVGync2A.js";export{e as usePacketStore};

Some files were not shown because too many files have changed in this diff Show More