mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-08-11 11:23:28 +02:00
Compare commits
3 Commits
dev
...
feat-flood-supp
| Author | SHA1 | Date | |
|---|---|---|---|
| 438a02242d | |||
| 4fec619e36 | |||
| 8ba818c921 |
@@ -328,6 +328,24 @@ mesh:
|
||||
# Use null or "<null>" via CLI command "region default <null>" to clear.
|
||||
default_region: null
|
||||
|
||||
# Redundant flood retransmission suppression (rebroadcast cancellation).
|
||||
# While our own flood retransmission waits out its collision-avoidance delay,
|
||||
# other repeaters often rebroadcast the same packet first. When enabled,
|
||||
# hearing flood_suppression_threshold rebroadcast copies cancels our pending
|
||||
# TX; the packet is logged with drop reason
|
||||
# "Redundant flood retransmission (rebroadcast heard)" instead of being sent.
|
||||
# Reduces redundant airtime and collisions on busy networks.
|
||||
# Only copies whose path actually grew (another repeater appended its path
|
||||
# hash) count as rebroadcasts. Same-depth copies — e.g. the origin resending
|
||||
# the same packet — are ignored so normal path flooding (our TX appending our
|
||||
# hash to the path) is never cancelled without evidence of propagation.
|
||||
flood_suppression_enabled: false
|
||||
|
||||
# Rebroadcast copies heard (while our TX is pending) that trigger suppression.
|
||||
# Minimum 1. Raise to 2+ if your repeater provides critical coverage and you
|
||||
# only want to skip TX when the packet is clearly well-propagated.
|
||||
flood_suppression_threshold: 1
|
||||
|
||||
# Multiple Identity Configuration (Optional)
|
||||
# Define additional identities for the repeater to manage
|
||||
# Each identity operates independently with its own key pair and configuration
|
||||
|
||||
+222
-64
@@ -43,6 +43,11 @@ LOOP_DETECT_MAX_COUNTERS = {
|
||||
LOOP_DETECT_STRICT: 1,
|
||||
}
|
||||
|
||||
# Sentinel returned by schedule_retransmit's task when a pending flood TX was
|
||||
# cancelled by the redundant flood retransmission check (rebroadcasts of the
|
||||
# same packet were heard before our own TX slot fired).
|
||||
TX_RESULT_SUPPRESSED = "suppressed"
|
||||
|
||||
|
||||
class RepeaterHandler(BaseHandler):
|
||||
@staticmethod
|
||||
@@ -85,6 +90,10 @@ class RepeaterHandler(BaseHandler):
|
||||
self.loop_detect_mode = self._normalize_loop_detect_mode(
|
||||
config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
|
||||
)
|
||||
# Redundant flood retransmission suppression (rebroadcast cancellation).
|
||||
self._load_flood_suppression_config()
|
||||
# pkt_hash (full upper hex) -> {"cancel_event": asyncio.Event, "dup_count": int}
|
||||
self._pending_flood_tx = {}
|
||||
|
||||
radio = dispatcher.radio if dispatcher else None
|
||||
if radio:
|
||||
@@ -118,6 +127,7 @@ class RepeaterHandler(BaseHandler):
|
||||
self.sent_direct_count = 0
|
||||
self.flood_dup_count = 0
|
||||
self.direct_dup_count = 0
|
||||
self.flood_suppressed_count = 0
|
||||
|
||||
# Storage collector for persistent packet logging
|
||||
try:
|
||||
@@ -238,6 +248,7 @@ class RepeaterHandler(BaseHandler):
|
||||
snr = metadata.get("snr", 0.0)
|
||||
rssi = metadata.get("rssi", 0)
|
||||
transmitted = False
|
||||
tx_suppressed = False
|
||||
tx_delay_ms = 0.0
|
||||
drop_reason = None
|
||||
lbt_attempts = 0
|
||||
@@ -330,8 +341,17 @@ class RepeaterHandler(BaseHandler):
|
||||
self.dropped_count += 1
|
||||
drop_reason = "Duty cycle limit"
|
||||
else:
|
||||
suppressible = not local_transmission and route_type in (
|
||||
ROUTE_TYPE_FLOOD,
|
||||
ROUTE_TYPE_TRANSPORT_FLOOD,
|
||||
)
|
||||
tx_task = await self.schedule_retransmit(
|
||||
fwd_pkt, delay, airtime_ms, local_transmission=local_transmission
|
||||
fwd_pkt,
|
||||
delay,
|
||||
airtime_ms,
|
||||
local_transmission=local_transmission,
|
||||
packet_hash=pkt_hash_full,
|
||||
suppressible=suppressible,
|
||||
)
|
||||
try:
|
||||
tx_success = await tx_task
|
||||
@@ -340,7 +360,13 @@ class RepeaterHandler(BaseHandler):
|
||||
drop_reason = "TX failed"
|
||||
logger.warning(f"Local TX failed: {e}")
|
||||
raise
|
||||
if not tx_success:
|
||||
if tx_success == TX_RESULT_SUPPRESSED:
|
||||
transmitted = False
|
||||
tx_suppressed = True
|
||||
drop_reason = "Redundant flood retransmission (rebroadcast heard)"
|
||||
self.flood_suppressed_count += 1
|
||||
self.dropped_count += 1
|
||||
elif not tx_success:
|
||||
transmitted = False
|
||||
drop_reason = "TX failed"
|
||||
self.dropped_count += 1
|
||||
@@ -388,8 +414,8 @@ class RepeaterHandler(BaseHandler):
|
||||
f"Packet header=0x{packet.header:02x}, type={payload_type}, route={route_type}"
|
||||
)
|
||||
|
||||
# Check if this is a duplicate
|
||||
is_dupe = pkt_hash_full in self.seen_packets and not transmitted
|
||||
# Check if this is a duplicate (a suppressed TX is not a duplicate of itself)
|
||||
is_dupe = pkt_hash_full in self.seen_packets and not transmitted and not tx_suppressed
|
||||
|
||||
# Set drop reason for duplicates and count flood vs direct dups
|
||||
if is_dupe and drop_reason is None:
|
||||
@@ -543,9 +569,14 @@ class RepeaterHandler(BaseHandler):
|
||||
"""
|
||||
self.rx_count += 1
|
||||
route_type = packet.header & PH_ROUTE_MASK
|
||||
pkt_hash_full = packet.calculate_packet_hash().hex().upper()
|
||||
if route_type in (ROUTE_TYPE_FLOOD, ROUTE_TYPE_TRANSPORT_FLOOD):
|
||||
self.recv_flood_count += 1
|
||||
self.flood_dup_count += 1
|
||||
# Rebroadcast copy heard — may cancel our own pending flood TX.
|
||||
# Pass the copy's hop count so same-depth copies (no path growth)
|
||||
# are not mistaken for rebroadcasts.
|
||||
self._note_flood_duplicate(pkt_hash_full, hop_count=self._safe_hop_count(packet))
|
||||
elif route_type in (ROUTE_TYPE_DIRECT, ROUTE_TYPE_TRANSPORT_DIRECT):
|
||||
self.recv_direct_count += 1
|
||||
self.direct_dup_count += 1
|
||||
@@ -573,7 +604,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:
|
||||
@@ -773,6 +804,68 @@ class RepeaterHandler(BaseHandler):
|
||||
if len(self.seen_packets) > self.max_cache_size:
|
||||
self.seen_packets.popitem(last=False)
|
||||
|
||||
def _load_flood_suppression_config(self) -> None:
|
||||
"""Load redundant-flood-retransmission suppression settings from config."""
|
||||
mesh_cfg = self.config.get("mesh", {})
|
||||
self.flood_suppression_enabled = bool(mesh_cfg.get("flood_suppression_enabled", False))
|
||||
try:
|
||||
threshold = int(mesh_cfg.get("flood_suppression_threshold", 1))
|
||||
except (TypeError, ValueError):
|
||||
threshold = 1
|
||||
self.flood_suppression_threshold = max(1, threshold)
|
||||
|
||||
@staticmethod
|
||||
def _safe_hop_count(packet: Packet) -> Optional[int]:
|
||||
"""Return the packet's path hop count, or None when it cannot be read."""
|
||||
try:
|
||||
return packet.get_path_hash_count()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _note_flood_duplicate(
|
||||
self, pkt_hash: Optional[str], hop_count: Optional[int] = None
|
||||
) -> None:
|
||||
"""Count a rebroadcast copy heard while our own flood TX is pending.
|
||||
|
||||
Redundant flood retransmission check: once the configured number of
|
||||
rebroadcast copies has been heard, the pending retransmission is
|
||||
cancelled before it reaches the radio.
|
||||
|
||||
The packet hash covers only payload type + payload (path excluded), so
|
||||
every copy of a flood packet matches regardless of hop depth — including
|
||||
the origin resending the same packet. Only copies whose path actually
|
||||
grew (hop_count >= the depth of our own forwarded copy) prove another
|
||||
repeater relayed the packet and appended its path hash. Same-depth
|
||||
copies (e.g. the origin's retry with an empty path) are ignored so
|
||||
normal path flooding — which relies on our TX adding our hash to the
|
||||
path — is not cancelled without evidence of propagation.
|
||||
"""
|
||||
if not self.flood_suppression_enabled or not pkt_hash:
|
||||
return
|
||||
entry = self._pending_flood_tx.get(pkt_hash)
|
||||
if entry is None or entry["cancel_event"].is_set():
|
||||
return
|
||||
min_hops = entry.get("min_hops")
|
||||
if min_hops is not None and hop_count is not None and hop_count < min_hops:
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(
|
||||
"Flood suppression: ignoring same-depth copy of packet %s "
|
||||
"(hops=%d < %d) — not a rebroadcast",
|
||||
pkt_hash[:16],
|
||||
hop_count,
|
||||
min_hops,
|
||||
)
|
||||
return
|
||||
entry["dup_count"] += 1
|
||||
if entry["dup_count"] >= self.flood_suppression_threshold:
|
||||
entry["cancel_event"].set()
|
||||
logger.info(
|
||||
"Redundant flood retransmission check: heard %d rebroadcast(s) of "
|
||||
"packet %s — cancelling our pending TX",
|
||||
entry["dup_count"],
|
||||
pkt_hash[:16],
|
||||
)
|
||||
|
||||
def validate_packet(self, packet: Packet) -> Tuple[bool, str]:
|
||||
|
||||
if not packet or not packet.payload:
|
||||
@@ -952,6 +1045,13 @@ class RepeaterHandler(BaseHandler):
|
||||
|
||||
# Suppress duplicates — pass pre-computed hash to avoid a second SHA-256.
|
||||
if self.is_duplicate(packet, packet_hash=packet_hash):
|
||||
# Rebroadcast copy heard (dispatcher dedupe disabled path) — may
|
||||
# cancel our own pending flood TX for this packet. Hop count guards
|
||||
# against same-depth copies (no path growth) triggering suppression.
|
||||
self._note_flood_duplicate(
|
||||
packet_hash or packet.calculate_packet_hash().hex().upper(),
|
||||
hop_count=self._safe_hop_count(packet),
|
||||
)
|
||||
packet.drop_reason = "Duplicate"
|
||||
return None
|
||||
|
||||
@@ -1143,77 +1243,131 @@ class RepeaterHandler(BaseHandler):
|
||||
delay: float,
|
||||
airtime_ms: float = 0.0,
|
||||
local_transmission: bool = False,
|
||||
packet_hash: Optional[str] = None,
|
||||
suppressible: bool = False,
|
||||
):
|
||||
"""Schedule a packet retransmission with delay and return the task.
|
||||
|
||||
If local_transmission is True and the first send fails, retry once after
|
||||
a short delay (handles transient radio/LBT failures).
|
||||
|
||||
When suppressible is True (relayed flood packets) and flood suppression
|
||||
is enabled, the pending TX is registered under packet_hash so that
|
||||
rebroadcast copies heard during the delay cancel it (redundant flood
|
||||
retransmission check). The task then resolves to TX_RESULT_SUPPRESSED
|
||||
instead of True/False.
|
||||
"""
|
||||
suppression_entry = None
|
||||
if suppressible and self.flood_suppression_enabled and packet_hash:
|
||||
# min_hops = depth of our forwarded copy (original + our appended
|
||||
# hash). Only duplicate copies at this depth or deeper prove another
|
||||
# repeater relayed the packet (the packet hash excludes the path, so
|
||||
# the origin's own retry would otherwise match and cancel our TX).
|
||||
suppression_entry = {
|
||||
"cancel_event": asyncio.Event(),
|
||||
"dup_count": 0,
|
||||
"min_hops": self._safe_hop_count(fwd_pkt),
|
||||
}
|
||||
self._pending_flood_tx[packet_hash] = suppression_entry
|
||||
|
||||
def _suppression_triggered() -> bool:
|
||||
return suppression_entry is not None and suppression_entry["cancel_event"].is_set()
|
||||
|
||||
def _log_suppressed(stage: str) -> None:
|
||||
logger.info(
|
||||
"TX prevented (%s): redundant flood retransmission check — "
|
||||
"%d rebroadcast(s) of packet %s heard while TX was pending",
|
||||
stage,
|
||||
suppression_entry["dup_count"],
|
||||
(packet_hash or "")[:16],
|
||||
)
|
||||
|
||||
async def delayed_send():
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
# Each attempt gets its own lock acquisition so the 1-second retry
|
||||
# backoff (local_transmission only) happens OUTSIDE the lock.
|
||||
# Holding _tx_lock across asyncio.sleep(1.0) would block every other
|
||||
# queued TX task for the full backoff period.
|
||||
#
|
||||
# Loop runs once for relayed packets, twice for local_transmission:
|
||||
# attempt 0 — initial try (no pre-sleep)
|
||||
# attempt 1 — retry after 1s backoff outside the lock
|
||||
for attempt in range(2 if local_transmission else 1):
|
||||
if attempt > 0:
|
||||
# Back-off OUTSIDE the lock — other tasks can transmit here.
|
||||
logger.info("Retrying local TX in 1s (lock released during backoff)...")
|
||||
await asyncio.sleep(1.0)
|
||||
# Redundant flood retransmission check — rebroadcasts may have
|
||||
# arrived while this task slept through its collision-avoidance
|
||||
# delay. Skip the radio entirely if the packet is already covered.
|
||||
if _suppression_triggered():
|
||||
_log_suppressed("pre-send")
|
||||
return TX_RESULT_SUPPRESSED
|
||||
|
||||
async with self._tx_lock:
|
||||
# ── Authoritative duty-cycle gate ──────────────────────────
|
||||
# The upfront can_transmit() call in __call__ is advisory: it
|
||||
# avoids scheduling packets obviously over budget, but cannot
|
||||
# prevent a race between tasks whose delay timers expire nearly
|
||||
# simultaneously. Both pass the advisory check before either
|
||||
# records airtime, then both attempt to transmit.
|
||||
#
|
||||
# Inside _tx_lock only one task runs at a time. The check and
|
||||
# record_tx() are effectively atomic — no TOCTOU window.
|
||||
# Re-checked every attempt because airtime state may change
|
||||
# while we wait for the lock or sleep through backoff.
|
||||
if airtime_ms > 0:
|
||||
can_tx_now, _ = self.airtime_mgr.can_transmit(airtime_ms)
|
||||
if not can_tx_now:
|
||||
logger.warning(
|
||||
"Packet dropped at TX time: duty-cycle exceeded (airtime=%.1fms)",
|
||||
airtime_ms,
|
||||
)
|
||||
return False
|
||||
# Each attempt gets its own lock acquisition so the 1-second retry
|
||||
# backoff (local_transmission only) happens OUTSIDE the lock.
|
||||
# Holding _tx_lock across asyncio.sleep(1.0) would block every other
|
||||
# queued TX task for the full backoff period.
|
||||
#
|
||||
# Loop runs once for relayed packets, twice for local_transmission:
|
||||
# attempt 0 — initial try (no pre-sleep)
|
||||
# attempt 1 — retry after 1s backoff outside the lock
|
||||
for attempt in range(2 if local_transmission else 1):
|
||||
if attempt > 0:
|
||||
# Back-off OUTSIDE the lock — other tasks can transmit here.
|
||||
logger.info("Retrying local TX in 1s (lock released during backoff)...")
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
try:
|
||||
sent = await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
|
||||
if not sent:
|
||||
logger.warning(
|
||||
"Retransmit failed (attempt %d): dispatcher returned false",
|
||||
attempt + 1,
|
||||
)
|
||||
if local_transmission and attempt == 0:
|
||||
continue
|
||||
return False
|
||||
self._record_packet_sent(fwd_pkt)
|
||||
async with self._tx_lock:
|
||||
# Re-check after acquiring the lock: rebroadcasts may have
|
||||
# arrived while another task held the radio.
|
||||
if _suppression_triggered():
|
||||
_log_suppressed("at-lock")
|
||||
return TX_RESULT_SUPPRESSED
|
||||
|
||||
# ── Authoritative duty-cycle gate ──────────────────────────
|
||||
# The upfront can_transmit() call in __call__ is advisory: it
|
||||
# avoids scheduling packets obviously over budget, but cannot
|
||||
# prevent a race between tasks whose delay timers expire nearly
|
||||
# simultaneously. Both pass the advisory check before either
|
||||
# records airtime, then both attempt to transmit.
|
||||
#
|
||||
# Inside _tx_lock only one task runs at a time. The check and
|
||||
# record_tx() are effectively atomic — no TOCTOU window.
|
||||
# Re-checked every attempt because airtime state may change
|
||||
# while we wait for the lock or sleep through backoff.
|
||||
if airtime_ms > 0:
|
||||
self.airtime_mgr.record_tx(airtime_ms)
|
||||
packet_size = fwd_pkt.get_raw_length()
|
||||
logger.info(
|
||||
f"Retransmitted packet ({packet_size} bytes, "
|
||||
f"{airtime_ms:.1f}ms airtime)"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Retransmit failed (attempt {attempt + 1}): {e}")
|
||||
if local_transmission and attempt == 0:
|
||||
pass # release lock, outer loop sleeps, then retries
|
||||
else:
|
||||
raise
|
||||
return False
|
||||
can_tx_now, _ = self.airtime_mgr.can_transmit(airtime_ms)
|
||||
if not can_tx_now:
|
||||
logger.warning(
|
||||
"Packet dropped at TX time: duty-cycle exceeded (airtime=%.1fms)",
|
||||
airtime_ms,
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
sent = await self.dispatcher.send_packet(fwd_pkt, wait_for_ack=False)
|
||||
if not sent:
|
||||
logger.warning(
|
||||
"Retransmit failed (attempt %d): dispatcher returned false",
|
||||
attempt + 1,
|
||||
)
|
||||
if local_transmission and attempt == 0:
|
||||
continue
|
||||
return False
|
||||
self._record_packet_sent(fwd_pkt)
|
||||
if airtime_ms > 0:
|
||||
self.airtime_mgr.record_tx(airtime_ms)
|
||||
packet_size = fwd_pkt.get_raw_length()
|
||||
logger.info(
|
||||
f"Retransmitted packet ({packet_size} bytes, "
|
||||
f"{airtime_ms:.1f}ms airtime)"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Retransmit failed (attempt {attempt + 1}): {e}")
|
||||
if local_transmission and attempt == 0:
|
||||
pass # release lock, outer loop sleeps, then retries
|
||||
else:
|
||||
raise
|
||||
return False
|
||||
finally:
|
||||
# Deregister the pending TX regardless of outcome so the registry
|
||||
# never grows beyond in-flight retransmissions.
|
||||
if (
|
||||
suppression_entry is not None
|
||||
and self._pending_flood_tx.get(packet_hash) is suppression_entry
|
||||
):
|
||||
self._pending_flood_tx.pop(packet_hash, None)
|
||||
|
||||
return asyncio.create_task(delayed_send())
|
||||
|
||||
@@ -1287,6 +1441,7 @@ class RepeaterHandler(BaseHandler):
|
||||
"sent_direct_count": self.sent_direct_count,
|
||||
"flood_dup_count": self.flood_dup_count,
|
||||
"direct_dup_count": self.direct_dup_count,
|
||||
"flood_suppressed_count": self.flood_suppressed_count,
|
||||
"rx_per_hour": rx_per_hour,
|
||||
"forwarded_per_hour": forwarded_per_hour,
|
||||
"recent_packets": list(self.recent_packets),
|
||||
@@ -1332,6 +1487,8 @@ class RepeaterHandler(BaseHandler):
|
||||
self.config.get("mesh", {}).get("global_flood_allow", True),
|
||||
),
|
||||
"path_hash_mode": self.config.get("mesh", {}).get("path_hash_mode", 0),
|
||||
"flood_suppression_enabled": self.flood_suppression_enabled,
|
||||
"flood_suppression_threshold": self.flood_suppression_threshold,
|
||||
},
|
||||
"mqtt_brokers": self.config.get("mqtt_brokers", {}),
|
||||
},
|
||||
@@ -1465,6 +1622,7 @@ class RepeaterHandler(BaseHandler):
|
||||
self.loop_detect_mode = self._normalize_loop_detect_mode(
|
||||
self.config.get("mesh", {}).get("loop_detect", LOOP_DETECT_OFF)
|
||||
)
|
||||
self._load_flood_suppression_config()
|
||||
|
||||
# Note: Radio config changes require restart as they affect hardware
|
||||
# Note: Airtime manager has its own config reference that gets updated
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
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
File diff suppressed because one or more lines are too long
+1
-1
@@ -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-xkq2menr.js";import{t as d}from"./index-BVAZTGr4.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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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-xkq2menr.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
@@ -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-BVAZTGr4.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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{t as e}from"./dataService-DDCfvMzz.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
+1
-1
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};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./packets-DFiLhAXG.js";export{e as usePacketStore};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
||||
import{t as e}from"./system-BmJnUIFH.js";export{e as useSystemStore};
|
||||
@@ -0,0 +1 @@
|
||||
import{t as e}from"./system-xkq2menr.js";export{e as useSystemStore};
|
||||
+1
-1
@@ -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-C6hfbWWv.js";import{t as o}from"./packets-DFiLhAXG.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"./websocket-DIxNtk0e.js";export{e as useWebSocketStore};
|
||||
@@ -1 +0,0 @@
|
||||
import{t as e}from"./websocket-CNooJnNy.js";export{e as useWebSocketStore};
|
||||
+1
-1
@@ -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-C6hfbWWv.js";import{t as s}from"./packets-DFiLhAXG.js";import{t as c}from"./system-xkq2menr.js";import{t as l}from"./dataService-DDCfvMzz.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};
|
||||
@@ -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-BVAZTGr4.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-C6hfbWWv.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-DFiLhAXG.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/system-xkq2menr.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/dataService-DDCfvMzz.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/websocket-DIxNtk0e.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-B9NQYcBx.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user