mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-06-13 01:34:45 +02:00
Merge pull request #142 from agessaman/dev-companion-v2-cleanup
Add Companion seeding from repeater advert database, add companions connection status to sessions panel
This commit is contained in:
@@ -1877,6 +1877,63 @@ class SQLiteHandler:
|
||||
logger.error(f"Failed to upsert companion contact: {e}")
|
||||
return False
|
||||
|
||||
def companion_import_repeater_contacts(
|
||||
self,
|
||||
companion_hash: str,
|
||||
contact_types: Optional[List[str]] = None,
|
||||
hours: Optional[int] = None,
|
||||
limit: Optional[int] = None,
|
||||
) -> int:
|
||||
"""Import repeater adverts into a companion's contact store (one-time seed).
|
||||
|
||||
Results are ordered by last_seen DESC so the most recent contacts are
|
||||
imported first. Optional hours filters to adverts seen within the last N hours;
|
||||
optional limit caps how many contacts are imported.
|
||||
"""
|
||||
type_map = {"companion": 1, "repeater": 2, "room_server": 3, "sensor": 4}
|
||||
try:
|
||||
with sqlite3.connect(self.sqlite_path) as conn:
|
||||
conn.row_factory = sqlite3.Row
|
||||
query = (
|
||||
"SELECT pubkey, node_name, contact_type, latitude, longitude, last_seen "
|
||||
"FROM adverts WHERE pubkey IS NOT NULL"
|
||||
)
|
||||
params: list = []
|
||||
if contact_types:
|
||||
placeholders = ",".join("?" * len(contact_types))
|
||||
query += f" AND contact_type IN ({placeholders})"
|
||||
params.extend(contact_types)
|
||||
if hours is not None:
|
||||
cutoff = time.time() - (hours * 3600)
|
||||
query += " AND last_seen >= ?"
|
||||
params.append(cutoff)
|
||||
query += " ORDER BY last_seen DESC"
|
||||
if limit is not None:
|
||||
query += " LIMIT ?"
|
||||
params.append(limit)
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
|
||||
count = 0
|
||||
for row in rows:
|
||||
raw_type = row["contact_type"] or ""
|
||||
normalized_type = raw_type.lower().replace(" ", "_").strip()
|
||||
adv_type = type_map.get(normalized_type, 0)
|
||||
contact = {
|
||||
"pubkey": bytes.fromhex(row["pubkey"]),
|
||||
"name": row["node_name"] or "",
|
||||
"adv_type": adv_type,
|
||||
"gps_lat": row["latitude"] or 0.0,
|
||||
"gps_lon": row["longitude"] or 0.0,
|
||||
"last_advert_timestamp": int(row["last_seen"] or 0),
|
||||
"lastmod": int(row["last_seen"] or 0),
|
||||
}
|
||||
if self.companion_upsert_contact(companion_hash, contact):
|
||||
count += 1
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to import repeater contacts: {e}")
|
||||
return 0
|
||||
|
||||
def companion_load_prefs(self, companion_hash: str) -> Optional[Dict]:
|
||||
"""Load persisted prefs for a companion. Returns parsed JSON dict or None if no row."""
|
||||
try:
|
||||
|
||||
@@ -3131,12 +3131,49 @@ class APIEndpoints:
|
||||
}
|
||||
)
|
||||
|
||||
# Add companion identities (no login/ACL fields; use registered + active for status)
|
||||
companion_bridges = getattr(self.daemon_instance, "companion_bridges", {})
|
||||
# Build hash -> active TCP connection and client IP (frame server has at most one client)
|
||||
active_by_hash = {}
|
||||
client_ip_by_hash = {}
|
||||
for fs in getattr(self.daemon_instance, "companion_frame_servers", []):
|
||||
try:
|
||||
ch = getattr(fs, "companion_hash", None)
|
||||
h = (
|
||||
int(ch, 16)
|
||||
if isinstance(ch, str) and ch.startswith("0x")
|
||||
else (int(ch) if ch is not None else None)
|
||||
)
|
||||
if h is not None:
|
||||
writer = getattr(fs, "_client_writer", None)
|
||||
active_by_hash[h] = writer is not None
|
||||
if writer is not None:
|
||||
peername = writer.get_extra_info("peername") if hasattr(writer, "get_extra_info") else None
|
||||
client_ip_by_hash[h] = str(peername[0]) if peername else None
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
for name, identity, config in identity_manager.get_identities_by_type("companion"):
|
||||
hash_byte = identity.get_public_key()[0]
|
||||
active = active_by_hash.get(hash_byte, False)
|
||||
entry = {
|
||||
"name": name,
|
||||
"type": "companion",
|
||||
"hash": f"0x{hash_byte:02X}",
|
||||
"registered": hash_byte in companion_bridges,
|
||||
"active": active,
|
||||
}
|
||||
if active:
|
||||
entry["client_ip"] = client_ip_by_hash.get(hash_byte)
|
||||
else:
|
||||
entry["client_ip"] = None
|
||||
acl_info_list.append(entry)
|
||||
|
||||
return self._success(
|
||||
{
|
||||
"acls": acl_info_list,
|
||||
"total_identities": len(acl_info_list),
|
||||
"total_authenticated_clients": sum(
|
||||
a["authenticated_clients"] for a in acl_info_list
|
||||
a.get("authenticated_clients", 0) for a in acl_info_list
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -3198,6 +3235,15 @@ class APIEndpoints:
|
||||
"hash": self._fmt_hash(identity.get_public_key()),
|
||||
}
|
||||
|
||||
# Add companions
|
||||
for name, identity, config in identity_manager.get_identities_by_type("companion"):
|
||||
hash_byte = identity.get_public_key()[0]
|
||||
identity_map[hash_byte] = {
|
||||
"name": name,
|
||||
"type": "companion",
|
||||
"hash": f"0x{hash_byte:02X}",
|
||||
}
|
||||
|
||||
# Filter by identity if requested
|
||||
target_hash = None
|
||||
if identity_hash:
|
||||
@@ -3394,6 +3440,7 @@ class APIEndpoints:
|
||||
identity_stats = {
|
||||
"repeater": {"count": 0, "clients": 0},
|
||||
"room_server": {"count": 0, "clients": 0},
|
||||
"companion": {"count": 0, "clients": 0},
|
||||
}
|
||||
|
||||
# Count repeater
|
||||
@@ -3430,6 +3477,18 @@ class APIEndpoints:
|
||||
else:
|
||||
guest_count += 1
|
||||
|
||||
# Count companions (no admin/guest; they use frame server, not OTA login)
|
||||
companions = identity_manager.get_identities_by_type("companion")
|
||||
identity_stats["companion"]["count"] = len(companions)
|
||||
|
||||
for name, identity, config in companions:
|
||||
hash_byte = identity.get_public_key()[0]
|
||||
acl = acl_dict.get(hash_byte)
|
||||
if acl:
|
||||
clients = acl.get_all_clients()
|
||||
identity_stats["companion"]["clients"] += len(clients)
|
||||
total_clients += len(clients)
|
||||
|
||||
return self._success(
|
||||
{
|
||||
"total_identities": len(acl_dict),
|
||||
|
||||
@@ -146,6 +146,23 @@ class CompanionAPIEndpoints:
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise cherrypy.HTTPError(400, f"Invalid public key: {exc}")
|
||||
|
||||
def _get_sqlite_handler(self):
|
||||
"""Return the repeater's sqlite_handler, or raise 503 if unavailable."""
|
||||
if not self.daemon_instance:
|
||||
raise cherrypy.HTTPError(503, "Daemon not initialized")
|
||||
if (
|
||||
not hasattr(self.daemon_instance, "repeater_handler")
|
||||
or not self.daemon_instance.repeater_handler
|
||||
):
|
||||
raise cherrypy.HTTPError(503, "Repeater handler not initialized")
|
||||
storage = getattr(self.daemon_instance.repeater_handler, "storage", None)
|
||||
if not storage:
|
||||
raise cherrypy.HTTPError(503, "Storage not initialized")
|
||||
sqlite_handler = getattr(storage, "sqlite_handler", None)
|
||||
if not sqlite_handler:
|
||||
raise cherrypy.HTTPError(503, "SQLite storage not available")
|
||||
return sqlite_handler
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# SSE push-event plumbing
|
||||
# ------------------------------------------------------------------
|
||||
@@ -323,6 +340,75 @@ class CompanionAPIEndpoints:
|
||||
}
|
||||
)
|
||||
|
||||
@cherrypy.expose
|
||||
@cherrypy.tools.json_out()
|
||||
@require_auth
|
||||
def import_repeater_contacts(self, **kwargs):
|
||||
"""POST /api/companion/import_repeater_contacts {companion_name, contact_types?, hours?, limit?}
|
||||
|
||||
Import repeater adverts into this companion's contact store (one-time seed).
|
||||
Optional: contact_types (list), hours (only adverts seen in last N hours),
|
||||
limit (max contacts to import, capped by companion max_contacts).
|
||||
Results are sorted by last_seen DESC. After import, contacts are hot-reloaded.
|
||||
"""
|
||||
self._require_post()
|
||||
body = self._get_json_body()
|
||||
companion_name = body.get("companion_name")
|
||||
if not companion_name:
|
||||
raise cherrypy.HTTPError(400, "companion_name required")
|
||||
contact_types = body.get("contact_types")
|
||||
if contact_types is not None:
|
||||
if not isinstance(contact_types, list):
|
||||
raise cherrypy.HTTPError(400, "contact_types must be a list")
|
||||
allowed = {"companion", "repeater", "room_server", "sensor"}
|
||||
for t in contact_types:
|
||||
if not isinstance(t, str) or t not in allowed:
|
||||
raise cherrypy.HTTPError(
|
||||
400,
|
||||
f"contact_types must contain only: companion, repeater, room_server, sensor (got {t!r})",
|
||||
)
|
||||
if not contact_types:
|
||||
contact_types = None
|
||||
hours = body.get("hours")
|
||||
if hours is not None:
|
||||
try:
|
||||
hours = int(hours)
|
||||
except (TypeError, ValueError):
|
||||
raise cherrypy.HTTPError(400, "hours must be a positive integer")
|
||||
if hours < 1:
|
||||
raise cherrypy.HTTPError(400, "hours must be a positive integer")
|
||||
limit = body.get("limit")
|
||||
if limit is not None:
|
||||
try:
|
||||
limit = int(limit)
|
||||
except (TypeError, ValueError):
|
||||
raise cherrypy.HTTPError(400, "limit must be a positive integer")
|
||||
if limit < 1:
|
||||
raise cherrypy.HTTPError(400, "limit must be a positive integer")
|
||||
bridge = self._get_bridge(**self._resolve_bridge_params(body))
|
||||
if limit is not None:
|
||||
max_contacts = getattr(bridge, "max_contacts", 1000)
|
||||
limit = min(limit, max_contacts)
|
||||
companion_hash = getattr(bridge, "_companion_hash", None)
|
||||
if not companion_hash:
|
||||
raise cherrypy.HTTPError(503, "Companion hash not available")
|
||||
sqlite_handler = self._get_sqlite_handler()
|
||||
count = sqlite_handler.companion_import_repeater_contacts(
|
||||
companion_hash,
|
||||
contact_types=contact_types,
|
||||
hours=hours,
|
||||
limit=limit,
|
||||
)
|
||||
contact_rows = sqlite_handler.companion_load_contacts(companion_hash)
|
||||
if contact_rows:
|
||||
records = []
|
||||
for row in contact_rows:
|
||||
d = dict(row)
|
||||
d["public_key"] = d.pop("pubkey", d.get("public_key", b""))
|
||||
records.append(d)
|
||||
bridge.contacts.load_from_dicts(records)
|
||||
return self._success({"imported": count})
|
||||
|
||||
# ----- Channels -----
|
||||
|
||||
@cherrypy.expose
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{a as p,b as n,g as m,e as t,s as g,t as s,j as d,p as l}from"./index-D0IT5vDS.js";const f={class:"flex items-center justify-between mb-4"},w={class:"text-xl font-semibold text-content-primary dark:text-content-primary"},v={class:"mb-6"},h={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},y={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},C={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},B={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},j={class:"flex gap-3"},_=p({__name:"ConfirmDialog",props:{show:{type:Boolean},title:{default:"Confirm Action"},message:{},confirmText:{default:"Confirm"},cancelText:{default:"Cancel"},variant:{default:"warning"}},emits:["close","confirm"],setup(c,{emit:b}){const o=c,r=b,u=i=>{i.target===i.currentTarget&&r("close")},k={danger:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",warning:"bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},x={danger:"bg-red-500 hover:bg-red-600",warning:"bg-yellow-500 hover:bg-yellow-600",info:"bg-blue-500 hover:bg-blue-600"};return(i,e)=>o.show?(l(),n("div",{key:0,onClick:u,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[3]||(e[3]=g(()=>{},["stop"]))},[t("div",f,[t("h3",w,s(o.title),1),t("button",{onClick:e[0]||(e[0]=a=>r("close")),class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},e[4]||(e[4]=[t("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",v,[t("div",{class:d(["inline-flex p-3 rounded-xl mb-4",k[o.variant]])},[o.variant==="danger"?(l(),n("svg",h,e[5]||(e[5]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):o.variant==="warning"?(l(),n("svg",y,e[6]||(e[6]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):(l(),n("svg",C,e[7]||(e[7]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),t("p",B,s(o.message),1)]),t("div",j,[t("button",{onClick:e[1]||(e[1]=a=>r("close")),class:"flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10"},s(o.cancelText),1),t("button",{onClick:e[2]||(e[2]=a=>r("confirm")),class:d(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",x[o.variant]])},s(o.confirmText),3)])])])):m("",!0)}});export{_};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{a as e,b as r,i as o,p as n}from"./index-D0IT5vDS.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6">Help & Documentation</h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3">pyMC Repeater Wiki</h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>',1)])))}});export{d as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{a as k,b as o,g,e as r,j as a,t as p,s as x,p as s}from"./index-D0IT5vDS.js";const f={class:"mb-6"},m={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},v={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},h={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},w={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},C={class:"flex"},B=k({__name:"MessageDialog",props:{show:{type:Boolean},message:{},variant:{default:"success"}},emits:["close"],setup(i,{emit:d}){const t=i,l=d,c=n=>{n.target===n.currentTarget&&l("close")},b={success:"bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400",error:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},u={success:"bg-green-500 hover:bg-green-600",error:"bg-red-500 hover:bg-red-600",info:"bg-blue-500 hover:bg-blue-600"};return(n,e)=>t.show?(s(),o("div",{key:0,onClick:c,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[r("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[1]||(e[1]=x(()=>{},["stop"]))},[r("div",f,[r("div",{class:a(["inline-flex p-3 rounded-xl mb-4",b[t.variant]])},[t.variant==="success"?(s(),o("svg",m,e[2]||(e[2]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"},null,-1)]))):t.variant==="error"?(s(),o("svg",v,e[3]||(e[3]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"},null,-1)]))):(s(),o("svg",h,e[4]||(e[4]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),r("p",w,p(t.message),1)]),r("div",C,[r("button",{onClick:e[0]||(e[0]=y=>l("close")),class:a(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",u[t.variant]])}," OK ",2)])])])):g("",!0)}});export{B as _};
|
||||
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
@@ -0,0 +1 @@
|
||||
import{M as x,c as s}from"./index-D0IT5vDS.js";const l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},d=-116,i=8,u=5;function y(t,e){return t-e}function S(t){return l[t]??l[i]}function f(t,e){const r=e+u;if(t<=e){const o=t<=e-5?0:1;return{bars:o,color:"text-red-600 dark:text-red-400",snr:t,quality:o===0?"none":"poor"}}if(t<r){const n=(t-e)/u<.5?2:3;return{bars:n,color:n===2?"text-orange-600 dark:text-orange-400":"text-yellow-600 dark:text-yellow-400",snr:t,quality:"fair"}}const a=t-r>=10?5:4;return{bars:a,color:a===5?"text-green-600 dark:text-green-400":"text-green-600 dark:text-green-300",snr:t,quality:a===5?"excellent":"good"}}function N(){const t=x(),e=s(()=>t.noiseFloorDbm??d),r=s(()=>t.stats?.config?.radio?.spreading_factor??i),c=s(()=>S(r.value));return{getSignalQuality:o=>{if(!o||o>0||o<-120)return{bars:0,color:"text-gray-400 dark:text-gray-500",snr:-999,quality:"none"};const n=y(o,e.value),g=Math.max(-30,Math.min(20,n));return f(g,c.value)},noiseFloor:e,spreadingFactor:r,minSNR:c}}export{N as u};
|
||||
@@ -8,8 +8,8 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-BvDdpPbD.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CMJaSgt0.css">
|
||||
<script type="module" crossorigin src="/assets/index-D0IT5vDS.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-b0YoQjVs.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Reference in New Issue
Block a user