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:
Lloyd
2026-01-18 20:15:50 +00:00
parent 5bdea1132c
commit 599e4628d9
46 changed files with 4559 additions and 4237 deletions
+30 -23
View File
@@ -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
+1
View File
@@ -38,6 +38,7 @@ dependencies = [
"cherrypy-cors==1.7.0",
"psutil>=5.9.0",
"pyjwt>=2.8.0",
"ws4py>=0.6.0",
]
+44 -1
View File
@@ -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']}")
+15 -6
View File
@@ -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}")
+68 -4
View File
@@ -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")
+3 -2
View File
@@ -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,
+20 -3
View File
@@ -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 &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">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};
+2 -2
View File
@@ -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>
+30
View File
@@ -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,