mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-05-09 06:54:28 +02:00
Changes include:
Features Neighbour details modal with full info and map view WebSocket support with heartbeat and automatic reconnection Improved signal quality calculations (SNR-based RSSI) Route-based pagination for faster initial loads UI Mobile sidebar tweaks (logout, version info, lazy-loaded charts) Sorting added to the neighbour table Packet view now shows multi-hop paths CAD calibration charts respect light/dark themes Statistics charts now show the full requested time range Performance Reduced polling when WebSocket is active Lazy loading for heavier components Noise floor data capped to keep charts responsive Technical Improved type safety across API responses Contrast improvements for accessibility Cleaner WebSocket and MQTT reconnection handling Additional metrics added to heartbeat stats Bug fixes Corrected noise floor history query Fixed authentication for CAD calibration streams Nothing major required from users — just update and carry on. As always, shout if something looks off.
This commit is contained in:
@@ -236,6 +236,13 @@ install_repeater() {
|
||||
echo " Generated version: $GENERATED_VERSION"
|
||||
fi
|
||||
|
||||
echo "29"; echo "# Cleaning old installation files..."
|
||||
# Remove old repeater directory to ensure clean install
|
||||
rm -rf "$INSTALL_DIR/repeater" 2>/dev/null || true
|
||||
# Clean up old Python bytecode
|
||||
find "$INSTALL_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$INSTALL_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||
|
||||
echo "30"; echo "# Installing files..."
|
||||
cp -r "$SCRIPT_DIR/repeater" "$INSTALL_DIR/"
|
||||
cp "$SCRIPT_DIR/pyproject.toml" "$INSTALL_DIR/"
|
||||
@@ -411,6 +418,14 @@ upgrade_repeater() {
|
||||
fi
|
||||
echo " ✓ Version file generated"
|
||||
|
||||
echo "[3.8/9] Cleaning old installation files..."
|
||||
# Remove old repeater directory to ensure clean upgrade
|
||||
rm -rf "$INSTALL_DIR/repeater" 2>/dev/null || true
|
||||
# Clean up old Python bytecode
|
||||
find "$INSTALL_DIR" -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
find "$INSTALL_DIR" -type f -name '*.pyc' -delete 2>/dev/null || true
|
||||
echo " ✓ Old files cleaned"
|
||||
|
||||
echo "[4/9] Installing new files..."
|
||||
cp -r repeater "$INSTALL_DIR/" 2>/dev/null || true
|
||||
cp pyproject.toml "$INSTALL_DIR/" 2>/dev/null || true
|
||||
@@ -465,8 +480,18 @@ EOF
|
||||
# Suppress pip root user warnings
|
||||
export PIP_ROOT_USER_ACTION=ignore
|
||||
|
||||
# First, upgrade the package and dependencies (only updates what needs updating)
|
||||
if python3 -m pip install --break-system-packages --upgrade --no-cache-dir .; then
|
||||
# Calculate version from git for setuptools_scm
|
||||
if [ -d .git ]; then
|
||||
git fetch --tags 2>/dev/null || true
|
||||
GIT_VERSION=$(python3 -m setuptools_scm 2>/dev/null || echo "1.0.5")
|
||||
export SETUPTOOLS_SCM_PRETEND_VERSION="$GIT_VERSION"
|
||||
echo "Upgrading to version: $GIT_VERSION"
|
||||
else
|
||||
export SETUPTOOLS_SCM_PRETEND_VERSION="1.0.5"
|
||||
fi
|
||||
|
||||
# Force reinstall the package and all dependencies for clean upgrade
|
||||
if python3 -m pip install --break-system-packages --force-reinstall --no-cache-dir --ignore-installed .; then
|
||||
echo ""
|
||||
echo "✓ Package and dependencies updated successfully!"
|
||||
else
|
||||
@@ -474,28 +499,10 @@ EOF
|
||||
echo "⚠ Package update failed, but continuing..."
|
||||
fi
|
||||
|
||||
# Force reinstall pymc_core to ensure it's always updated
|
||||
# Extract the pymc_core dependency from pyproject.toml
|
||||
# Note: pymc_core is already reinstalled as part of the full --force-reinstall above
|
||||
echo ""
|
||||
echo "Ensuring pymc_core is up to date..."
|
||||
PYMC_CORE_DEP=$(grep -oP '"pymc_core\[hardware\][^"]*"' pyproject.toml 2>/dev/null | tr -d '"' || echo "")
|
||||
if [ -n "$PYMC_CORE_DEP" ]; then
|
||||
# Check if it's a Git URL (contains @)
|
||||
if [[ "$PYMC_CORE_DEP" == *" @ "* ]]; then
|
||||
# Extract just the URL part after " @ "
|
||||
PYMC_CORE_SPEC="${PYMC_CORE_DEP#* @ }"
|
||||
else
|
||||
# Just the package name, use as-is
|
||||
PYMC_CORE_SPEC="$PYMC_CORE_DEP"
|
||||
fi
|
||||
if python3 -m pip install --break-system-packages --force-reinstall --no-cache-dir --no-deps "$PYMC_CORE_SPEC"; then
|
||||
echo "✓ pymc_core updated successfully!"
|
||||
else
|
||||
echo "⚠ pymc_core update failed, but continuing..."
|
||||
fi
|
||||
else
|
||||
echo "⚠ Could not find pymc_core dependency in pyproject.toml"
|
||||
fi
|
||||
echo "✓ All packages including pymc_core reinstalled successfully"
|
||||
|
||||
|
||||
echo "[8/9] Starting service..."
|
||||
systemctl daemon-reload
|
||||
|
||||
@@ -38,6 +38,7 @@ dependencies = [
|
||||
"cherrypy-cors==1.7.0",
|
||||
"psutil>=5.9.0",
|
||||
"pyjwt>=2.8.0",
|
||||
"ws4py>=0.6.0",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ class _BrokerConnection:
|
||||
self._connect_time = None
|
||||
self._tls_verified = False
|
||||
self._running = False
|
||||
self._reconnect_attempts = 0
|
||||
self._reconnect_timer = None
|
||||
self._max_reconnect_delay = 300 # 5 minutes max
|
||||
|
||||
# MQTT WebSocket client - unique client ID per broker
|
||||
client_id = f"meshcore_{self.public_key}_{broker['host']}"
|
||||
@@ -118,18 +121,52 @@ class _BrokerConnection:
|
||||
if rc == 0:
|
||||
logging.info(f"Connected to {self.broker['name']}")
|
||||
self._running = True
|
||||
self._reconnect_attempts = 0 # Reset counter on success
|
||||
if self._on_connect_callback:
|
||||
self._on_connect_callback(self.broker["name"])
|
||||
else:
|
||||
logging.error(f"Failed to connect to {self.broker['name']} (rc={rc})")
|
||||
self._schedule_reconnect()
|
||||
|
||||
def _on_disconnect(self, client, userdata, rc):
|
||||
"""MQTT disconnection callback"""
|
||||
logging.warning(f"Disconnected from {self.broker['name']} (rc={rc})")
|
||||
was_running = self._running
|
||||
self._running = False
|
||||
|
||||
if rc != 0: # Unexpected disconnect
|
||||
logging.warning(f"Disconnected from {self.broker['name']} (rc={rc})")
|
||||
if was_running: # Only reconnect if we were intentionally connected
|
||||
self._schedule_reconnect()
|
||||
else:
|
||||
logging.info(f"Clean disconnect from {self.broker['name']}")
|
||||
|
||||
if self._on_disconnect_callback:
|
||||
self._on_disconnect_callback(self.broker["name"])
|
||||
|
||||
def _schedule_reconnect(self):
|
||||
"""Schedule reconnection with exponential backoff"""
|
||||
if self._reconnect_timer:
|
||||
self._reconnect_timer.cancel()
|
||||
|
||||
# Exponential backoff: 5s, 10s, 20s, 40s, 80s, up to max
|
||||
delay = min(5 * (2 ** self._reconnect_attempts), self._max_reconnect_delay)
|
||||
self._reconnect_attempts += 1
|
||||
|
||||
logging.info(f"Scheduling reconnect to {self.broker['name']} in {delay}s (attempt {self._reconnect_attempts})")
|
||||
self._reconnect_timer = threading.Timer(delay, self._attempt_reconnect)
|
||||
self._reconnect_timer.daemon = True
|
||||
self._reconnect_timer.start()
|
||||
|
||||
def _attempt_reconnect(self):
|
||||
"""Attempt to reconnect to broker"""
|
||||
try:
|
||||
logging.info(f"Attempting reconnection to {self.broker['name']}...")
|
||||
self.refresh_jwt_token() # Refresh token before reconnecting
|
||||
self.client.reconnect()
|
||||
except Exception as e:
|
||||
logging.error(f"Reconnection failed for {self.broker['name']}: {e}")
|
||||
self._schedule_reconnect() # Try again later
|
||||
|
||||
def refresh_jwt_token(self):
|
||||
"""Refresh JWT token for MQTT authentication"""
|
||||
token = self._generate_jwt()
|
||||
@@ -165,6 +202,12 @@ class _BrokerConnection:
|
||||
def disconnect(self):
|
||||
"""Disconnect from broker"""
|
||||
self._running = False
|
||||
|
||||
# Cancel any pending reconnection
|
||||
if self._reconnect_timer:
|
||||
self._reconnect_timer.cancel()
|
||||
self._reconnect_timer = None
|
||||
|
||||
self.client.loop_stop()
|
||||
self.client.disconnect()
|
||||
logging.info(f"Disconnected from {self.broker['name']}")
|
||||
|
||||
@@ -766,22 +766,31 @@ class SQLiteHandler:
|
||||
logger.error(f"Failed to get neighbors: {e}")
|
||||
return {}
|
||||
|
||||
def get_noise_floor_history(self, hours: int = 24) -> list:
|
||||
def get_noise_floor_history(self, hours: int = 24, limit: int = None) -> list:
|
||||
try:
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
|
||||
with sqlite3.connect(self.sqlite_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
measurements = conn.execute("""
|
||||
# Build query with optional limit
|
||||
query = """
|
||||
SELECT timestamp, noise_floor_dbm
|
||||
FROM noise_floor
|
||||
WHERE timestamp > ?
|
||||
ORDER BY timestamp ASC
|
||||
""", (cutoff,)).fetchall()
|
||||
ORDER BY timestamp DESC
|
||||
"""
|
||||
|
||||
return [{"timestamp": row["timestamp"], "noise_floor_dbm": row["noise_floor_dbm"]}
|
||||
for row in measurements]
|
||||
if limit:
|
||||
query += f" LIMIT {int(limit)}"
|
||||
|
||||
measurements = conn.execute(query, (cutoff,)).fetchall()
|
||||
|
||||
# Reverse to get chronological order (oldest to newest)
|
||||
result = [{"timestamp": row["timestamp"], "noise_floor_dbm": row["noise_floor_dbm"]}
|
||||
for row in reversed(measurements)]
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get noise floor history: {e}")
|
||||
|
||||
@@ -66,18 +66,62 @@ class StorageCollector:
|
||||
from .hardware_stats import HardwareStatsCollector
|
||||
self.hardware_stats = HardwareStatsCollector()
|
||||
logger.info("Hardware stats collector initialized")
|
||||
|
||||
# Initialize WebSocket handler for real-time updates
|
||||
self.websocket_available = False
|
||||
try:
|
||||
from .websocket_handler import broadcast_packet, broadcast_stats
|
||||
self.websocket_broadcast_packet = broadcast_packet
|
||||
self.websocket_broadcast_stats = broadcast_stats
|
||||
self.websocket_available = True
|
||||
logger.info("WebSocket handler initialized for real-time updates")
|
||||
except ImportError:
|
||||
logger.debug("WebSocket handler not available")
|
||||
|
||||
def _get_live_stats(self) -> dict:
|
||||
"""Get live stats from RepeaterHandler"""
|
||||
if not self.repeater_handler:
|
||||
return {"uptime_secs": 0, "packets_sent": 0, "packets_received": 0}
|
||||
return {
|
||||
"uptime_secs": 0,
|
||||
"packets_sent": 0,
|
||||
"packets_received": 0,
|
||||
"errors": 0,
|
||||
"queue_len": 0
|
||||
}
|
||||
|
||||
uptime_secs = int(time.time() - self.repeater_handler.start_time)
|
||||
return {
|
||||
|
||||
# Get airtime stats
|
||||
airtime_stats = self.repeater_handler.airtime_mgr.get_stats()
|
||||
|
||||
# Get latest noise floor from database
|
||||
noise_floor = None
|
||||
try:
|
||||
recent_noise = self.sqlite_handler.get_noise_floor_history(hours=0.5, limit=1)
|
||||
if recent_noise and len(recent_noise) > 0:
|
||||
noise_floor = recent_noise[-1].get('noise_floor_dbm')
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not fetch noise floor: {e}")
|
||||
|
||||
stats = {
|
||||
"uptime_secs": uptime_secs,
|
||||
"packets_sent": self.repeater_handler.forwarded_count,
|
||||
"packets_received": self.repeater_handler.rx_count,
|
||||
"errors": 0,
|
||||
"queue_len": 0, # N/A for Python repeater
|
||||
}
|
||||
|
||||
# Add airtime stats
|
||||
if airtime_stats:
|
||||
stats["tx_air_secs"] = airtime_stats["total_airtime_ms"] / 1000
|
||||
stats["current_airtime_ms"] = airtime_stats["current_airtime_ms"]
|
||||
stats["utilization_percent"] = airtime_stats["utilization_percent"]
|
||||
|
||||
# Add noise floor if available
|
||||
if noise_floor is not None:
|
||||
stats["noise_floor"] = noise_floor
|
||||
|
||||
return stats
|
||||
|
||||
def record_packet(self, packet_record: dict, skip_letsmesh_if_invalid: bool = True):
|
||||
"""Record packet to storage and publish to MQTT/LetsMesh
|
||||
@@ -96,6 +140,26 @@ class StorageCollector:
|
||||
cumulative_counts = self.sqlite_handler.get_cumulative_counts()
|
||||
self.rrd_handler.update_packet_metrics(packet_record, cumulative_counts)
|
||||
self.mqtt_handler.publish(packet_record, "packet")
|
||||
|
||||
# Broadcast to WebSocket clients for real-time updates
|
||||
if self.websocket_available:
|
||||
try:
|
||||
self.websocket_broadcast_packet(packet_record)
|
||||
|
||||
# Also broadcast lightweight stats update
|
||||
uptime_seconds = time.time() - self.repeater_handler.start_time if self.repeater_handler else 0
|
||||
self.websocket_broadcast_stats({
|
||||
"packet_stats": {
|
||||
"total_packets": cumulative_counts.get("rx_total", 0),
|
||||
"transmitted_packets": cumulative_counts.get("tx_total", 0),
|
||||
"dropped_packets": cumulative_counts.get("drop_total", 0),
|
||||
},
|
||||
"system_stats": {
|
||||
"uptime_seconds": uptime_seconds,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"WebSocket broadcast failed: {e}")
|
||||
|
||||
# Publish to LetsMesh if enabled (skip invalid packets if requested)
|
||||
if skip_letsmesh_if_invalid and packet_record.get('drop_reason'):
|
||||
@@ -209,8 +273,8 @@ class StorageCollector:
|
||||
def cleanup_old_data(self, days: int = 7):
|
||||
self.sqlite_handler.cleanup_old_data(days)
|
||||
|
||||
def get_noise_floor_history(self, hours: int = 24) -> list:
|
||||
return self.sqlite_handler.get_noise_floor_history(hours)
|
||||
def get_noise_floor_history(self, hours: int = 24, limit: int = None) -> list:
|
||||
return self.sqlite_handler.get_noise_floor_history(hours, limit)
|
||||
|
||||
def get_noise_floor_stats(self, hours: int = 24) -> dict:
|
||||
return self.sqlite_handler.get_noise_floor_stats(hours)
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
WebSocket handler for real-time packet updates - simple ws4py implementation
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import cherrypy
|
||||
from ws4py.websocket import WebSocket
|
||||
from ws4py.server.cherrypyserver import WebSocketPlugin, WebSocketTool
|
||||
|
||||
logger = logging.getLogger("WebSocket")
|
||||
|
||||
# Suppress noisy ws4py error logs for normal disconnections (ConnectionResetError, etc.)
|
||||
logging.getLogger('ws4py').setLevel(logging.CRITICAL)
|
||||
|
||||
# Global set of connected clients
|
||||
_connected_clients = set()
|
||||
|
||||
# Heartbeat configuration
|
||||
PING_INTERVAL = 30 # seconds
|
||||
_heartbeat_thread = None
|
||||
_heartbeat_running = False
|
||||
|
||||
|
||||
class PacketWebSocket(WebSocket):
|
||||
|
||||
def opened(self):
|
||||
"""Called when a WebSocket connection is established"""
|
||||
_connected_clients.add(self)
|
||||
logger.info(f"WebSocket connected. Total clients: {len(_connected_clients)}")
|
||||
|
||||
def closed(self, code, reason=None):
|
||||
"""Called when a WebSocket connection is closed"""
|
||||
_connected_clients.discard(self)
|
||||
logger.info(f"WebSocket disconnected. Total clients: {len(_connected_clients)}")
|
||||
|
||||
def received_message(self, message):
|
||||
"""Handle messages from client"""
|
||||
try:
|
||||
data = json.loads(str(message))
|
||||
if data.get("type") == "ping":
|
||||
self.send(json.dumps({"type": "pong"}))
|
||||
elif data.get("type") == "pong":
|
||||
# Client responded to our ping
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def broadcast_packet(packet_data: dict):
|
||||
|
||||
if not _connected_clients:
|
||||
return
|
||||
|
||||
message = json.dumps({"type": "packet", "data": packet_data})
|
||||
|
||||
for client in list(_connected_clients):
|
||||
try:
|
||||
client.send(message)
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket send error: {e}")
|
||||
_connected_clients.discard(client)
|
||||
|
||||
|
||||
def broadcast_stats(stats_data: dict):
|
||||
|
||||
if not _connected_clients:
|
||||
return
|
||||
|
||||
message = json.dumps({"type": "stats", "data": stats_data})
|
||||
|
||||
for client in list(_connected_clients):
|
||||
try:
|
||||
client.send(message)
|
||||
except Exception as e:
|
||||
logger.error(f"WebSocket send error: {e}")
|
||||
_connected_clients.discard(client)
|
||||
|
||||
|
||||
def _heartbeat_loop():
|
||||
"""Background thread to send periodic pings to all connected clients"""
|
||||
global _heartbeat_running
|
||||
|
||||
while _heartbeat_running:
|
||||
time.sleep(PING_INTERVAL)
|
||||
|
||||
if not _connected_clients:
|
||||
continue
|
||||
|
||||
ping_message = json.dumps({"type": "ping"})
|
||||
|
||||
for client in list(_connected_clients):
|
||||
try:
|
||||
client.send(ping_message)
|
||||
except Exception as e:
|
||||
logger.debug(f"Heartbeat ping failed: {e}")
|
||||
_connected_clients.discard(client)
|
||||
|
||||
|
||||
def init_websocket():
|
||||
"""Initialize WebSocket plugin and start heartbeat"""
|
||||
global _heartbeat_thread, _heartbeat_running
|
||||
|
||||
WebSocketPlugin(cherrypy.engine).subscribe()
|
||||
cherrypy.tools.websocket = WebSocketTool()
|
||||
|
||||
# Start heartbeat thread
|
||||
if not _heartbeat_running:
|
||||
_heartbeat_running = True
|
||||
_heartbeat_thread = threading.Thread(target=_heartbeat_loop, daemon=True)
|
||||
_heartbeat_thread.start()
|
||||
logger.info(f"WebSocket initialized with {PING_INTERVAL}s heartbeat")
|
||||
else:
|
||||
logger.info("WebSocket initialized")
|
||||
@@ -1270,12 +1270,13 @@ class APIEndpoints:
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
def noise_floor_history(self, hours: int = 24):
|
||||
def noise_floor_history(self, hours: int = 24, limit: int = None):
|
||||
|
||||
try:
|
||||
storage = self._get_storage()
|
||||
hours = int(hours)
|
||||
history = storage.get_noise_floor_history(hours=hours)
|
||||
limit = int(limit) if limit else None
|
||||
history = storage.get_noise_floor_history(hours=hours, limit=limit)
|
||||
|
||||
return self._success({
|
||||
"history": history,
|
||||
|
||||
@@ -8,7 +8,8 @@ def check_auth():
|
||||
"""
|
||||
CherryPy tool to check authentication before processing request.
|
||||
|
||||
Checks for either JWT in Authorization header or API token in X-API-Key header.
|
||||
Checks for either JWT in Authorization header, API token in X-API-Key header,
|
||||
or JWT token in query parameter (for EventSource/SSE connections).
|
||||
Sets cherrypy.request.user on success.
|
||||
Returns 401 JSON response on failure.
|
||||
"""
|
||||
@@ -29,7 +30,7 @@ def check_auth():
|
||||
cherrypy.response.status = 500
|
||||
return {"success": False, "error": "Authentication system not configured"}
|
||||
|
||||
# Check for JWT token first
|
||||
# Check for JWT token in Authorization header first
|
||||
auth_header = cherrypy.request.headers.get("Authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:] # Remove "Bearer " prefix
|
||||
@@ -43,7 +44,23 @@ def check_auth():
|
||||
}
|
||||
return
|
||||
|
||||
# Check for API token
|
||||
# Check for JWT token in query parameter (for EventSource/SSE)
|
||||
# EventSource doesn't support custom headers, so we use query param
|
||||
query_token = cherrypy.request.params.get("token")
|
||||
if query_token:
|
||||
payload = jwt_handler.verify_jwt(query_token)
|
||||
|
||||
if payload:
|
||||
cherrypy.request.user = {
|
||||
"username": payload.get("sub"),
|
||||
"client_id": payload.get("client_id"),
|
||||
"auth_type": "jwt_query"
|
||||
}
|
||||
# Remove token from params to avoid exposing it in logs
|
||||
del cherrypy.request.params["token"]
|
||||
return
|
||||
|
||||
# Check for API token in X-API-Key header
|
||||
api_key = cherrypy.request.headers.get("X-API-Key", "")
|
||||
if api_key:
|
||||
token_info = token_manager.verify_token(api_key)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.glass-card[data-v-c30e5f38]{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
@@ -0,0 +1 @@
|
||||
import{a as p,b as n,g as m,e as t,s as g,t as s,j as d,p as l}from"./index-C2DY4pTz.js";const f={class:"flex items-center justify-between mb-4"},w={class:"text-xl font-semibold text-content-primary dark:text-content-primary"},v={class:"mb-6"},h={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},y={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},C={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},B={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},j={class:"flex gap-3"},_=p({__name:"ConfirmDialog",props:{show:{type:Boolean},title:{default:"Confirm Action"},message:{},confirmText:{default:"Confirm"},cancelText:{default:"Cancel"},variant:{default:"warning"}},emits:["close","confirm"],setup(c,{emit:b}){const o=c,r=b,u=i=>{i.target===i.currentTarget&&r("close")},k={danger:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",warning:"bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},x={danger:"bg-red-500 hover:bg-red-600",warning:"bg-yellow-500 hover:bg-yellow-600",info:"bg-blue-500 hover:bg-blue-600"};return(i,e)=>o.show?(l(),n("div",{key:0,onClick:u,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[3]||(e[3]=g(()=>{},["stop"]))},[t("div",f,[t("h3",w,s(o.title),1),t("button",{onClick:e[0]||(e[0]=a=>r("close")),class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},e[4]||(e[4]=[t("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",v,[t("div",{class:d(["inline-flex p-3 rounded-xl mb-4",k[o.variant]])},[o.variant==="danger"?(l(),n("svg",h,e[5]||(e[5]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):o.variant==="warning"?(l(),n("svg",y,e[6]||(e[6]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):(l(),n("svg",C,e[7]||(e[7]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),t("p",B,s(o.message),1)]),t("div",j,[t("button",{onClick:e[1]||(e[1]=a=>r("close")),class:"flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10"},s(o.cancelText),1),t("button",{onClick:e[2]||(e[2]=a=>r("confirm")),class:d(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",x[o.variant]])},s(o.confirmText),3)])])])):m("",!0)}});export{_};
|
||||
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{a as e,b as r,i as o,p as n}from"./index-C2DY4pTz.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('<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 & 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">pyMC 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 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><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{d as default};
|
||||
@@ -0,0 +1 @@
|
||||
.bg-gradient-light[data-v-7d3a3377]{background:linear-gradient(to bottom,#0ea5e966,#06b6d44d)}.bg-gradient-dark[data-v-7d3a3377]{background:linear-gradient(to bottom,#67e8f94d,#a5f3fc26)}.login-card[data-v-7d3a3377]{background:#11191c66;backdrop-filter:blur(40px) saturate(180%);-webkit-backdrop-filter:blur(40px) saturate(180%)}.login-card[data-v-7d3a3377]{background:#ffffffb3}.dark .login-card[data-v-7d3a3377]{background:#11191c66}.input-glass[data-v-7d3a3377]{backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px)}.input-glass[data-v-7d3a3377]{background:#ffffffe6;border:1px solid #D1D5DB}.dark .input-glass[data-v-7d3a3377]{background:#ffffff0d;border-color:#ffffff1a}.input-glass[data-v-7d3a3377]:focus{background:#fff}.dark .input-glass[data-v-7d3a3377]:focus{background:#ffffff1a}.input-glass[data-v-7d3a3377]:focus{box-shadow:0 0 0 1px #aae8e833,0 0 20px #aae8e826,inset 0 1px #ffffff1a}.input-glow[data-v-7d3a3377]{opacity:0;transition:opacity .3s ease;box-shadow:inset 0 1px #ffffff0d}.input-glass:focus+.input-glow[data-v-7d3a3377]{opacity:1;box-shadow:0 0 20px #aae8e833,inset 0 1px #ffffff1a}.button-glass[data-v-7d3a3377]{backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);position:relative}.button-glass[data-v-7d3a3377]:before{content:"";position:absolute;inset:0;border-radius:12px;padding:1px;background:linear-gradient(90deg,transparent 0%,rgba(170,232,232,.3) 50%,transparent 100%);-webkit-mask:linear-gradient(#fff 0 0) content-box,linear-gradient(#fff 0 0);-webkit-mask-composite:xor;mask-composite:exclude;transform:translate(-100%);transition:transform 1s ease}.button-glass[data-v-7d3a3377]:hover:not(:disabled):before{transform:translate(100%)}.button-glass[data-v-7d3a3377]{box-shadow:0 0 0 1px #aae8e833,0 4px 16px #0003,inset 0 1px #ffffff1a}.button-glass[data-v-7d3a3377]:hover:not(:disabled){box-shadow:0 0 0 1px #aae8e866,0 0 30px #aae8e84d,0 4px 20px #0000004d,inset 0 1px #ffffff26}.login-content:has(.button-glass:hover:not(:disabled)) .logo-image[data-v-7d3a3377]{filter:brightness(1.4) drop-shadow(0 0 12px rgba(170,232,232,.7));transform:scale(1.02)}.login-content:has(.button-glass:hover:not(:disabled)) .logo-glow[data-v-7d3a3377]{opacity:.6;transform:scale(1.15)}.logo-glow[data-v-7d3a3377]{opacity:0}.dark .logo-glow[data-v-7d3a3377]{opacity:1}@keyframes float-7d3a3377{0%,to{transform:translateY(0)}50%{transform:translateY(-10px)}}@keyframes pulse-slow-7d3a3377{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.05)}}@keyframes pulse-slower-7d3a3377{0%,to{opacity:.75;transform:scale(1)}50%{opacity:.5;transform:scale(1.08)}}@keyframes pulse-slowest-7d3a3377{0%,to{opacity:.8;transform:scale(1)}50%{opacity:.6;transform:scale(1.06)}}.animate-pulse-slow[data-v-7d3a3377]{animation:pulse-slow-7d3a3377 8s ease-in-out infinite}.animate-pulse-slower[data-v-7d3a3377]{animation:pulse-slower-7d3a3377 10s ease-in-out infinite}.animate-pulse-slowest[data-v-7d3a3377]{animation:pulse-slowest-7d3a3377 12s ease-in-out infinite}@keyframes shake-7d3a3377{0%,to{transform:translate(0)}10%,30%,50%,70%,90%{transform:translate(-5px)}20%,40%,60%,80%{transform:translate(5px)}}.animate-shake[data-v-7d3a3377]{animation:shake-7d3a3377 .5s ease-in-out}.form-group[data-v-7d3a3377]{position:relative}.form-group:hover label[data-v-7d3a3377]{color:#aae8e8e6;transition:color .3s ease}
|
||||
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
@@ -0,0 +1 @@
|
||||
.glass-card[data-v-20a8772f]{background:#ffffff0d;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);border:1px solid rgba(255,255,255,.1)}.modal-enter-active[data-v-20a8772f],.modal-leave-active[data-v-20a8772f]{transition:opacity .3s ease}.modal-enter-from[data-v-20a8772f],.modal-leave-to[data-v-20a8772f]{opacity:0}.modal-enter-active .glass-card[data-v-20a8772f],.modal-leave-active .glass-card[data-v-20a8772f]{transition:transform .3s ease}.modal-enter-from .glass-card[data-v-20a8772f],.modal-leave-to .glass-card[data-v-20a8772f]{transform:scale(.9)}.slide-enter-active[data-v-20a8772f],.slide-leave-active[data-v-20a8772f]{transition:all .3s ease}.slide-enter-from[data-v-20a8772f],.slide-leave-to[data-v-20a8772f]{opacity:0;transform:translateY(-10px)}@keyframes float-slow-20a8772f{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-20a8772f{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-20a8772f{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-20a8772f]{animation:float-slow-20a8772f 15s ease-in-out infinite;will-change:transform,opacity}.animate-pulse-slower[data-v-20a8772f]{animation:float-slower-20a8772f 18s ease-in-out infinite;will-change:transform,opacity}.animate-pulse-slowest[data-v-20a8772f]{animation:float-slowest-20a8772f 20s ease-in-out infinite;will-change:transform,opacity}
|
||||
@@ -0,0 +1 @@
|
||||
.plotly-chart[data-v-967da4a4]{background:transparent!important}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.glass-card[data-v-eab6d04d]{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);background:#ffffffbf;border:1px solid rgba(0,0,0,.06);box-shadow:0 2px 8px #0000000a}.dark .glass-card[data-v-eab6d04d]{background:#0000004d;border:1px solid rgba(255,255,255,.1);box-shadow:none}.chart-updating[data-v-eab6d04d]{animation:subtle-pulse-eab6d04d .8s ease-in-out}@keyframes subtle-pulse-eab6d04d{0%{transform:scale(1)}50%{transform:scale(1.02)}to{transform:scale(1)}}.chart-container[data-v-eab6d04d]{position:relative;transition:all .3s ease}.chart-container[data-v-eab6d04d]:hover{background:#0000000a}.dark .chart-container[data-v-eab6d04d]:hover{background:#ffffff14}.process-row[data-v-eab6d04d]{transition:all .3s ease}.process-row[data-v-eab6d04d]:hover{background:#00000005;transform:translate(2px)}.dark .process-row[data-v-eab6d04d]:hover{background:#ffffff0d}.process-row-enter-active[data-v-eab6d04d],.process-row-leave-active[data-v-eab6d04d]{transition:all .4s ease}.process-row-enter-from[data-v-eab6d04d]{opacity:0;transform:translateY(-10px) scale(.95)}.process-row-leave-to[data-v-eab6d04d]{opacity:0;transform:translateY(10px) scale(.95)}.process-row-move[data-v-eab6d04d]{transition:transform .4s ease}.cpu-value[data-v-eab6d04d],.memory-value[data-v-eab6d04d]{transition:all .3s ease;padding:2px 6px;border-radius:4px}.cpu-value[data-v-eab6d04d]:hover,.memory-value[data-v-eab6d04d]:hover{background:#f59e0b1a;transform:scale(1.05)}@keyframes value-update-eab6d04d{0%{background:#f59e0b4d}to{background:transparent}}.value-updated[data-v-eab6d04d]{animation:value-update-eab6d04d .6s ease-out}
|
||||
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 @@
|
||||
function e(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}export{e as g};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.sparkline-card[data-v-257cbdca]{background:#ffffffbf;border:1px solid rgba(0,0,0,.06);border-radius:12px;padding:12px 14px;-webkit-backdrop-filter:blur(50px);backdrop-filter:blur(50px);overflow:hidden;transition:background .3s ease,border-color .3s ease,box-shadow .3s ease;box-shadow:0 4px 16px #0000000a,0 1px 3px #00000005}.dark .sparkline-card[data-v-257cbdca]{background:#0006;border:1px solid rgba(255,255,255,.05);box-shadow:0 4px 16px #0003}.card-header[data-v-257cbdca]{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:8px}.card-title[data-v-257cbdca]{color:#4b5563b3;font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:.05em;transition:color .3s ease}.dark .card-title[data-v-257cbdca]{color:#fff9}.card-subtitle[data-v-257cbdca]{color:#4b556380;font-size:9px;font-weight:400;margin-top:2px;transition:color .3s ease}.dark .card-subtitle[data-v-257cbdca]{color:#fff6}.card-value[data-v-257cbdca]{font-size:22px;font-weight:700;line-height:1;font-variant-numeric:tabular-nums}.card-chart[data-v-257cbdca]{width:100%;height:28px;overflow:hidden}.chart-svg[data-v-257cbdca]{width:100%;height:100%}.chart-loader[data-v-257cbdca]{display:flex;align-items:center;justify-content:center;height:100%}.loader-spinner[data-v-257cbdca]{width:18px;height:18px;border:2px solid rgba(255,255,255,.2);border-radius:50%;animation:spin-257cbdca 1s linear infinite}.chart-text[data-v-257cbdca]{display:flex;align-items:center;justify-content:center;height:100%}.percent-value[data-v-257cbdca]{font-size:20px;font-weight:500;color:#ffffff80;font-variant-numeric:tabular-nums}.sparkline-path[data-v-257cbdca]{transition:d 1s ease-out}@keyframes spin-257cbdca{to{transform:rotate(360deg)}}@media (min-width: 1024px){.sparkline-card[data-v-257cbdca]{padding:14px 16px}.card-header[data-v-257cbdca]{margin-bottom:10px}.card-title[data-v-257cbdca]{font-size:12px}.card-value[data-v-257cbdca]{font-size:26px}.card-chart[data-v-257cbdca]{height:32px}.percent-value[data-v-257cbdca]{font-size:24px}}
|
||||
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
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
const n="pymc_pref_";function c(r,t){try{const e=localStorage.getItem(n+r);return e===null?t:JSON.parse(e)}catch(e){return console.warn(`Failed to get preference ${r}:`,e),t}}function o(r,t){try{localStorage.setItem(n+r,JSON.stringify(t))}catch(e){console.warn(`Failed to set preference ${r}:`,e)}}export{c as g,o as s};
|
||||
@@ -0,0 +1 @@
|
||||
import{M as x,c as s}from"./index-C2DY4pTz.js";const l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},d=-116,i=8,u=5;function y(t,e){return t-e}function S(t){return l[t]??l[i]}function f(t,e){const r=e+u;if(t<=e){const o=t<=e-5?0:1;return{bars:o,color:"text-red-600 dark:text-red-400",snr:t,quality:o===0?"none":"poor"}}if(t<r){const n=(t-e)/u<.5?2:3;return{bars:n,color:n===2?"text-orange-600 dark:text-orange-400":"text-yellow-600 dark:text-yellow-400",snr:t,quality:"fair"}}const a=t-r>=10?5:4;return{bars:a,color:a===5?"text-green-600 dark:text-green-400":"text-green-600 dark:text-green-300",snr:t,quality:a===5?"excellent":"good"}}function N(){const t=x(),e=s(()=>t.noiseFloorDbm??d),r=s(()=>t.stats?.config?.radio?.spreading_factor??i),c=s(()=>S(r.value));return{getSignalQuality:o=>{if(!o||o>0||o<-120)return{bars:0,color:"text-gray-400 dark:text-gray-500",snr:-999,quality:"none"};const n=y(o,e.value),g=Math.max(-30,Math.min(20,n));return f(g,c.value)},noiseFloor:e,spreadingFactor:r,minSNR:c}}export{N as u};
|
||||
@@ -8,8 +8,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-Dz5Q9Vcf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-KLnbrplX.css">
|
||||
<script type="module" crossorigin src="/assets/index-C2DY4pTz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DEJx-WQR.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -20,6 +20,15 @@ from .auth.jwt_handler import JWTHandler
|
||||
from .auth.api_tokens import APITokenManager
|
||||
from .auth import cherrypy_tool # Import to register the tool
|
||||
|
||||
# WebSocket support
|
||||
try:
|
||||
from repeater.data_acquisition.websocket_handler import PacketWebSocket, init_websocket, broadcast_packet
|
||||
WEBSOCKET_AVAILABLE = True
|
||||
except ImportError:
|
||||
WEBSOCKET_AVAILABLE = False
|
||||
logger = logging.getLogger("HTTPServer")
|
||||
logger.warning("ws4py not available - WebSocket support disabled")
|
||||
|
||||
logger = logging.getLogger("HTTPServer")
|
||||
|
||||
|
||||
@@ -426,6 +435,27 @@ class HTTPStatsServer:
|
||||
|
||||
cherrypy.tree.mount(self.doc_app, "/doc", doc_config)
|
||||
|
||||
# Initialize WebSocket if available
|
||||
if WEBSOCKET_AVAILABLE:
|
||||
try:
|
||||
init_websocket()
|
||||
|
||||
# Create WebSocket app
|
||||
class WSApp:
|
||||
@cherrypy.expose
|
||||
def index(self):
|
||||
pass # WebSocket tool handles the actual upgrade
|
||||
|
||||
cherrypy.tree.mount(WSApp(), '/ws/packets', {
|
||||
'/': {
|
||||
'tools.websocket.on': True,
|
||||
'tools.websocket.handler_cls': PacketWebSocket,
|
||||
}
|
||||
})
|
||||
logger.info("WebSocket endpoint mounted at /ws/packets")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize WebSocket: {e}")
|
||||
|
||||
# Store auth handlers in cherrypy config for middleware access
|
||||
cherrypy.config.update({
|
||||
"jwt_handler": self.jwt_handler,
|
||||
|
||||
Reference in New Issue
Block a user