feat(neighbour-links): implement neighbour link tracking and history retrieval

This commit is contained in:
Rightup
2026-07-15 17:08:40 +01:00
parent 6aafa7fe99
commit 9688fe70e4
60 changed files with 1137 additions and 54 deletions
+8
View File
@@ -41,6 +41,14 @@ repeater:
# Changing this value has no effect on current packet processing.
score_threshold: 0.3
# Observation-only upstream neighbour link metrics.
# These metrics are currently not used for forwarding, dedupe, TX delay,
# route selection, or packet acceptance decisions yet ;-0.
neighbour_link_metrics_enabled: true
neighbour_link_ewma_alpha: 0.20
neighbour_link_ttl_seconds: 86400
neighbour_link_max_entries: 512
# Automatic advertisement interval in hours
# The repeater will send an advertisement packet at this interval
# Set to 0 to disable automatic adverts (manual only via web interface)
+117 -1
View File
@@ -100,6 +100,8 @@ class SQLiteHandler:
src_hash TEXT,
dst_hash TEXT,
path_hash TEXT,
upstream_hash TEXT,
upstream_hash_size INTEGER,
header TEXT,
transport_codes TEXT,
payload TEXT,
@@ -189,6 +191,10 @@ class SQLiteHandler:
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_type ON packets(type)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_packets_hash ON packets(packet_hash)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_packets_upstream_time "
"ON packets(upstream_hash, upstream_hash_size, timestamp)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_packets_transmitted ON packets(transmitted)"
)
@@ -637,6 +643,35 @@ class SQLiteHandler:
)
logger.info(f"Migration '{migration_name}' applied successfully")
# Migration 12: Add upstream hash fields to packets for
# neighbour-link history lookups and indexing.
migration_name = "add_upstream_hash_to_packets"
existing = conn.execute(
"SELECT migration_name FROM migrations WHERE migration_name = ?",
(migration_name,),
).fetchone()
if not existing:
cursor = conn.execute("PRAGMA table_info(packets)")
columns = [column[1] for column in cursor.fetchall()]
if "upstream_hash" not in columns:
conn.execute("ALTER TABLE packets ADD COLUMN upstream_hash TEXT")
logger.info("Added upstream_hash column to packets table")
if "upstream_hash_size" not in columns:
conn.execute("ALTER TABLE packets ADD COLUMN upstream_hash_size INTEGER")
logger.info("Added upstream_hash_size column to packets table")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_packets_upstream_time "
"ON packets(upstream_hash, upstream_hash_size, timestamp)"
)
conn.execute(
"INSERT INTO migrations (migration_name, applied_at) VALUES (?, ?)",
(migration_name, time.time()),
)
logger.info(f"Migration '{migration_name}' applied successfully")
conn.commit()
except Exception as e:
@@ -732,10 +767,11 @@ class SQLiteHandler:
INSERT INTO packets (
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
upstream_hash, upstream_hash_size,
header, transport_codes, payload, payload_length,
tx_delay_ms, packet_hash, original_path, forwarded_path, raw_packet,
lbt_attempts, lbt_backoff_delays_ms, lbt_channel_busy
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
record.get("timestamp", time.time()),
@@ -751,6 +787,8 @@ class SQLiteHandler:
record.get("src_hash"),
record.get("dst_hash"),
record.get("path_hash"),
record.get("upstream_hash"),
record.get("upstream_hash_size"),
record.get("header"),
record.get("transport_codes"),
record.get("payload"),
@@ -1576,6 +1614,7 @@ class SQLiteHandler:
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
upstream_hash, upstream_hash_size,
transport_codes, payload, payload_length,
tx_delay_ms, packet_hash, original_path, forwarded_path,
lbt_attempts, lbt_channel_busy
@@ -1629,6 +1668,7 @@ class SQLiteHandler:
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
upstream_hash, upstream_hash_size,
transport_codes, payload, payload_length,
tx_delay_ms, packet_hash, original_path, forwarded_path,
lbt_attempts, lbt_channel_busy
@@ -1762,6 +1802,7 @@ class SQLiteHandler:
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
upstream_hash, upstream_hash_size,
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
@@ -1788,6 +1829,7 @@ class SQLiteHandler:
id,
timestamp, type, route, length, rssi, snr, score,
transmitted, is_duplicate, drop_reason, src_hash, dst_hash, path_hash,
upstream_hash, upstream_hash_size,
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
@@ -1803,6 +1845,80 @@ class SQLiteHandler:
logger.error(f"Failed to get packet by id: {e}")
return None
def get_neighbor_link_history(
self,
*,
peer_hash: str,
path_hash_size: int,
hours: int = 24,
limit: int = 1000,
) -> list:
try:
normalized_hash = str(peer_hash or "").strip().upper()
if not normalized_hash:
return []
path_hash_size = int(path_hash_size)
hours = max(1, int(hours))
limit = max(1, min(int(limit), 5000))
cutoff = time.time() - (hours * 3600)
with self._connect() as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"""
SELECT
timestamp,
rssi,
snr,
score,
is_duplicate,
packet_hash,
type,
route,
original_path
FROM packets INDEXED BY idx_packets_upstream_time
WHERE upstream_hash = ?
AND upstream_hash_size = ?
AND timestamp >= ?
ORDER BY timestamp DESC
LIMIT ?
""",
(normalized_hash, path_hash_size, cutoff, limit),
).fetchall()
history = []
for row in rows:
hop_count = None
original_path = row["original_path"]
if original_path:
try:
parsed = json.loads(original_path)
if isinstance(parsed, list):
hop_count = len(parsed)
except Exception:
hop_count = None
history.append(
{
"timestamp": row["timestamp"],
"rssi": row["rssi"],
"snr": row["snr"],
"score": row["score"],
"is_duplicate": bool(row["is_duplicate"]),
"packet_hash": row["packet_hash"],
"packet_type": row["type"],
"route_type": row["route"],
"path_hop_count": hop_count,
}
)
history.reverse()
return history
except Exception as e:
logger.error(f"Failed to get neighbor link history: {e}")
return []
def get_packet_type_stats(self, hours: int = 24) -> dict:
try:
now = time.time()
@@ -441,6 +441,21 @@ class StorageCollector:
def get_packet_by_id(self, packet_id: int) -> Optional[dict]:
return self.sqlite_handler.get_packet_by_id(packet_id)
def get_neighbor_link_history(
self,
*,
peer_hash: str,
path_hash_size: int,
hours: int = 24,
limit: int = 1000,
) -> list:
return self.sqlite_handler.get_neighbor_link_history(
peer_hash=peer_hash,
path_hash_size=path_hash_size,
hours=hours,
limit=limit,
)
def get_rrd_data(
self,
start_time: Optional[int] = None,
+58 -3
View File
@@ -23,6 +23,7 @@ from openhop_core.protocol.packet_utils import PacketHeaderUtils, PathUtils
from repeater.airtime import AirtimeManager
from repeater.data_acquisition import StorageCollector
from repeater.neighbour_links import NeighbourLinkTracker
from repeater.policy_engine import PolicyDecision, PolicyEngine
logger = logging.getLogger("RepeaterHandler")
@@ -84,6 +85,7 @@ class RepeaterHandler(BaseHandler):
self.loop_detect_mode = self._normalize_loop_detect_mode(
config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
)
self.neighbour_link_tracker = NeighbourLinkTracker(config)
radio = dispatcher.radio if dispatcher else None
if radio:
@@ -184,6 +186,8 @@ class RepeaterHandler(BaseHandler):
route_type = packet.header & PH_ROUTE_MASK
pkt_hash_full = packet.calculate_packet_hash().hex().upper()
snr = metadata.get("snr", 0.0)
rssi = metadata.get("rssi", 0)
# TX mode: forward (repeat on), monitor (no repeat, tenants can TX), no_tx (all TX off)
mode = self.config.get("repeater", {}).get("mode", "forward")
@@ -234,8 +238,6 @@ class RepeaterHandler(BaseHandler):
# clone the packet to avoid modifying the original
processed_packet = copy.deepcopy(packet)
snr = metadata.get("snr", 0.0)
rssi = metadata.get("rssi", 0)
transmitted = False
tx_delay_ms = 0.0
drop_reason = None
@@ -246,6 +248,25 @@ class RepeaterHandler(BaseHandler):
original_path_hashes = packet.get_path_hashes_hex()
path_hash_size = packet.get_path_hash_size()
if not local_transmission:
payload_type = (
packet.get_payload_type() if hasattr(packet, "get_payload_type") else None
)
score = self.calculate_packet_score(
snr,
len(packet.payload or b""),
self.radio_config["spreading_factor"],
)
self.neighbour_link_tracker.observe(
packet,
route_type=route_type,
payload_type=payload_type,
rssi=rssi,
snr=snr,
score=score,
is_duplicate=(pkt_hash_full in self.seen_packets),
)
# Process for forwarding (skip if repeat disabled or if this is a local transmission).
# Pass pkt_hash_full so flood_forward / direct_forward don't recompute SHA-256.
result = (
@@ -557,6 +578,22 @@ class RepeaterHandler(BaseHandler):
path_hash_size = packet.get_path_hash_size()
path_hash = self._path_hash_display(original_path_hashes)
src_hash, dst_hash = self._packet_record_src_dst(packet, payload_type)
pkt_hash_full = packet.calculate_packet_hash().hex().upper()
score = self.calculate_packet_score(
snr,
len(packet.payload or b""),
self.radio_config["spreading_factor"],
)
self.neighbour_link_tracker.observe(
packet,
route_type=route_type,
payload_type=payload_type,
rssi=float(rssi),
snr=float(snr),
score=score,
is_duplicate=True,
)
packet_record = self._build_packet_record(
packet,
@@ -572,7 +609,7 @@ class RepeaterHandler(BaseHandler):
transmitted=False,
drop_reason="Duplicate",
is_duplicate=True,
packet_hash=packet.calculate_packet_hash().hex().upper(),
packet_hash=pkt_hash_full,
)
if self.storage:
@@ -655,6 +692,13 @@ class RepeaterHandler(BaseHandler):
pkt_hash = packet_hash or packet.calculate_packet_hash().hex().upper()
payload = getattr(packet, "payload", None)
payload_len = len(payload or b"")
upstream_hash, upstream_hash_size = self.neighbour_link_tracker.get_upstream_peer_identity(
packet,
route_type,
payload_type,
path_hashes=original_path_hashes,
path_hash_size=path_hash_size,
)
# LoRa time-on-air for this packet (Semtech reference formula).
# Computed once here so every downstream consumer (MQTT, SQLite, Glass,
@@ -698,6 +742,8 @@ class RepeaterHandler(BaseHandler):
"original_path": original_path_hashes or None,
"forwarded_path": forwarded_path,
"path_hash_size": path_hash_size,
"upstream_hash": upstream_hash,
"upstream_hash_size": upstream_hash_size,
"raw_packet": packet.write_to().hex() if hasattr(packet, "write_to") else None,
"lbt_attempts": lbt_attempts,
"lbt_backoff_delays_ms": lbt_backoff_delays_ms,
@@ -1463,10 +1509,19 @@ class RepeaterHandler(BaseHandler):
self.send_advert_interval_hours = repeater_config.get("send_advert_interval_hours", 10)
self.cache_ttl = repeater_config.get("cache_ttl", 60)
self.max_flood_hops = repeater_config.get("max_flood_hops", 64)
self.neighbour_link_tracker.refresh_config(self.config)
self.loop_detect_mode = self._normalize_loop_detect_mode(
self.config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
)
with self.neighbour_link_tracker.lock:
now_monotonic = time.monotonic()
self.neighbour_link_tracker.purge_expired_locked(now_monotonic)
while (
len(self.neighbour_link_tracker.links) > self.neighbour_link_tracker.max_entries
):
self.neighbour_link_tracker.evict_stalest_locked()
# Note: Radio config changes require restart as they affect hardware
# Note: Airtime manager has its own config reference that gets updated
+274
View File
@@ -0,0 +1,274 @@
import threading
import time
from dataclasses import dataclass
from typing import Optional, Tuple
from openhop_core.protocol import Packet
from openhop_core.protocol.constants import (
PAYLOAD_TYPE_TRACE,
ROUTE_TYPE_FLOOD,
ROUTE_TYPE_TRANSPORT_FLOOD,
)
@dataclass
class NeighbourLink:
peer_hash: str
path_hash_size: int
first_seen: float
last_seen: float
last_seen_monotonic: float
sample_count: int = 0
duplicate_sample_count: int = 0
last_rssi: float = 0.0
last_snr: float = 0.0
last_score: float = 0.0
ewma_rssi: float = 0.0
ewma_snr: float = 0.0
ewma_score: float = 0.0
best_score: float = 0.0
worst_score: float = 1.0
def update(
self,
*,
now: float,
now_monotonic: float,
rssi: float,
snr: float,
score: float,
is_duplicate: bool,
alpha: float,
) -> None:
first_sample = self.sample_count == 0
self.sample_count += 1
if is_duplicate:
self.duplicate_sample_count += 1
self.last_seen = now
self.last_seen_monotonic = now_monotonic
self.last_rssi = rssi
self.last_snr = snr
self.last_score = score
if first_sample:
self.first_seen = now
self.ewma_rssi = rssi
self.ewma_snr = snr
self.ewma_score = score
self.best_score = score
self.worst_score = score
return
self.ewma_rssi = alpha * rssi + (1.0 - alpha) * self.ewma_rssi
self.ewma_snr = alpha * snr + (1.0 - alpha) * self.ewma_snr
self.ewma_score = alpha * score + (1.0 - alpha) * self.ewma_score
if score > self.best_score:
self.best_score = score
if score < self.worst_score:
self.worst_score = score
class NeighbourLinkTracker:
def __init__(self, config: dict):
self._neighbour_links: dict[str, NeighbourLink] = {}
self._neighbour_links_lock = threading.RLock()
self._metrics_enabled = True
self._ewma_alpha = 0.20
self._ttl_seconds = 86400.0
self._max_entries = 512
self.refresh_config(config)
@property
def links(self) -> dict[str, NeighbourLink]:
return self._neighbour_links
@property
def lock(self) -> threading.RLock:
return self._neighbour_links_lock
@property
def metrics_enabled(self) -> bool:
return self._metrics_enabled
@property
def ewma_alpha(self) -> float:
return self._ewma_alpha
@property
def ttl_seconds(self) -> float:
return self._ttl_seconds
@property
def max_entries(self) -> int:
return self._max_entries
def refresh_config(self, config: dict) -> None:
repeater_config = config.get("repeater", {})
self._metrics_enabled = bool(repeater_config.get("neighbour_link_metrics_enabled", True))
try:
alpha = float(repeater_config.get("neighbour_link_ewma_alpha", 0.20))
except (TypeError, ValueError):
alpha = 0.20
self._ewma_alpha = max(0.0, min(1.0, alpha))
try:
ttl = float(repeater_config.get("neighbour_link_ttl_seconds", 86400))
except (TypeError, ValueError):
ttl = 86400.0
self._ttl_seconds = max(1.0, ttl)
try:
max_entries = int(repeater_config.get("neighbour_link_max_entries", 512))
except (TypeError, ValueError):
max_entries = 512
self._max_entries = max(1, max_entries)
def purge_expired_locked(self, now_monotonic: float) -> None:
expired_keys = [
key
for key, link in self._neighbour_links.items()
if (now_monotonic - link.last_seen_monotonic) > self._ttl_seconds
]
for key in expired_keys:
del self._neighbour_links[key]
def evict_stalest_locked(self) -> None:
if not self._neighbour_links:
return
stalest_key = min(
self._neighbour_links,
key=lambda key: self._neighbour_links[key].last_seen_monotonic,
)
del self._neighbour_links[stalest_key]
@staticmethod
def get_upstream_peer_identity(
packet: Packet,
route_type: int,
payload_type: Optional[int],
*,
path_hashes=None,
path_hash_size: Optional[int] = None,
) -> Tuple[Optional[str], Optional[int]]:
if route_type not in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
return None, None
if payload_type == PAYLOAD_TYPE_TRACE:
return None, None
hashes = path_hashes if path_hashes is not None else packet.get_path_hashes_hex()
if not hashes:
return None, None
size = path_hash_size
if size is None:
size = packet.get_path_hash_size() if hasattr(packet, "get_path_hash_size") else None
if not size or int(size) <= 0:
return None, None
peer_hash = str(hashes[-1]).upper()
if not peer_hash:
return None, None
return peer_hash, int(size)
def observe(
self,
packet: Packet,
*,
route_type: int,
payload_type: Optional[int],
rssi: float,
snr: float,
score: float,
is_duplicate: bool,
) -> None:
if not self._metrics_enabled:
return
peer_hash, path_hash_size = self.get_upstream_peer_identity(
packet,
route_type,
payload_type,
)
if not peer_hash or not path_hash_size:
return
now = time.time()
now_monotonic = time.monotonic()
key = f"{path_hash_size}:{peer_hash}"
with self._neighbour_links_lock:
self.purge_expired_locked(now_monotonic)
link = self._neighbour_links.get(key)
if link is None and len(self._neighbour_links) >= self._max_entries:
self.evict_stalest_locked()
if link is None:
link = NeighbourLink(
peer_hash=peer_hash,
path_hash_size=path_hash_size,
first_seen=now,
last_seen=now,
last_seen_monotonic=now_monotonic,
)
self._neighbour_links[key] = link
link.update(
now=now,
now_monotonic=now_monotonic,
rssi=float(rssi),
snr=float(snr),
score=float(score),
is_duplicate=is_duplicate,
alpha=self._ewma_alpha,
)
def snapshot(
self,
*,
active_within_seconds: float = 900.0,
) -> list[dict]:
try:
active_window = max(0.0, float(active_within_seconds))
except (TypeError, ValueError):
active_window = 900.0
now_monotonic = time.monotonic()
snapshot = []
with self._neighbour_links_lock:
self.purge_expired_locked(now_monotonic)
for link in self._neighbour_links.values():
age_seconds = max(0.0, now_monotonic - link.last_seen_monotonic)
snapshot.append(
{
"peer_hash": link.peer_hash,
"path_hash_size": link.path_hash_size,
"sample_count": link.sample_count,
"duplicate_sample_count": link.duplicate_sample_count,
"first_seen": link.first_seen,
"last_seen": link.last_seen,
"age_seconds": age_seconds,
"active": age_seconds <= active_window,
"last_rssi": link.last_rssi,
"last_snr": link.last_snr,
"last_score": link.last_score,
"ewma_rssi": link.ewma_rssi,
"ewma_snr": link.ewma_snr,
"ewma_score": link.ewma_score,
"best_score": link.best_score,
"worst_score": link.worst_score,
}
)
return sorted(snapshot, key=lambda item: item["last_seen"], reverse=True)
+69
View File
@@ -87,6 +87,8 @@ POLICY_GROUP_KINDS = {
# GET /api/packet_stats?hours=24 - Get packet statistics
# GET /api/packet_type_stats?hours=24 - Get packet type statistics
# GET /api/route_stats?hours=24 - Get route statistics
# GET /api/neighbor_links?active_within_seconds=900 - Get in-memory observed upstream neighbour links
# GET /api/neighbor_link_history?peer_hash=AB&path_hash_size=1&hours=24&limit=1000 - Get observed upstream history from packets table
# GET /api/recent_packets?limit=100 - Get recent packets
# GET /api/filtered_packets?type=4&route=1&start_timestamp=X&end_timestamp=Y&limit=1000 - Get filtered packets
# GET /api/packet_by_hash?packet_hash=abc123 - Get specific packet by hash
@@ -3076,6 +3078,73 @@ class APIEndpoints:
logger.error(f"Error getting route stats: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def neighbor_links(self, active_within_seconds=90, limit=500):
try:
handler = getattr(self.daemon_instance, "repeater_handler", None)
tracker = (
getattr(handler, "neighbour_link_tracker", None) if handler is not None else None
)
if tracker is None or not hasattr(tracker, "snapshot"):
return self._error("Repeater handler not initialized")
active_window = float(active_within_seconds)
row_limit = int(limit)
if row_limit < 1:
raise ValueError("limit must be >= 1")
links = tracker.snapshot(active_within_seconds=active_window)
links = links[:row_limit]
return self._success(
{
"links": links,
"active_within_seconds": active_window,
"limit": row_limit,
"count": len(links),
}
)
except ValueError as e:
return self._error(f"Invalid parameter format: {e}")
except Exception as e:
logger.error(f"Error getting neighbor links: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def neighbor_link_history(self, peer_hash=None, path_hash_size=None, hours=24, limit=1000):
try:
if not peer_hash:
return self._error("peer_hash parameter required")
if path_hash_size is None:
return self._error("path_hash_size parameter required")
size = int(path_hash_size)
window_hours = int(hours)
row_limit = int(limit)
rows = self._get_storage().get_neighbor_link_history(
peer_hash=str(peer_hash),
path_hash_size=size,
hours=window_hours,
limit=row_limit,
)
return self._success(
{
"peer_hash": str(peer_hash).upper(),
"path_hash_size": size,
"hours": window_hours,
"limit": row_limit,
"rows": rows,
"count": len(rows),
}
)
except ValueError as e:
return self._error(f"Invalid parameter format: {e}")
except Exception as e:
logger.error(f"Error getting neighbor link history: {e}")
return self._error(e)
@cherrypy.expose
@cherrypy.tools.json_out()
def recent_packets(self, limit=100):
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
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
@@ -1 +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-BmJnUIFH.js";import{t as d}from"./index-DF7On1cp.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};
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-Cf4Quxb7.js";import{t as d}from"./index-DJjJNEBn.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
@@ -1 +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-BmJnUIFH.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};
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-Cf4Quxb7.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-DF7On1cp.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-DJjJNEBn.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
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 @@
import{t as e}from"./dataService-auvhoQZt.js";export{e as useDataService};
@@ -1 +0,0 @@
import{t as e}from"./dataService-DRVYk7u4.js";export{e as useDataService};
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"./packets-BoR1a5V0.js";export{e as usePacketStore};
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{t as e}from"./packets-CgbgLl5w.js";export{e as usePacketStore};
@@ -0,0 +1 @@
import{t as e}from"./api-D8WOXa6q.js";async function t(t,n,r={}){let{connectTimeoutMs:i=15e3,idleTimeoutMs:a=5e3,onPhaseChange:o}=r,s=new AbortController,c=!1,l=null,u=setTimeout(()=>{c||s.abort(Error(`Connection timeout`))},i),d=()=>{l&&clearTimeout(l),l=setTimeout(()=>{s.abort(Error(`Stream stalled`))},a)};o?.(`connecting`);try{return await e.get(t,n,{signal:s.signal,timeout:0,onDownloadProgress:e=>{!c&&(e.loaded??0)>0?(c=!0,clearTimeout(u),o?.(`receiving`),d()):c&&d()}})}finally{clearTimeout(u),l&&clearTimeout(l)}}export{t};
@@ -1 +0,0 @@
import{t as e}from"./system-BmJnUIFH.js";export{e as useSystemStore};
@@ -1 +1 @@
import{M as e,U as t,o as n}from"./runtime-core.esm-bundler-CINEgm0a.js";import{n as r,t as i,v as a}from"./api-GPpxWKdp.js";import{t as o}from"./packets-BoR1a5V0.js";var s=`pymc_config_cache`;function c(){try{let e=sessionStorage.getItem(s);return e?JSON.parse(e):null}catch{return null}}function l(e){if(e)try{sessionStorage.setItem(s,JSON.stringify(e))}catch{}}function u(){try{sessionStorage.removeItem(s)}catch{}}var d=a(`system`,()=>{let a=c(),s=t(a?{config:a}:null),d=t(!1),f=t(null),p=t(null),m=t(`forward`),h=t(!0),g=t(0),_=t(10),v=t(!1),y=n(()=>s.value?.config?.node_name??`Unknown`),b=n(()=>s.value?.site_name??``);e(()=>{let e=b.value;document.title=e?`${e} — Repeater`:`Repeater Dashboard`});let x=n(()=>{let e=s.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),S=n(()=>s.value!==null),C=n(()=>s.value?.version??`Unknown`),w=n(()=>s.value?.core_version??`Unknown`),T=n(()=>s.value?.noise_floor_dbm??null),E=n(()=>_.value>0?Math.min(g.value/_.value*100,100):0),D=n(()=>m.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:m.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:h.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),O=n(()=>({mode:m.value})),k=n(()=>h.value?{active:!0,warning:!1}:{active:!1,warning:!0}),A=e=>{v.value=e},j=null;async function M(e){return j===null?(j=(async()=>{try{d.value=!0,f.value=null;let t=new AbortController,n=15e3,i=window.setTimeout(()=>t.abort(),n),a=!1,c=()=>{a||(a=!0,e?.onFirstByte?.()),clearTimeout(i),i=window.setTimeout(()=>t.abort(),n)},u;try{u=await r.get(`/stats`,{signal:t.signal,onDownloadProgress:c,timeout:0})}finally{clearTimeout(i)}let m=u.data,h;if(m.success&&m.data)h=m.data;else if(m&&`version`in m)h=m;else throw Error(m.error||`Failed to fetch stats`);return s.value=h,p.value=new Date,N(h),l(h.config),o().systemStats=h,h}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{d.value=!1}})(),j.finally(()=>{j=null}),j):j}function N(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?m.value=t:t!==void 0&&(m.value=`forward`);let n=e.config.duty_cycle;if(n){h.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?_.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(_.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?g.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(g.value=t.parsedValue||0)}async function P(e){try{let t=await i.post(`/set_mode`,{mode:e});if(t.success)return m.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function F(e){try{let t=await i.post(`/set_duty_cycle`,{enabled:e});if(t.success)return h.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function I(){try{let e=await i.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function L(){return await F(!h.value)}function R(e){s.value?(e.uptime_seconds!==void 0&&(s.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(s.value.noise_floor_dbm=e.noise_floor_dbm)):s.value=e,p.value=new Date,N(e)}async function z(e=5e3,t=!1){t||await M();let n=null;return t||(n=setInterval(async()=>{try{await M()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function B(){s.value=null,f.value=null,p.value=null,d.value=!1,m.value=`forward`,h.value=!0,g.value=0,_.value=10,u()}return{stats:s,isLoading:d,error:f,lastUpdated:p,currentMode:m,dutyCycleEnabled:h,dutyCycleUtilization:g,dutyCycleMax:_,cadCalibrationRunning:v,nodeName:y,siteName:b,pubKey:x,hasStats:S,version:C,coreVersion:w,noiseFloorDbm:T,dutyCyclePercentage:E,statusBadge:D,modeButtonState:O,dutyCycleButtonState:k,fetchStats:M,setMode:P,setDutyCycle:F,sendAdvert:I,toggleDutyCycle:L,startAutoRefresh:z,updateRealtimeStats:R,reset:B,setCadCalibrationRunning:A}});export{d as t};
import{M as e,U as t,o as n}from"./runtime-core.esm-bundler-CINEgm0a.js";import{n as r,t as i,v as a}from"./api-D8WOXa6q.js";import{t as o}from"./packets-CgbgLl5w.js";var s=`pymc_config_cache`;function c(){try{let e=sessionStorage.getItem(s);return e?JSON.parse(e):null}catch{return null}}function l(e){if(e)try{sessionStorage.setItem(s,JSON.stringify(e))}catch{}}function u(){try{sessionStorage.removeItem(s)}catch{}}var d=a(`system`,()=>{let a=c(),s=t(a?{config:a}:null),d=t(!1),f=t(null),p=t(null),m=t(`forward`),h=t(!0),g=t(0),_=t(10),v=t(!1),y=n(()=>s.value?.config?.node_name??`Unknown`),b=n(()=>s.value?.site_name??``);e(()=>{let e=b.value;document.title=e?`${e} — Repeater`:`Repeater Dashboard`});let x=n(()=>{let e=s.value?.public_key;return!e||e===`Unknown`?`Unknown`:e.length>=16?`${e.slice(0,8)} ... ${e.slice(-8)}`:`${e}`}),S=n(()=>s.value!==null),C=n(()=>s.value?.version??`Unknown`),w=n(()=>s.value?.core_version??`Unknown`),T=n(()=>s.value?.noise_floor_dbm??null),E=n(()=>_.value>0?Math.min(g.value/_.value*100,100):0),D=n(()=>m.value===`no_tx`?{text:`No TX`,title:`No repeat, no local TX; adverts skipped`}:m.value===`monitor`?{text:`Monitor Mode`,title:`Monitoring only - not forwarding packets`}:h.value?{text:`Active`,title:`Forwarding with duty cycle enforcement`}:{text:`No Limits`,title:`Forwarding without duty cycle enforcement`}),O=n(()=>({mode:m.value})),k=n(()=>h.value?{active:!0,warning:!1}:{active:!1,warning:!0}),A=e=>{v.value=e},j=null;async function M(e){return j===null?(j=(async()=>{try{d.value=!0,f.value=null;let t=new AbortController,n=15e3,i=window.setTimeout(()=>t.abort(),n),a=!1,c=()=>{a||(a=!0,e?.onFirstByte?.()),clearTimeout(i),i=window.setTimeout(()=>t.abort(),n)},u;try{u=await r.get(`/stats`,{signal:t.signal,onDownloadProgress:c,timeout:0})}finally{clearTimeout(i)}let m=u.data,h;if(m.success&&m.data)h=m.data;else if(m&&`version`in m)h=m;else throw Error(m.error||`Failed to fetch stats`);return s.value=h,p.value=new Date,N(h),l(h.config),o().systemStats=h,h}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error fetching stats:`,e),e}finally{d.value=!1}})(),j.finally(()=>{j=null}),j):j}function N(e){if(e.config){let t=e.config.repeater?.mode;t===`forward`||t===`monitor`||t===`no_tx`?m.value=t:t!==void 0&&(m.value=`forward`);let n=e.config.duty_cycle;if(n){h.value=n.enforcement_enabled!==!1;let e=n.max_airtime_percent;typeof e==`number`?_.value=e:e&&typeof e==`object`&&`parsedValue`in e&&(_.value=e.parsedValue||10)}}let t=e.utilization_percent;typeof t==`number`?g.value=t:t&&typeof t==`object`&&`parsedValue`in t&&(g.value=t.parsedValue||0)}async function P(e){try{let t=await i.post(`/set_mode`,{mode:e});if(t.success)return m.value=e,!0;throw Error(t.error||`Failed to set mode`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting mode:`,e),e}}async function F(e){try{let t=await i.post(`/set_duty_cycle`,{enabled:e});if(t.success)return h.value=e,!0;throw Error(t.error||`Failed to set duty cycle`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error setting duty cycle:`,e),e}}async function I(){try{let e=await i.post(`/send_advert`,{},{timeout:1e4});if(e.success)return!0;throw Error(e.error||`Failed to send advert`)}catch(e){throw f.value=e instanceof Error?e.message:`Unknown error occurred`,console.error(`Error sending advert:`,e),e}}async function L(){return await F(!h.value)}function R(e){s.value?(e.uptime_seconds!==void 0&&(s.value.uptime_seconds=e.uptime_seconds),e.noise_floor_dbm!==void 0&&(s.value.noise_floor_dbm=e.noise_floor_dbm)):s.value=e,p.value=new Date,N(e)}async function z(e=5e3,t=!1){t||await M();let n=null;return t||(n=setInterval(async()=>{try{await M()}catch(e){console.error(`Auto-refresh error:`,e)}},e)),()=>{n&&clearInterval(n)}}function B(){s.value=null,f.value=null,p.value=null,d.value=!1,m.value=`forward`,h.value=!0,g.value=0,_.value=10,u()}return{stats:s,isLoading:d,error:f,lastUpdated:p,currentMode:m,dutyCycleEnabled:h,dutyCycleUtilization:g,dutyCycleMax:_,cadCalibrationRunning:v,nodeName:y,siteName:b,pubKey:x,hasStats:S,version:C,coreVersion:w,noiseFloorDbm:T,dutyCyclePercentage:E,statusBadge:D,modeButtonState:O,dutyCycleButtonState:k,fetchStats:M,setMode:P,setDutyCycle:F,sendAdvert:I,toggleDutyCycle:L,startAutoRefresh:z,updateRealtimeStats:R,reset:B,setCadCalibrationRunning:A}});export{d as t};
@@ -0,0 +1 @@
import{t as e}from"./system-Cf4Quxb7.js";export{e as useSystemStore};
@@ -0,0 +1 @@
import{t as e}from"./websocket-DqKUMFFE.js";export{e as useWebSocketStore};
@@ -1 +0,0 @@
import{t as e}from"./websocket-CNooJnNy.js";export{e as useWebSocketStore};
@@ -1 +1 @@
import{U as e,o as t}from"./runtime-core.esm-bundler-CINEgm0a.js";import{c as n,d as r,i,l as a,v as o}from"./api-GPpxWKdp.js";import{t as s}from"./packets-BoR1a5V0.js";import{t as c}from"./system-BmJnUIFH.js";import{t as l}from"./dataService-DRVYk7u4.js";var u=o(`websocket`,()=>{let o=e(null),u=e(`idle`),d=e(0),f=e(Date.now()),p=e(null),m=e(null),h=e(!1),g=e(!1),_=e(!1),v=e({visible:!1,message:``,variant:`info`}),y=null,b=s(),x=c(),S=i(),C=l(),w=t(()=>u.value===`open`);function T(e,t,n=0){y!==null&&(clearTimeout(y),y=null),v.value={visible:!0,message:e,variant:t},n>0&&(y=window.setTimeout(()=>{E()},n))}function E(){y!==null&&(clearTimeout(y),y=null),v.value.visible=!1}function D(){p.value!==null&&(clearTimeout(p.value),p.value=null)}function O(){m.value!==null&&(clearInterval(m.value),m.value=null)}function k(){T(`Reconnecting...`,`info`)}function A(){let e=a();return!h.value&&!g.value&&!!e&&!r()&&S.canMaintainConnections}function j(){let e,t=a(),r=n(),i=new URLSearchParams;return t&&i.set(`token`,t),r&&i.set(`client_id`,r),e=`${window.location.protocol===`https:`?`wss:`:`ws:`}//${``?.trim()?new URL(``).host:window.location.host}/ws/packets?${i.toString()}`,e}async function M(){await C.onReconnect()}function N(e=!1){O(),o.value&&e&&(o.value.onopen=null,o.value.onmessage=null,o.value.onerror=null,o.value.onclose=null)}function P(){if(D(),!A()){if(a()&&r()){S.handleAuthFailure(`expired`);return}u.value=`closed`;return}if(d.value>=6){u.value=`closed`,T(`Connection lost`,`error`,5e3);return}u.value=`reconnecting`,k();let e=Math.min(1e3*2**d.value,3e4);d.value+=1,p.value=window.setTimeout(()=>{p.value=null,F(!0)},e)}function F(e=!1){if(!A()||o.value?.readyState===WebSocket.OPEN||o.value?.readyState===WebSocket.CONNECTING)return;D(),N(!0),u.value=e||d.value>0||_.value?`reconnecting`:`connecting`,_.value&&k();let t=new WebSocket(j());o.value=t,t.onopen=()=>{u.value=`open`,f.value=Date.now();let e=d.value>0||_.value;d.value=0,_.value=!1,O(),m.value=window.setInterval(()=>{o.value?.readyState===WebSocket.OPEN&&(o.value.send(JSON.stringify({type:`ping`})),Date.now()-f.value>6e4&&(N(!0),o.value?.close()))},3e4),e?(C.onReconnect(),T(`Back online`,`success`,2500)):E()},t.onmessage=e=>{try{let t=JSON.parse(e.data);t.type===`packet`?b.addRealtimePacket(t.data):t.type===`stats`?(t.data?.packet_stats&&b.updateRealtimeStats({packet_stats:t.data.packet_stats}),t.data?.system_stats&&x.updateRealtimeStats(t.data.system_stats)):t.type===`packet_stats`?b.updateRealtimeStats(t.data):t.type===`system_stats`?x.updateRealtimeStats(t.data):(t.type===`pong`||t.type===`ping`)&&(f.value=Date.now(),t.type===`ping`&&o.value?.readyState===WebSocket.OPEN&&o.value.send(JSON.stringify({type:`pong`})))}catch(e){console.error(`[WebSocket] Parse error:`,e)}},t.onerror=()=>{u.value=d.value>0?`reconnecting`:`closed`},t.onclose=e=>{let t=o.value;if(N(),t===o.value&&(o.value=null),h.value||g.value){u.value=`closed`;return}if(e.code===1008||e.code===4001||e.code===4003){S.handleAuthFailure(`expired`);return}C.noteDisconnect(),P()}}function I(e=`lifecycle`){if(g.value=!0,D(),u.value=`closed`,e===`offline`?(_.value=!0,T(`Connection lost`,`error`,4e3)):e===`hidden`?(_.value=!0,E()):e===`logout`&&(_.value=!1,E()),o.value){let e=o.value;o.value=null,N(!0),e.close()}}function L(){h.value=!1,g.value=!1}function R(e={}){h.value=e.preventReconnect??h.value,e.silent||E(),I(e.preventReconnect?`logout`:`lifecycle`),d.value=0}return{isConnected:w,connectionState:u,reconnectAttempts:d,snackbar:v,connect:F,disconnect:R,pause:I,allowReconnect:L,hideSnackbar:E,resyncData:M}});export{u as t};
import{U as e,o as t}from"./runtime-core.esm-bundler-CINEgm0a.js";import{c as n,d as r,i,l as a,v as o}from"./api-D8WOXa6q.js";import{t as s}from"./packets-CgbgLl5w.js";import{t as c}from"./system-Cf4Quxb7.js";import{t as l}from"./dataService-auvhoQZt.js";var u=o(`websocket`,()=>{let o=e(null),u=e(`idle`),d=e(0),f=e(Date.now()),p=e(null),m=e(null),h=e(!1),g=e(!1),_=e(!1),v=e({visible:!1,message:``,variant:`info`}),y=null,b=s(),x=c(),S=i(),C=l(),w=t(()=>u.value===`open`);function T(e,t,n=0){y!==null&&(clearTimeout(y),y=null),v.value={visible:!0,message:e,variant:t},n>0&&(y=window.setTimeout(()=>{E()},n))}function E(){y!==null&&(clearTimeout(y),y=null),v.value.visible=!1}function D(){p.value!==null&&(clearTimeout(p.value),p.value=null)}function O(){m.value!==null&&(clearInterval(m.value),m.value=null)}function k(){T(`Reconnecting...`,`info`)}function A(){let e=a();return!h.value&&!g.value&&!!e&&!r()&&S.canMaintainConnections}function j(){let e,t=a(),r=n(),i=new URLSearchParams;return t&&i.set(`token`,t),r&&i.set(`client_id`,r),e=`${window.location.protocol===`https:`?`wss:`:`ws:`}//${``?.trim()?new URL(``).host:window.location.host}/ws/packets?${i.toString()}`,e}async function M(){await C.onReconnect()}function N(e=!1){O(),o.value&&e&&(o.value.onopen=null,o.value.onmessage=null,o.value.onerror=null,o.value.onclose=null)}function P(){if(D(),!A()){if(a()&&r()){S.handleAuthFailure(`expired`);return}u.value=`closed`;return}if(d.value>=6){u.value=`closed`,T(`Connection lost`,`error`,5e3);return}u.value=`reconnecting`,k();let e=Math.min(1e3*2**d.value,3e4);d.value+=1,p.value=window.setTimeout(()=>{p.value=null,F(!0)},e)}function F(e=!1){if(!A()||o.value?.readyState===WebSocket.OPEN||o.value?.readyState===WebSocket.CONNECTING)return;D(),N(!0),u.value=e||d.value>0||_.value?`reconnecting`:`connecting`,_.value&&k();let t=new WebSocket(j());o.value=t,t.onopen=()=>{u.value=`open`,f.value=Date.now();let e=d.value>0||_.value;d.value=0,_.value=!1,O(),m.value=window.setInterval(()=>{o.value?.readyState===WebSocket.OPEN&&(o.value.send(JSON.stringify({type:`ping`})),Date.now()-f.value>6e4&&(N(!0),o.value?.close()))},3e4),e?(C.onReconnect(),T(`Back online`,`success`,2500)):E()},t.onmessage=e=>{try{let t=JSON.parse(e.data);t.type===`packet`?b.addRealtimePacket(t.data):t.type===`stats`?(t.data?.packet_stats&&b.updateRealtimeStats({packet_stats:t.data.packet_stats}),t.data?.system_stats&&x.updateRealtimeStats(t.data.system_stats)):t.type===`packet_stats`?b.updateRealtimeStats(t.data):t.type===`system_stats`?x.updateRealtimeStats(t.data):(t.type===`pong`||t.type===`ping`)&&(f.value=Date.now(),t.type===`ping`&&o.value?.readyState===WebSocket.OPEN&&o.value.send(JSON.stringify({type:`pong`})))}catch(e){console.error(`[WebSocket] Parse error:`,e)}},t.onerror=()=>{u.value=d.value>0?`reconnecting`:`closed`},t.onclose=e=>{let t=o.value;if(N(),t===o.value&&(o.value=null),h.value||g.value){u.value=`closed`;return}if(e.code===1008||e.code===4001||e.code===4003){S.handleAuthFailure(`expired`);return}C.noteDisconnect(),P()}}function I(e=`lifecycle`){if(g.value=!0,D(),u.value=`closed`,e===`offline`?(_.value=!0,T(`Connection lost`,`error`,4e3)):e===`hidden`?(_.value=!0,E()):e===`logout`&&(_.value=!1,E()),o.value){let e=o.value;o.value=null,N(!0),e.close()}}function L(){h.value=!1,g.value=!1}function R(e={}){h.value=e.preventReconnect??h.value,e.silent||E(),I(e.preventReconnect?`logout`:`lifecycle`),d.value=0}return{isConnected:w,connectionState:u,reconnectAttempts:d,snackbar:v,connect:F,disconnect:R,pause:I,allowReconnect:L,hideSnackbar:E,resyncData:M}});export{u as t};
+7 -7
View File
@@ -8,20 +8,20 @@
<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-DF7On1cp.js"></script>
<script type="module" crossorigin src="/assets/index-DJjJNEBn.js"></script>
<link rel="modulepreload" crossorigin href="/assets/chunk-DECur_0Z.js">
<link rel="modulepreload" crossorigin href="/assets/runtime-core.esm-bundler-CINEgm0a.js">
<link rel="modulepreload" crossorigin href="/assets/api-GPpxWKdp.js">
<link rel="modulepreload" crossorigin href="/assets/api-D8WOXa6q.js">
<link rel="modulepreload" crossorigin href="/assets/createLucideIcon-D-_sbJKW.js">
<link rel="modulepreload" crossorigin href="/assets/runtime-dom.esm-bundler-B3VeUO8l.js">
<link rel="modulepreload" crossorigin href="/assets/Spinner-CMJUE3iy.js">
<link rel="modulepreload" crossorigin href="/assets/useTheme-vbCn9P26.js">
<link rel="modulepreload" crossorigin href="/assets/packets-BoR1a5V0.js">
<link rel="modulepreload" crossorigin href="/assets/system-BmJnUIFH.js">
<link rel="modulepreload" crossorigin href="/assets/dataService-DRVYk7u4.js">
<link rel="modulepreload" crossorigin href="/assets/websocket-CNooJnNy.js">
<link rel="modulepreload" crossorigin href="/assets/packets-CgbgLl5w.js">
<link rel="modulepreload" crossorigin href="/assets/system-Cf4Quxb7.js">
<link rel="modulepreload" crossorigin href="/assets/dataService-auvhoQZt.js">
<link rel="modulepreload" crossorigin href="/assets/websocket-DqKUMFFE.js">
<link rel="modulepreload" crossorigin href="/assets/constants-C3rXUIAq.js">
<link rel="stylesheet" crossorigin href="/assets/index-CD3mpKIx.css">
<link rel="stylesheet" crossorigin href="/assets/index-B230IDp9.css">
</head>
<body>
<div id="app"></div>
+187
View File
@@ -961,6 +961,83 @@ paths:
schema:
type: object
/neighbor_links:
get:
tags: [Packets]
summary: Get observed neighbour link snapshots
description: Returns observation-only neighbour link metrics by upstream peer hash.
parameters:
- name: active_within_seconds
in: query
required: false
schema:
type: integer
default: 90
minimum: 1
description: Mark links as active when last-seen age is within this threshold.
- name: limit
in: query
required: false
schema:
type: integer
default: 500
minimum: 1
maximum: 5000
description: Maximum number of link snapshots to return.
responses:
'200':
description: Neighbour link snapshots
content:
application/json:
schema:
$ref: '#/components/schemas/NeighborLinksResponse'
/neighbor_link_history:
get:
tags: [Packets]
summary: Get neighbour link packet history
description: Returns historical packet observations for a single upstream peer hash.
parameters:
- name: peer_hash
in: query
required: true
schema:
type: string
description: Upstream peer hash to query.
- name: path_hash_size
in: query
required: true
schema:
type: integer
minimum: 1
maximum: 3
description: Path hash size in bytes for the peer hash.
- name: hours
in: query
required: false
schema:
type: integer
default: 24
minimum: 1
maximum: 168
description: Time window in hours.
- name: limit
in: query
required: false
schema:
type: integer
default: 1000
minimum: 1
maximum: 5000
description: Maximum rows to return.
responses:
'200':
description: Neighbour link history
content:
application/json:
schema:
$ref: '#/components/schemas/NeighborLinkHistoryResponse'
# ============================================================================
# Charts & RRD Endpoints
# ============================================================================
@@ -4308,6 +4385,116 @@ components:
type: string
description: Error message
NeighborLinkSnapshot:
type: object
properties:
peer_hash:
type: string
path_hash_size:
type: integer
minimum: 1
maximum: 3
sample_count:
type: integer
duplicate_sample_count:
type: integer
first_seen:
type: integer
last_seen:
type: integer
age_seconds:
type: number
active:
type: boolean
last_rssi:
type: number
last_snr:
type: number
last_score:
type: number
ewma_rssi:
type: number
ewma_snr:
type: number
ewma_score:
type: number
best_score:
type: number
worst_score:
type: number
NeighborLinksData:
type: object
properties:
links:
type: array
items:
$ref: '#/components/schemas/NeighborLinkSnapshot'
active_within_seconds:
type: integer
count:
type: integer
NeighborLinksResponse:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/NeighborLinksData'
NeighborLinkHistoryRow:
type: object
properties:
timestamp:
type: integer
rssi:
type: number
nullable: true
snr:
type: number
nullable: true
score:
type: number
nullable: true
is_duplicate:
type: boolean
packet_hash:
type: string
packet_type:
type: integer
route_type:
type: integer
path_hop_count:
type: integer
nullable: true
NeighborLinkHistoryData:
type: object
properties:
peer_hash:
type: string
path_hash_size:
type: integer
hours:
type: integer
limit:
type: integer
rows:
type: array
items:
$ref: '#/components/schemas/NeighborLinkHistoryRow'
count:
type: integer
NeighborLinkHistoryResponse:
type: object
properties:
success:
type: boolean
data:
$ref: '#/components/schemas/NeighborLinkHistoryData'
LbtDiagnosticsResponse:
type: object
required: [start_time, end_time, bucket_seconds, severe_attempt_threshold, summary, buckets, packet_types, packet_type_buckets, correlations, limitations]
+47
View File
@@ -720,6 +720,53 @@ def test_packet_and_route_stats_endpoints(cherrypy_ctx):
storage.get_route_stats.assert_called_once_with(hours=6)
def test_neighbor_links_endpoint_returns_snapshot(cherrypy_ctx):
del cherrypy_ctx
api = _make_api()
tracker = SimpleNamespace(
snapshot=MagicMock(
return_value=[
{"peer_hash": "AB", "sample_count": 2},
{"peer_hash": "CD", "sample_count": 1},
]
)
)
repeater_handler = SimpleNamespace(neighbour_link_tracker=tracker)
api.daemon_instance = SimpleNamespace(repeater_handler=repeater_handler)
result = api.neighbor_links(active_within_seconds="120", limit="1")
assert result["success"] is True
assert result["data"]["count"] == 1
assert result["data"]["active_within_seconds"] == 120.0
assert result["data"]["limit"] == 1
assert result["data"]["links"][0]["peer_hash"] == "AB"
tracker.snapshot.assert_called_once_with(active_within_seconds=120.0)
def test_neighbor_link_history_endpoint_filters_by_hash_and_size(cherrypy_ctx):
del cherrypy_ctx
api = _make_api()
rows = [{"packet_hash": "A1", "path_hop_count": 3}]
storage = SimpleNamespace(get_neighbor_link_history=MagicMock(return_value=rows))
_attach_storage(api, storage)
result = api.neighbor_link_history(peer_hash="ab", path_hash_size="2", hours="12", limit="50")
assert result["success"] is True
assert result["data"]["peer_hash"] == "AB"
assert result["data"]["path_hash_size"] == 2
assert result["data"]["hours"] == 12
assert result["data"]["limit"] == 50
assert result["data"]["rows"] == rows
storage.get_neighbor_link_history.assert_called_once_with(
peer_hash="ab",
path_hash_size=2,
hours=12,
limit=50,
)
def test_recent_packets_and_bulk_packets(cherrypy_ctx):
del cherrypy_ctx
api = _make_api()
+204
View File
@@ -15,6 +15,7 @@ import pytest
from openhop_core.protocol import Packet, PacketBuilder
from openhop_core.protocol.constants import (
MAX_PATH_SIZE,
PAYLOAD_TYPE_TRACE,
PH_ROUTE_MASK,
PH_TYPE_SHIFT,
ROUTE_TYPE_DIRECT,
@@ -171,6 +172,28 @@ def _make_transport_direct_packet(
return pkt
def _make_hashed_flood_packet(path_hashes: list[str], hash_size: int = 1, payload_type: int = 0x01):
path_bytes = bytearray()
for hash_hex in path_hashes:
path_bytes.extend(bytes.fromhex(hash_hex))
pkt = _make_flood_packet(
payload=b"\xaa\xbb\xcc", path=bytes(path_bytes), payload_type=payload_type
)
pkt.path_len = PathUtils.encode_path_len(hash_size, len(path_hashes))
return pkt
def _make_hashed_transport_flood_packet(path_hashes: list[str], hash_size: int = 1):
path_bytes = bytearray()
for hash_hex in path_hashes:
path_bytes.extend(bytes.fromhex(hash_hex))
pkt = _make_transport_flood_packet(payload=b"\x10\x20\x30", path=bytes(path_bytes))
pkt.path_len = PathUtils.encode_path_len(hash_size, len(path_hashes))
return pkt
# ===================================================================
# 1. flood_forward
# ===================================================================
@@ -1010,6 +1033,187 @@ class TestStatistics:
assert stats["local_hash"] == f"0x{LOCAL_HASH:02x}"
@pytest.mark.asyncio
class TestNeighbourLinkObservation:
@staticmethod
def _observe_with_tracker(handler, pkt, *, rssi: float, snr: float, is_duplicate: bool) -> None:
route_type = pkt.header & PH_ROUTE_MASK
payload_type = pkt.get_payload_type() if hasattr(pkt, "get_payload_type") else None
score = handler.calculate_packet_score(
snr,
len(pkt.payload or b""),
handler.radio_config["spreading_factor"],
)
handler.neighbour_link_tracker.observe(
pkt,
route_type=route_type,
payload_type=payload_type,
rssi=rssi,
snr=snr,
score=score,
is_duplicate=is_duplicate,
)
async def test_received_flood_with_non_empty_path_creates_link_for_final_hash(self, handler):
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_hashed_flood_packet(["11", "22", "33"], hash_size=1)
await handler(pkt, {"rssi": -77, "snr": 5.5}, local_transmission=False)
snapshot = handler.neighbour_link_tracker.snapshot()
assert len(snapshot) == 1
assert snapshot[0]["peer_hash"] == "33"
assert snapshot[0]["path_hash_size"] == 1
async def test_uses_last_path_element_not_first_for_upstream_peer(self, handler):
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_hashed_flood_packet(["AA", "BB", "CC"], hash_size=1)
await handler(pkt, {"rssi": -80, "snr": 1.0}, local_transmission=False)
snapshot = handler.neighbour_link_tracker.snapshot()
assert snapshot[0]["peer_hash"] == "CC"
assert snapshot[0]["peer_hash"] != "AA"
async def test_empty_path_flood_creates_no_neighbour_link(self, handler):
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_hashed_flood_packet([], hash_size=1)
await handler(pkt, {"rssi": -70, "snr": 2.0}, local_transmission=False)
assert handler.neighbour_link_tracker.snapshot() == []
async def test_direct_packets_create_no_neighbour_link(self, handler):
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_direct_packet(path=bytes([LOCAL_HASH, 0x44]))
await handler(pkt, {"rssi": -70, "snr": 2.0}, local_transmission=False)
assert handler.neighbour_link_tracker.snapshot() == []
async def test_trace_packets_create_no_neighbour_link(self, handler):
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_hashed_flood_packet(["11", "22"], hash_size=1, payload_type=PAYLOAD_TYPE_TRACE)
await handler(pkt, {"rssi": -70, "snr": 2.0}, local_transmission=False)
assert handler.neighbour_link_tracker.snapshot() == []
async def test_local_transmissions_create_no_neighbour_link(self, handler):
handler.config["repeater"]["mode"] = "no_tx"
pkt = _make_hashed_flood_packet(["11", "22"], hash_size=1)
await handler(pkt, {"rssi": -70, "snr": 2.0}, local_transmission=True)
assert handler.neighbour_link_tracker.snapshot() == []
async def test_transport_flood_is_observed(self, handler):
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_hashed_transport_flood_packet(["19", "2A", "3B"], hash_size=1)
await handler(pkt, {"rssi": -60, "snr": 4.0}, local_transmission=False)
snapshot = handler.neighbour_link_tracker.snapshot()
assert len(snapshot) == 1
assert snapshot[0]["peer_hash"] == "3B"
async def test_existing_packet_score_calculation_is_reused_unchanged(self, handler):
handler.config["repeater"]["mode"] = "monitor"
pkt = _make_hashed_flood_packet(["55"], hash_size=1)
with patch.object(handler, "calculate_packet_score", return_value=0.42) as score_mock:
await handler(pkt, {"rssi": -81.0, "snr": 3.25}, local_transmission=False)
score_mock.assert_any_call(
3.25,
len(pkt.payload or b""),
handler.radio_config["spreading_factor"],
)
assert score_mock.call_count >= 1
snapshot = handler.neighbour_link_tracker.snapshot()
assert snapshot[0]["last_score"] == pytest.approx(0.42)
def test_first_sample_initializes_ewma_directly(self, handler):
pkt = _make_hashed_flood_packet(["66"], hash_size=1)
self._observe_with_tracker(handler, pkt, rssi=-90.0, snr=7.0, is_duplicate=False)
snapshot = handler.neighbour_link_tracker.snapshot()
assert snapshot[0]["ewma_rssi"] == pytest.approx(-90.0)
assert snapshot[0]["ewma_snr"] == pytest.approx(7.0)
assert snapshot[0]["ewma_score"] == pytest.approx(snapshot[0]["last_score"])
def test_later_samples_apply_configured_ewma_alpha(self, handler):
handler.config["repeater"]["neighbour_link_ewma_alpha"] = 0.5
handler.reload_runtime_config()
pkt = _make_hashed_flood_packet(["77"], hash_size=1)
self._observe_with_tracker(handler, pkt, rssi=-100.0, snr=1.0, is_duplicate=False)
self._observe_with_tracker(handler, pkt, rssi=-80.0, snr=5.0, is_duplicate=False)
snapshot = handler.neighbour_link_tracker.snapshot()
assert snapshot[0]["ewma_rssi"] == pytest.approx(-90.0)
assert snapshot[0]["ewma_snr"] == pytest.approx(3.0)
def test_duplicate_samples_increment_duplicate_sample_count(self, handler):
pkt = _make_hashed_flood_packet(["88"], hash_size=1)
self._observe_with_tracker(handler, pkt, rssi=-75.0, snr=2.0, is_duplicate=False)
handler.record_duplicate(pkt, rssi=-74, snr=1.5)
snapshot = handler.neighbour_link_tracker.snapshot()
assert snapshot[0]["sample_count"] == 2
assert snapshot[0]["duplicate_sample_count"] == 1
def test_different_path_hash_widths_do_not_merge(self, handler):
pkt_1b = _make_hashed_flood_packet(["AB"], hash_size=1)
pkt_2b = _make_hashed_flood_packet(["00AB"], hash_size=2)
self._observe_with_tracker(handler, pkt_1b, rssi=-70.0, snr=3.0, is_duplicate=False)
self._observe_with_tracker(handler, pkt_2b, rssi=-71.0, snr=3.1, is_duplicate=False)
snapshot = handler.neighbour_link_tracker.snapshot()
assert len(snapshot) == 2
keys = {(row["path_hash_size"], row["peer_hash"]) for row in snapshot}
assert keys == {(1, "AB"), (2, "00AB")}
def test_link_state_is_bounded(self, handler):
handler.config["repeater"]["neighbour_link_max_entries"] = 2
handler.config["repeater"]["neighbour_link_ttl_seconds"] = 86400
handler.reload_runtime_config()
for peer in ("10", "20", "30"):
pkt = _make_hashed_flood_packet([peer], hash_size=1)
self._observe_with_tracker(handler, pkt, rssi=-70.0, snr=2.0, is_duplicate=False)
snapshot = handler.neighbour_link_tracker.snapshot()
assert len(snapshot) == 2
peers = {row["peer_hash"] for row in snapshot}
assert peers == {"20", "30"}
def test_expired_links_are_removed_using_monotonic_time(self, handler):
handler.config["repeater"]["neighbour_link_ttl_seconds"] = 1
handler.reload_runtime_config()
pkt = _make_hashed_flood_packet(["44"], hash_size=1)
self._observe_with_tracker(handler, pkt, rssi=-65.0, snr=6.0, is_duplicate=False)
with handler.neighbour_link_tracker.lock:
for link in handler.neighbour_link_tracker.links.values():
link.last_seen_monotonic = time.monotonic() - 5.0
assert handler.neighbour_link_tracker.snapshot() == []
def test_snapshot_returns_plain_data_not_live_mapping(self, handler):
pkt = _make_hashed_flood_packet(["99"], hash_size=1)
self._observe_with_tracker(handler, pkt, rssi=-64.0, snr=6.0, is_duplicate=False)
snapshot = handler.neighbour_link_tracker.snapshot()
snapshot[0]["sample_count"] = 999
snapshot[0]["peer_hash"] = "MUTATED"
fresh = handler.neighbour_link_tracker.snapshot()
assert fresh[0]["sample_count"] != 999
assert fresh[0]["peer_hash"] == "99"
# ===================================================================
# 15. Edge cases and regression tests
# ===================================================================
@@ -656,9 +656,7 @@ async def test_protocol_request_real_crypto_consume_vs_collision_forward():
genuine, _ = PacketBuilder.create_protocol_request(
_SendDest(local.get_public_key()), sender, REQ_TYPE_GET_STATUS
)
with patch(
"repeater.handler_helpers.protocol_request.asyncio.sleep", new_callable=AsyncMock
):
with patch("repeater.handler_helpers.protocol_request.asyncio.sleep", new_callable=AsyncMock):
assert await helper.process_request_packet(genuine) is True
assert genuine.is_marked_do_not_retransmit()
injector.assert_awaited() # a response was transmitted
+108
View File
@@ -200,6 +200,114 @@ def test_store_packet_returns_inserted_row_id(tmp_path):
assert row[3] == 3
def test_packets_table_has_upstream_columns_and_index(tmp_path):
h = _make_handler(tmp_path)
with h._connect() as conn:
cols = conn.execute("PRAGMA table_info(packets)").fetchall()
col_names = {col[1] for col in cols}
idx_rows = conn.execute("PRAGMA index_list(packets)").fetchall()
idx_names = {row[1] for row in idx_rows}
assert "upstream_hash" in col_names
assert "upstream_hash_size" in col_names
assert "idx_packets_upstream_time" in idx_names
def test_store_packet_persists_upstream_fields(tmp_path):
h = _make_handler(tmp_path)
packet_id = h.store_packet(
{
"timestamp": 200.0,
"type": 1,
"route": 1,
"length": 9,
"transmitted": False,
"packet_hash": "pkt-upstream",
"upstream_hash": "AB",
"upstream_hash_size": 2,
"original_path": ["CD", "AB"],
}
)
row = h.get_packet_by_id(int(packet_id))
assert row is not None
assert row["upstream_hash"] == "AB"
assert row["upstream_hash_size"] == 2
def test_neighbor_link_history_uses_packets_table_and_filters_hash_and_size(tmp_path):
h = _make_handler(tmp_path)
base_ts = 1_700_000_000.0
h.store_packet(
{
"timestamp": base_ts,
"type": 4,
"route": 1,
"length": 10,
"rssi": -80,
"snr": 3.5,
"score": 0.4,
"is_duplicate": False,
"packet_hash": "match-1",
"upstream_hash": "AA",
"upstream_hash_size": 1,
"original_path": ["10", "AA"],
}
)
h.store_packet(
{
"timestamp": base_ts + 1.0,
"type": 5,
"route": 1,
"length": 11,
"rssi": -82,
"snr": 2.5,
"score": 0.35,
"is_duplicate": True,
"packet_hash": "match-2",
"upstream_hash": "AA",
"upstream_hash_size": 1,
"original_path": ["20", "30", "AA"],
}
)
# Same hash but different width: must not be merged.
h.store_packet(
{
"timestamp": base_ts + 2.0,
"type": 6,
"route": 1,
"length": 12,
"packet_hash": "wrong-width",
"upstream_hash": "AA",
"upstream_hash_size": 2,
"original_path": ["00AA"],
}
)
# Different hash: must be filtered out.
h.store_packet(
{
"timestamp": base_ts + 3.0,
"type": 7,
"route": 1,
"length": 13,
"packet_hash": "wrong-hash",
"upstream_hash": "BB",
"upstream_hash_size": 1,
"original_path": ["BB"],
}
)
rows = h.get_neighbor_link_history(peer_hash="aa", path_hash_size=1, hours=50000, limit=100)
assert [row["packet_hash"] for row in rows] == ["match-1", "match-2"]
assert rows[0]["path_hop_count"] == 2
assert rows[1]["path_hop_count"] == 3
assert rows[1]["is_duplicate"] is True
def test_recent_packet_queries_include_ids_and_preserve_duplicate_hash_rows(tmp_path):
h = _make_handler(tmp_path)