feat: add backup and restore and DB man

This commit is contained in:
Lloyd
2026-03-27 11:15:53 +00:00
parent 031f7b5e47
commit f5dbd83cda
27 changed files with 538 additions and 58 deletions
+106
View File
@@ -1148,6 +1148,112 @@ class SQLiteHandler:
logger.error(f"Failed to get noise floor stats: {e}")
return {}
def get_table_stats(self) -> dict:
"""Get row counts, date ranges, and storage info for all tables."""
try:
db_size = self.sqlite_path.stat().st_size if self.sqlite_path.exists() else 0
tables_with_timestamp = {
"packets": "timestamp",
"adverts": "timestamp",
"noise_floor": "timestamp",
"crc_errors": "timestamp",
"room_messages": "created_at",
"companion_messages": "created_at",
}
tables_without_timestamp = [
"transport_keys",
"api_tokens",
"room_client_sync",
"companion_contacts",
"companion_channels",
"companion_prefs",
"migrations",
]
table_info = []
with sqlite3.connect(self.sqlite_path) as conn:
# Get actual tables present in the database
existing = {
row[0]
for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
).fetchall()
}
for table, ts_col in tables_with_timestamp.items():
if table not in existing:
continue
row = conn.execute(
f"SELECT COUNT(*), MIN({ts_col}), MAX({ts_col}) FROM {table}" # noqa: S608
).fetchone()
count, oldest, newest = row[0], row[1], row[2]
table_info.append(
{
"name": table,
"row_count": count,
"oldest_timestamp": oldest,
"newest_timestamp": newest,
"has_timestamp": True,
}
)
for table in tables_without_timestamp:
if table not in existing:
continue
count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] # noqa: S608
table_info.append(
{
"name": table,
"row_count": count,
"has_timestamp": False,
}
)
return {"database_size_bytes": db_size, "tables": table_info}
except Exception as e:
logger.error(f"Failed to get table stats: {e}")
return {"database_size_bytes": 0, "tables": []}
def purge_table(self, table_name: str) -> int:
"""Delete all rows from a specific table. Returns rows deleted."""
# Hardcoded allowlist — never allow arbitrary table names
PURGEABLE = {
"packets",
"adverts",
"noise_floor",
"crc_errors",
"room_messages",
"room_client_sync",
"companion_contacts",
"companion_channels",
"companion_messages",
"companion_prefs",
}
if table_name not in PURGEABLE:
raise ValueError(f"Table '{table_name}' cannot be purged")
try:
with sqlite3.connect(self.sqlite_path) as conn:
result = conn.execute(f"DELETE FROM {table_name}") # noqa: S608
conn.commit()
logger.info(f"Purged {result.rowcount} rows from {table_name}")
return result.rowcount
except Exception as e:
logger.error(f"Failed to purge table {table_name}: {e}")
raise
def vacuum(self):
"""Reclaim disk space after purging tables."""
try:
with sqlite3.connect(self.sqlite_path) as conn:
conn.execute("VACUUM")
logger.info("Database vacuumed successfully")
except Exception as e:
logger.error(f"Failed to vacuum database: {e}")
raise
def cleanup_old_data(self, days: int = 7):
try:
cutoff = time.time() - (days * 24 * 3600)
+374
View File
@@ -135,6 +135,16 @@ logger = logging.getLogger("HTTPServer")
# GET /api/radio_presets - Get radio preset configurations
# POST /api/setup_wizard - Complete initial setup wizard
# Backup & Restore
# GET /api/config_export - Export config as JSON (redacts secrets, ?include_secrets=true for full backup)
# POST /api/config_import - Import config JSON and apply (supports full backup restore with secrets)
# GET /api/identity_export - Export repeater identity key as hex string
#
# Database Management
# GET /api/db_stats - Get table row counts, date ranges, database size
# POST /api/db_purge - Purge (empty) one or more tables
# POST /api/db_vacuum - Reclaim disk space (VACUUM)
# Common Parameters
# hours - Time range in hours (default: 24)
# resolution - Data resolution: 'average', 'max', 'min' (default: 'average')
@@ -4348,6 +4358,370 @@ class APIEndpoints:
logger.error(f"CLI endpoint error: {e}", exc_info=True)
return self._error(str(e))
# ======================
# Backup & Restore
# ======================
@cherrypy.expose
@cherrypy.tools.json_out()
def config_export(self, include_secrets=None):
"""Export the full configuration as JSON.
GET /api/config_export
GET /api/config_export?include_secrets=true (full backup with secrets)
By default, sensitive fields (passwords, JWT secrets, identity keys)
are redacted. Pass ?include_secrets=true for a full backup that
includes all secrets — required for restoring to a new device.
Returns: {"success": true, "data": {"meta": {...}, "config": {...}}}
"""
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
import copy
full_backup = str(include_secrets).lower() in ("true", "1", "yes")
exported = copy.deepcopy(self.config)
if full_backup:
# Convert binary identity key to hex for JSON serialisation
rep = exported.get("repeater", {})
if "identity_key" in rep and isinstance(rep["identity_key"], bytes):
rep["identity_key"] = rep["identity_key"].hex()
# Convert identity keys in companion / room_server configs
for section in ("room_servers", "companions"):
entries = exported.get("identities", {}).get(section, []) or []
for entry in entries:
if isinstance(entry.get("identity_key"), bytes):
entry["identity_key"] = entry["identity_key"].hex()
else:
# Redact sensitive fields
sec = exported.get("repeater", {}).get("security", {})
for field in ("admin_password", "guest_password", "jwt_secret"):
if field in sec:
sec[field] = "*** REDACTED ***"
# Redact repeater identity key
rep = exported.get("repeater", {})
if "identity_key" in rep:
del rep["identity_key"]
# Redact identity keys in companion / room_server configs
for section in ("room_servers", "companions"):
entries = exported.get("identities", {}).get(section, []) or []
for entry in entries:
if "identity_key" in entry:
entry["identity_key"] = "*** REDACTED ***"
meta = {
"exported_at": datetime.utcnow().isoformat() + "Z",
"version": __version__,
"config_path": self._config_path,
"includes_secrets": full_backup,
}
return {"success": True, "data": {"meta": meta, "config": exported}}
except Exception as e:
logger.error(f"Config export error: {e}", exc_info=True)
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def config_import(self):
"""Import a configuration JSON and apply it.
POST /api/config_import
Body: {"config": { ... }, "restart_after": false}
The imported config is merged section-by-section into the current config.
Sections present in the import will overwrite current values.
Redacted sentinel values ("*** REDACTED ***") are skipped so that
existing passwords / keys are preserved.
If the import contains a non-redacted identity_key (from a full backup),
it will be restored. Redacted or missing identity keys are left unchanged.
Returns: {"success": true, "message": "...", "restart_required": true,
"sections_updated": [...]}
"""
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
data = cherrypy.request.json
imported_config = data.get("config")
if not imported_config or not isinstance(imported_config, dict):
return self._error("Missing or invalid 'config' object in request body")
# Sections we allow to be imported
ALLOWED_SECTIONS = {
"repeater", "mesh", "radio", "identities", "delays",
"ch341", "web", "letsmesh", "logging", "radio_type",
}
updated_sections = []
restart_required = False
for section, value in imported_config.items():
if section not in ALLOWED_SECTIONS:
logger.info(f"Config import: skipping unknown section '{section}'")
continue
if section == "repeater" and isinstance(value, dict):
# Preserve security secrets that are redacted
sec = value.get("security", {})
if isinstance(sec, dict):
cur_sec = self.config.get("repeater", {}).get("security", {})
for field in ("admin_password", "guest_password", "jwt_secret"):
if sec.get(field) == "*** REDACTED ***":
sec[field] = cur_sec.get(field, "")
# Restore identity_key only if a real (non-redacted) hex value is provided
ik = value.get("identity_key")
if ik and isinstance(ik, str) and ik != "*** REDACTED ***":
try:
value["identity_key"] = bytes.fromhex(ik)
except ValueError:
logger.warning("Config import: invalid identity_key hex, skipping")
value.pop("identity_key", None)
else:
value.pop("identity_key", None)
value.pop("identity_file", None)
if section == "identities" and isinstance(value, dict):
# Preserve identity keys that are redacted
for id_section in ("room_servers", "companions"):
entries = value.get(id_section, []) or []
cur_entries = (
self.config.get("identities", {}).get(id_section, []) or []
)
cur_by_name = {e.get("name"): e for e in cur_entries}
for entry in entries:
if entry.get("identity_key") == "*** REDACTED ***":
existing = cur_by_name.get(entry.get("name"), {})
entry["identity_key"] = existing.get("identity_key", "")
if section == "radio":
restart_required = True
if section == "radio_type":
# radio_type is a top-level scalar, not a dict
self.config[section] = value
else:
if section not in self.config:
self.config[section] = {}
if isinstance(value, dict) and isinstance(self.config[section], dict):
self.config[section].update(value)
else:
self.config[section] = value
updated_sections.append(section)
if not updated_sections:
return self._error("No valid configuration sections found in import")
# Persist and live-reload
result = self.config_manager.update_and_save(
updates={}, # Already applied above
live_update=True,
live_update_sections=updated_sections,
)
# Save to file (update_and_save with empty updates may not save)
saved = self.config_manager.save_to_file()
return {
"success": True,
"message": f"Imported {len(updated_sections)} config section(s)",
"sections_updated": updated_sections,
"saved": saved,
"restart_required": restart_required,
}
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error(f"Config import error: {e}", exc_info=True)
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
def identity_export(self):
"""Export the repeater's identity key as a hex string.
GET /api/identity_export
WARNING: This transmits the private key over the network.
Only use on trusted networks.
Returns: {"success": true, "data": {"identity_key_hex": "abcdef...",
"key_length_bytes": 32, "public_key_hex": "...",
"node_address": "0x42"}}
"""
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
identity_key = self.config.get("repeater", {}).get("identity_key")
if not identity_key:
return self._error("No identity key configured")
# Convert to hex
if isinstance(identity_key, bytes):
key_hex = identity_key.hex()
elif isinstance(identity_key, str):
key_hex = identity_key
else:
return self._error(f"Identity key has unexpected type: {type(identity_key).__name__}")
result = {
"identity_key_hex": key_hex,
"key_length_bytes": len(bytes.fromhex(key_hex)),
}
# Try to derive public key info
try:
if self.daemon_instance and hasattr(self.daemon_instance, "local_identity"):
li = self.daemon_instance.local_identity
pub = li.get_public_key()
result["public_key_hex"] = bytes(pub).hex()
result["node_address"] = f"0x{pub[0]:02x}"
except Exception:
pass # Not critical
return {"success": True, "data": result}
except Exception as e:
logger.error(f"Identity export error: {e}", exc_info=True)
return self._error(str(e))
# ======================
# Database Management
# ======================
@cherrypy.expose
@cherrypy.tools.json_out()
def db_stats(self):
"""Get database table statistics.
GET /api/db_stats
Returns row counts, date ranges, and total database size.
"""
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
storage = self._get_storage()
stats = storage.sqlite_handler.get_table_stats()
# Add RRD file size if it exists
rrd_path = storage.sqlite_handler.storage_dir / "metrics.rrd"
stats["rrd_size_bytes"] = (
rrd_path.stat().st_size if rrd_path.exists() else 0
)
return {"success": True, "data": stats}
except Exception as e:
logger.error(f"DB stats error: {e}", exc_info=True)
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def db_purge(self):
"""Purge (empty) one or more database tables.
POST /api/db_purge
Body: {"tables": ["packets", "adverts"]}
or {"tables": "all"} to purge all data tables
Returns per-table row counts deleted.
"""
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
data = cherrypy.request.json
tables_param = data.get("tables")
if not tables_param:
return self._error("Missing 'tables' parameter")
ALL_PURGEABLE = [
"packets", "adverts", "noise_floor", "crc_errors",
"room_messages", "room_client_sync",
"companion_contacts", "companion_channels",
"companion_messages", "companion_prefs",
]
if tables_param == "all":
tables = ALL_PURGEABLE
elif isinstance(tables_param, list):
tables = tables_param
else:
return self._error("'tables' must be a list of table names or 'all'")
storage = self._get_storage()
results = {}
for table in tables:
try:
deleted = storage.sqlite_handler.purge_table(table)
results[table] = {"deleted": deleted}
except ValueError as ve:
results[table] = {"error": str(ve)}
return {
"success": True,
"data": results,
"message": f"Purged {len([r for r in results.values() if 'deleted' in r])} table(s)",
}
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error(f"DB purge error: {e}", exc_info=True)
return self._error(str(e))
@cherrypy.expose
@cherrypy.tools.json_out()
@cherrypy.tools.json_in()
def db_vacuum(self):
"""Reclaim disk space after purging tables.
POST /api/db_vacuum
Runs SQLite VACUUM to compact the database file.
"""
self._set_cors_headers()
if cherrypy.request.method == "OPTIONS":
return ""
try:
self._require_post()
storage = self._get_storage()
size_before = storage.sqlite_handler.sqlite_path.stat().st_size
storage.sqlite_handler.vacuum()
size_after = storage.sqlite_handler.sqlite_path.stat().st_size
return {
"success": True,
"data": {
"size_before": size_before,
"size_after": size_after,
"freed_bytes": size_before - size_after,
},
}
except cherrypy.HTTPError:
raise
except Exception as e:
logger.error(f"DB vacuum error: {e}", exc_info=True)
return self._error(str(e))
# ======================
# OpenAPI Documentation
# ======================
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{a as m,e as n,h as p,f as t,x as g,t as s,k as d,q as l}from"./index-DiXGO0gy.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"},M={class:"flex gap-3"},j=m({__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,k=i=>{i.target===i.currentTarget&&r("close")},u={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:k,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",u[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",M,[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)])])])):p("",!0)}});export{j as _};
import{a as m,e as n,h as p,f as t,x as g,t as s,k as d,q as l}from"./index-DY3e-vDe.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"},M={class:"flex gap-3"},j=m({__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,k=i=>{i.target===i.currentTarget&&r("close")},u={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:k,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",u[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",M,[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)])])])):p("",!0)}});export{j as _};
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{a as e,e as r,j as o,q as n}from"./index-DiXGO0gy.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6">Help &amp; Documentation</h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3">pyMC Repeater Wiki</h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>',1)])))}});export{d as default};
import{a as e,e as r,j as o,q as n}from"./index-DY3e-vDe.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6">Help &amp; Documentation</h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3">pyMC Repeater Wiki</h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>',1)])))}});export{d as default};
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{a as k,e as o,h as g,f as r,k as a,t as p,x,q as s}from"./index-DiXGO0gy.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")},u={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"},b={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",u[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",b[t.variant]])}," OK ",2)])])])):g("",!0)}});export{B as _};
import{a as k,e as o,h as g,f as r,k as a,t as p,x,q as s}from"./index-DY3e-vDe.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")},u={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"},b={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",u[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",b[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
@@ -1,4 +1,4 @@
import{L as J,a as zl,r as ut,o as Hl,a0 as Ul,P as Rn,E as ql,e as tt,f as Z,h as Yt,t as Is,w as Kl,v as Vl,X as Ji,k as Tn,x as jl,q as it,y as Gl}from"./index-DiXGO0gy.js";/**
import{L as J,a as zl,r as ut,o as Hl,a0 as Ul,P as Rn,E as ql,e as tt,f as Z,h as Yt,t as Is,w as Kl,v as Vl,X as Ji,k as Tn,x as jl,q as it,y as Gl}from"./index-DY3e-vDe.js";/**
* Copyright (c) 2014-2024 The xterm.js authors. All rights reserved.
* @license MIT
*
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{M as x,c as s}from"./index-DiXGO0gy.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};
import{M as x,c as s}from"./index-DY3e-vDe.js";const l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},d=-116,i=8,u=5;function y(t,e){return t-e}function S(t){return l[t]??l[i]}function f(t,e){const r=e+u;if(t<=e){const o=t<=e-5?0:1;return{bars:o,color:"text-red-600 dark:text-red-400",snr:t,quality:o===0?"none":"poor"}}if(t<r){const n=(t-e)/u<.5?2:3;return{bars:n,color:n===2?"text-orange-600 dark:text-orange-400":"text-yellow-600 dark:text-yellow-400",snr:t,quality:"fair"}}const a=t-r>=10?5:4;return{bars:a,color:a===5?"text-green-600 dark:text-green-400":"text-green-600 dark:text-green-300",snr:t,quality:a===5?"excellent":"good"}}function N(){const t=x(),e=s(()=>t.noiseFloorDbm??d),r=s(()=>t.stats?.config?.radio?.spreading_factor??i),c=s(()=>S(r.value));return{getSignalQuality:o=>{if(!o||o>0||o<-120)return{bars:0,color:"text-gray-400 dark:text-gray-500",snr:-999,quality:"none"};const n=y(o,e.value),g=Math.max(-30,Math.min(20,n));return f(g,c.value)},noiseFloor:e,spreadingFactor:r,minSNR:c}}export{N as u};
+2 -2
View File
@@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-DiXGO0gy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BG-RmQ-I.css">
<script type="module" crossorigin src="/assets/index-DY3e-vDe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-UmMj5Kdd.css">
</head>
<body>
<div id="app"></div>