numerous improvements
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -52,28 +52,37 @@ class AnnounceManager:
|
||||
limit=None,
|
||||
offset=0,
|
||||
):
|
||||
sql = "SELECT * FROM announces WHERE 1=1"
|
||||
sql = """
|
||||
SELECT a.*, c.custom_image as contact_image
|
||||
FROM announces a
|
||||
LEFT JOIN contacts c ON (
|
||||
a.identity_hash = c.remote_identity_hash OR
|
||||
a.destination_hash = c.lxmf_address OR
|
||||
a.destination_hash = c.lxst_address
|
||||
)
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
if aspect:
|
||||
sql += " AND aspect = ?"
|
||||
sql += " AND a.aspect = ?"
|
||||
params.append(aspect)
|
||||
if identity_hash:
|
||||
sql += " AND identity_hash = ?"
|
||||
sql += " AND a.identity_hash = ?"
|
||||
params.append(identity_hash)
|
||||
if destination_hash:
|
||||
sql += " AND destination_hash = ?"
|
||||
sql += " AND a.destination_hash = ?"
|
||||
params.append(destination_hash)
|
||||
if query:
|
||||
like_term = f"%{query}%"
|
||||
sql += " AND (destination_hash LIKE ? OR identity_hash LIKE ?)"
|
||||
sql += " AND (a.destination_hash LIKE ? OR a.identity_hash LIKE ?)"
|
||||
params.extend([like_term, like_term])
|
||||
if blocked_identity_hashes:
|
||||
placeholders = ", ".join(["?"] * len(blocked_identity_hashes))
|
||||
sql += f" AND identity_hash NOT IN ({placeholders})"
|
||||
sql += f" AND a.identity_hash NOT IN ({placeholders})"
|
||||
params.extend(blocked_identity_hashes)
|
||||
|
||||
sql += " ORDER BY updated_at DESC"
|
||||
sql += " ORDER BY a.updated_at DESC"
|
||||
|
||||
if limit is not None:
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
|
||||
@@ -9,8 +9,7 @@ class AsyncUtils:
|
||||
|
||||
@staticmethod
|
||||
def apply_asyncio_313_patch():
|
||||
"""
|
||||
Apply a patch for asyncio on Python 3.13 to avoid a bug in sendfile with SSL.
|
||||
"""Apply a patch for asyncio on Python 3.13 to avoid a bug in sendfile with SSL.
|
||||
See: https://github.com/python/cpython/issues/124448
|
||||
And: https://github.com/aio-libs/aiohttp/issues/8863
|
||||
"""
|
||||
@@ -23,14 +22,25 @@ class AsyncUtils:
|
||||
original_sendfile = asyncio.base_events.BaseEventLoop.sendfile
|
||||
|
||||
async def patched_sendfile(
|
||||
self, transport, file, offset=0, count=None, *, fallback=True
|
||||
self,
|
||||
transport,
|
||||
file,
|
||||
offset=0,
|
||||
count=None,
|
||||
*,
|
||||
fallback=True,
|
||||
):
|
||||
if transport.get_extra_info("sslcontext"):
|
||||
raise NotImplementedError(
|
||||
"sendfile is broken on SSL transports in Python 3.13"
|
||||
"sendfile is broken on SSL transports in Python 3.13",
|
||||
)
|
||||
return await original_sendfile(
|
||||
self, transport, file, offset, count, fallback=fallback
|
||||
self,
|
||||
transport,
|
||||
file,
|
||||
offset,
|
||||
count,
|
||||
fallback=fallback,
|
||||
)
|
||||
|
||||
asyncio.base_events.BaseEventLoop.sendfile = patched_sendfile
|
||||
|
||||
279
meshchatx/src/backend/bot_handler.py
Normal file
279
meshchatx/src/backend/bot_handler.py
Normal file
@@ -0,0 +1,279 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import RNS
|
||||
|
||||
logger = logging.getLogger("meshchatx.bots")
|
||||
|
||||
|
||||
class BotHandler:
|
||||
def __init__(self, identity_path, config_manager=None):
|
||||
self.identity_path = os.path.abspath(identity_path)
|
||||
self.config_manager = config_manager
|
||||
self.bots_dir = os.path.join(self.identity_path, "bots")
|
||||
os.makedirs(self.bots_dir, exist_ok=True)
|
||||
self.running_bots = {}
|
||||
self.state_file = os.path.join(self.bots_dir, "bots_state.json")
|
||||
self.bots_state: list[dict] = []
|
||||
self._load_state()
|
||||
self.runner_path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"bot_process.py",
|
||||
)
|
||||
|
||||
def _load_state(self):
|
||||
try:
|
||||
with open(self.state_file, encoding="utf-8") as f:
|
||||
self.bots_state = json.load(f)
|
||||
# Ensure all storage paths are absolute
|
||||
for entry in self.bots_state:
|
||||
if "storage_dir" in entry:
|
||||
entry["storage_dir"] = os.path.abspath(entry["storage_dir"])
|
||||
except FileNotFoundError:
|
||||
self.bots_state = []
|
||||
except Exception:
|
||||
self.bots_state = []
|
||||
|
||||
def _save_state(self):
|
||||
try:
|
||||
with open(self.state_file, "w", encoding="utf-8") as f:
|
||||
json.dump(self.bots_state, f, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def get_available_templates(self):
|
||||
return [
|
||||
{
|
||||
"id": "echo",
|
||||
"name": "Echo Bot",
|
||||
"description": "Repeats any message it receives.",
|
||||
},
|
||||
{
|
||||
"id": "note",
|
||||
"name": "Note Bot",
|
||||
"description": "Store and retrieve notes using JSON storage.",
|
||||
},
|
||||
{
|
||||
"id": "reminder",
|
||||
"name": "Reminder Bot",
|
||||
"description": "Set and receive reminders using SQLite storage.",
|
||||
},
|
||||
]
|
||||
|
||||
def restore_enabled_bots(self):
|
||||
for entry in list(self.bots_state):
|
||||
if entry.get("enabled"):
|
||||
try:
|
||||
self.start_bot(
|
||||
template_id=entry["template_id"],
|
||||
name=entry["name"],
|
||||
bot_id=entry["id"],
|
||||
storage_dir=entry["storage_dir"],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to restore bot %s: %s", entry.get("id"), exc)
|
||||
|
||||
def get_status(self):
|
||||
bots = []
|
||||
for bot_id, bot_info in self.running_bots.items():
|
||||
instance = bot_info["instance"]
|
||||
bots.append(
|
||||
{
|
||||
"id": bot_id,
|
||||
"template": bot_info["template"],
|
||||
"name": instance.bot.config.name
|
||||
if instance and instance.bot
|
||||
else "Unknown",
|
||||
"address": RNS.prettyhexrep(instance.bot.local.hash)
|
||||
if instance and instance.bot and instance.bot.local
|
||||
else "Unknown",
|
||||
"full_address": RNS.hexrep(instance.bot.local.hash, delimit=False)
|
||||
if instance and instance.bot and instance.bot.local
|
||||
else None,
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
"has_lxmfy": True,
|
||||
"detection_error": None,
|
||||
"running_bots": bots,
|
||||
"bots": self.bots_state,
|
||||
}
|
||||
|
||||
def start_bot(self, template_id, name=None, bot_id=None, storage_dir=None):
|
||||
# Reuse existing entry or create new
|
||||
entry = None
|
||||
if bot_id:
|
||||
for e in self.bots_state:
|
||||
if e.get("id") == bot_id:
|
||||
entry = e
|
||||
break
|
||||
if entry is None:
|
||||
bot_id = bot_id or uuid.uuid4().hex
|
||||
bot_storage_dir = storage_dir or os.path.join(self.bots_dir, bot_id)
|
||||
bot_storage_dir = os.path.abspath(bot_storage_dir)
|
||||
entry = {
|
||||
"id": bot_id,
|
||||
"template_id": template_id,
|
||||
"name": name or f"{template_id.title()} Bot",
|
||||
"storage_dir": bot_storage_dir,
|
||||
"enabled": True,
|
||||
"pid": None,
|
||||
}
|
||||
self.bots_state.append(entry)
|
||||
else:
|
||||
bot_storage_dir = entry["storage_dir"]
|
||||
entry["template_id"] = template_id
|
||||
entry["name"] = name or entry.get("name") or f"{template_id.title()} Bot"
|
||||
entry["enabled"] = True
|
||||
|
||||
os.makedirs(bot_storage_dir, exist_ok=True)
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
self.runner_path,
|
||||
"--template",
|
||||
template_id,
|
||||
"--name",
|
||||
entry["name"],
|
||||
"--storage",
|
||||
bot_storage_dir,
|
||||
]
|
||||
|
||||
proc = subprocess.Popen(cmd, cwd=bot_storage_dir) # noqa: S603
|
||||
entry["pid"] = proc.pid
|
||||
self._save_state()
|
||||
|
||||
self.running_bots[bot_id] = {
|
||||
"instance": None,
|
||||
"thread": None,
|
||||
"stop_event": None,
|
||||
"template": template_id,
|
||||
"pid": proc.pid,
|
||||
}
|
||||
logger.info(f"Started bot {bot_id} (template: {template_id}) pid={proc.pid}")
|
||||
return bot_id
|
||||
|
||||
def stop_bot(self, bot_id):
|
||||
entry = None
|
||||
for e in self.bots_state:
|
||||
if e.get("id") == bot_id:
|
||||
entry = e
|
||||
break
|
||||
if entry is None:
|
||||
return False
|
||||
|
||||
pid = entry.get("pid")
|
||||
if pid:
|
||||
try:
|
||||
if sys.platform.startswith("win"):
|
||||
subprocess.run(
|
||||
["taskkill", "/PID", str(pid), "/T", "/F"],
|
||||
check=False,
|
||||
timeout=5,
|
||||
)
|
||||
else:
|
||||
os.kill(pid, 15)
|
||||
# brief wait
|
||||
time.sleep(0.5)
|
||||
# optional force kill if still alive
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
os.kill(pid, 9)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to terminate bot %s pid %s: %s",
|
||||
bot_id,
|
||||
pid,
|
||||
exc,
|
||||
)
|
||||
|
||||
entry["pid"] = None
|
||||
entry["enabled"] = False
|
||||
self._save_state()
|
||||
if bot_id in self.running_bots:
|
||||
del self.running_bots[bot_id]
|
||||
logger.info("Stopped bot %s", bot_id)
|
||||
return True
|
||||
|
||||
def restart_bot(self, bot_id):
|
||||
entry = None
|
||||
for e in self.bots_state:
|
||||
if e.get("id") == bot_id:
|
||||
entry = e
|
||||
break
|
||||
if entry is None:
|
||||
raise ValueError(f"Unknown bot: {bot_id}")
|
||||
self.stop_bot(bot_id)
|
||||
return self.start_bot(
|
||||
template_id=entry["template_id"],
|
||||
name=entry["name"],
|
||||
bot_id=bot_id,
|
||||
storage_dir=entry["storage_dir"],
|
||||
)
|
||||
|
||||
def delete_bot(self, bot_id):
|
||||
# Stop it first
|
||||
self.stop_bot(bot_id)
|
||||
|
||||
# Remove from state
|
||||
entry = None
|
||||
for i, e in enumerate(self.bots_state):
|
||||
if e.get("id") == bot_id:
|
||||
entry = e
|
||||
del self.bots_state[i]
|
||||
break
|
||||
|
||||
if entry:
|
||||
# Delete storage dir
|
||||
storage_dir = entry.get("storage_dir")
|
||||
if storage_dir and os.path.exists(storage_dir):
|
||||
try:
|
||||
shutil.rmtree(storage_dir)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete storage dir for bot %s: %s", bot_id, exc
|
||||
)
|
||||
|
||||
self._save_state()
|
||||
logger.info("Deleted bot %s", bot_id)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_bot_identity_path(self, bot_id):
|
||||
entry = None
|
||||
for e in self.bots_state:
|
||||
if e.get("id") == bot_id:
|
||||
entry = e
|
||||
break
|
||||
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
storage_dir = entry.get("storage_dir")
|
||||
if not storage_dir:
|
||||
return None
|
||||
|
||||
# LXMFy stores identity in the 'config' subdirectory by default
|
||||
id_path = os.path.join(storage_dir, "config", "identity")
|
||||
if os.path.exists(id_path):
|
||||
return id_path
|
||||
|
||||
# Fallback to direct identity file if it was moved or configured differently
|
||||
id_path_alt = os.path.join(storage_dir, "identity")
|
||||
if os.path.exists(id_path_alt):
|
||||
return id_path_alt
|
||||
|
||||
return None
|
||||
|
||||
def stop_all(self):
|
||||
for bot_id in list(self.running_bots.keys()):
|
||||
self.stop_bot(bot_id)
|
||||
45
meshchatx/src/backend/bot_process.py
Normal file
45
meshchatx/src/backend/bot_process.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import argparse
|
||||
import os
|
||||
|
||||
from meshchatx.src.backend.bot_templates import (
|
||||
EchoBotTemplate,
|
||||
NoteBotTemplate,
|
||||
ReminderBotTemplate,
|
||||
)
|
||||
|
||||
TEMPLATE_MAP = {
|
||||
"echo": EchoBotTemplate,
|
||||
"note": NoteBotTemplate,
|
||||
"reminder": ReminderBotTemplate,
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--template", required=True, choices=TEMPLATE_MAP.keys())
|
||||
parser.add_argument("--name", required=True)
|
||||
parser.add_argument("--storage", required=True)
|
||||
args = parser.parse_args()
|
||||
|
||||
os.makedirs(args.storage, exist_ok=True)
|
||||
os.chdir(args.storage)
|
||||
|
||||
BotCls = TEMPLATE_MAP[args.template]
|
||||
# LXMFy hardcodes its config directory to os.path.join(os.getcwd(), 'config').
|
||||
# By chdir'ing into args.storage, we ensure 'config' and data are kept within that folder.
|
||||
bot_instance = BotCls(name=args.name, storage_path=args.storage, test_mode=False)
|
||||
|
||||
# Optional immediate announce for reachability
|
||||
try:
|
||||
if hasattr(bot_instance.bot, "announce_enabled"):
|
||||
bot_instance.bot.announce_enabled = True
|
||||
if hasattr(bot_instance.bot, "_announce"):
|
||||
bot_instance.bot._announce()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
bot_instance.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
265
meshchatx/src/backend/bot_templates.py
Normal file
265
meshchatx/src/backend/bot_templates.py
Normal file
@@ -0,0 +1,265 @@
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from lxmfy import IconAppearance, LXMFBot, pack_icon_appearance_field
|
||||
|
||||
HAS_LXMFY = True
|
||||
|
||||
|
||||
class StoppableBot:
|
||||
def __init__(self):
|
||||
self._stop_event = None
|
||||
|
||||
def set_stop_event(self, stop_event):
|
||||
self._stop_event = stop_event
|
||||
|
||||
def should_stop(self):
|
||||
return self._stop_event and self._stop_event.is_set()
|
||||
|
||||
|
||||
class EchoBotTemplate(StoppableBot):
|
||||
def __init__(self, name="Echo Bot", storage_path=None, test_mode=False):
|
||||
super().__init__()
|
||||
|
||||
self.bot = LXMFBot(
|
||||
name=name,
|
||||
announce=600,
|
||||
command_prefix="",
|
||||
first_message_enabled=True,
|
||||
test_mode=test_mode,
|
||||
storage_path=storage_path,
|
||||
)
|
||||
self.setup_commands()
|
||||
self.setup_message_handlers()
|
||||
|
||||
icon_data = IconAppearance(
|
||||
icon_name="forum",
|
||||
fg_color=b"\xad\xd8\xe6",
|
||||
bg_color=b"\x3b\x59\x98",
|
||||
)
|
||||
self.icon_lxmf_field = pack_icon_appearance_field(icon_data)
|
||||
|
||||
def setup_message_handlers(self):
|
||||
@self.bot.on_message()
|
||||
def echo_non_command_messages(sender, message):
|
||||
if self.should_stop():
|
||||
return True
|
||||
content = message.content.decode("utf-8").strip()
|
||||
if not content:
|
||||
return False
|
||||
|
||||
command_name = content.split()[0]
|
||||
if command_name in self.bot.commands:
|
||||
return False
|
||||
|
||||
self.bot.send(
|
||||
sender,
|
||||
content,
|
||||
lxmf_fields=self.icon_lxmf_field,
|
||||
)
|
||||
return False
|
||||
|
||||
def setup_commands(self):
|
||||
@self.bot.command(name="echo", description="Echo back your message")
|
||||
def echo(ctx):
|
||||
if self.should_stop():
|
||||
return
|
||||
if ctx.args:
|
||||
ctx.reply(" ".join(ctx.args), lxmf_fields=self.icon_lxmf_field)
|
||||
else:
|
||||
ctx.reply("Usage: echo <message>", lxmf_fields=self.icon_lxmf_field)
|
||||
|
||||
@self.bot.on_first_message()
|
||||
def welcome(sender, message):
|
||||
if self.should_stop():
|
||||
return True
|
||||
content = message.content.decode("utf-8").strip()
|
||||
self.bot.send(
|
||||
sender,
|
||||
f"Hi! I'm an echo bot, You said: {content}\n\n"
|
||||
"Try: echo <message> to make me repeat things!",
|
||||
lxmf_fields=self.icon_lxmf_field,
|
||||
)
|
||||
return True
|
||||
|
||||
def run(self):
|
||||
self.bot.scheduler.start()
|
||||
try:
|
||||
while not self.should_stop():
|
||||
for _ in range(self.bot.queue.qsize()):
|
||||
lxm = self.bot.queue.get()
|
||||
if self.bot.router:
|
||||
self.bot.router.handle_outbound(lxm)
|
||||
time.sleep(1)
|
||||
finally:
|
||||
self.bot.cleanup()
|
||||
|
||||
|
||||
class NoteBotTemplate(StoppableBot):
|
||||
def __init__(self, name="Note Bot", storage_path=None, test_mode=False):
|
||||
super().__init__()
|
||||
|
||||
self.bot = LXMFBot(
|
||||
name=name,
|
||||
announce=600,
|
||||
command_prefix="/",
|
||||
storage_type="json",
|
||||
storage_path=storage_path or "data/notes",
|
||||
test_mode=test_mode,
|
||||
)
|
||||
self.setup_commands()
|
||||
|
||||
def setup_commands(self):
|
||||
@self.bot.command(name="note", description="Save a note")
|
||||
def save_note(ctx):
|
||||
if self.should_stop():
|
||||
return
|
||||
if not ctx.args:
|
||||
ctx.reply("Usage: /note <your note>")
|
||||
return
|
||||
|
||||
note = {
|
||||
"text": " ".join(ctx.args),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"tags": [w[1:] for w in ctx.args if w.startswith("#")],
|
||||
}
|
||||
|
||||
notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
|
||||
notes.append(note)
|
||||
self.bot.storage.set(f"notes:{ctx.sender}", notes)
|
||||
ctx.reply("Note saved!")
|
||||
|
||||
@self.bot.command(name="notes", description="List your notes")
|
||||
def list_notes(ctx):
|
||||
if self.should_stop():
|
||||
return
|
||||
notes = self.bot.storage.get(f"notes:{ctx.sender}", [])
|
||||
if not notes:
|
||||
ctx.reply("You haven't saved any notes yet!")
|
||||
return
|
||||
|
||||
if not ctx.args:
|
||||
response = "Your Notes:\n"
|
||||
for i, note in enumerate(notes[-10:], 1):
|
||||
tags = (
|
||||
" ".join(f"#{tag}" for tag in note["tags"])
|
||||
if note["tags"]
|
||||
else ""
|
||||
)
|
||||
response += f"{i}. {note['text']} {tags}\n"
|
||||
if len(notes) > 10:
|
||||
response += f"\nShowing last 10 of {len(notes)} notes. Use /notes all to see all."
|
||||
ctx.reply(response)
|
||||
elif ctx.args[0] == "all":
|
||||
response = "All Your Notes:\n"
|
||||
for i, note in enumerate(notes, 1):
|
||||
tags = (
|
||||
" ".join(f"#{tag}" for tag in note["tags"])
|
||||
if note["tags"]
|
||||
else ""
|
||||
)
|
||||
response += f"{i}. {note['text']} {tags}\n"
|
||||
ctx.reply(response)
|
||||
|
||||
def run(self):
|
||||
self.bot.scheduler.start()
|
||||
try:
|
||||
while not self.should_stop():
|
||||
for _ in range(self.bot.queue.qsize()):
|
||||
lxm = self.bot.queue.get()
|
||||
if self.bot.router:
|
||||
self.bot.router.handle_outbound(lxm)
|
||||
time.sleep(1)
|
||||
finally:
|
||||
self.bot.cleanup()
|
||||
|
||||
|
||||
class ReminderBotTemplate(StoppableBot):
|
||||
def __init__(self, name="Reminder Bot", storage_path=None, test_mode=False):
|
||||
super().__init__()
|
||||
|
||||
self.bot = LXMFBot(
|
||||
name=name,
|
||||
announce=600,
|
||||
command_prefix="/",
|
||||
storage_type="sqlite",
|
||||
storage_path=storage_path or "data/reminders.db",
|
||||
test_mode=test_mode,
|
||||
)
|
||||
self.setup_commands()
|
||||
self.bot.scheduler.add_task(
|
||||
"check_reminders",
|
||||
self._check_reminders,
|
||||
"*/1 * * * *",
|
||||
)
|
||||
|
||||
def setup_commands(self):
|
||||
@self.bot.command(name="remind", description="Set a reminder")
|
||||
def remind(ctx):
|
||||
if self.should_stop():
|
||||
return
|
||||
if not ctx.args or len(ctx.args) < 2:
|
||||
ctx.reply(
|
||||
"Usage: /remind <time> <message>\nExample: /remind 1h30m Buy groceries",
|
||||
)
|
||||
return
|
||||
|
||||
time_str = ctx.args[0].lower()
|
||||
message = " ".join(ctx.args[1:])
|
||||
|
||||
total_minutes = 0
|
||||
time_parts = re.findall(r"(\d+)([dhm])", time_str)
|
||||
|
||||
for value, unit in time_parts:
|
||||
if unit == "d":
|
||||
total_minutes += int(value) * 24 * 60
|
||||
elif unit == "h":
|
||||
total_minutes += int(value) * 60
|
||||
elif unit == "m":
|
||||
total_minutes += int(value)
|
||||
|
||||
if total_minutes == 0:
|
||||
ctx.reply("Invalid time format. Use combinations of d, h, m")
|
||||
return
|
||||
|
||||
remind_time = datetime.now() + timedelta(minutes=total_minutes)
|
||||
reminder = {
|
||||
"user": ctx.sender,
|
||||
"message": message,
|
||||
"time": remind_time.timestamp(),
|
||||
"created": time.time(),
|
||||
}
|
||||
|
||||
reminders = self.bot.storage.get("reminders", [])
|
||||
reminders.append(reminder)
|
||||
self.bot.storage.set("reminders", reminders)
|
||||
ctx.reply(
|
||||
f"I'll remind you about '{message}' at {remind_time.strftime('%Y-%m-%d %H:%M:%S')}",
|
||||
)
|
||||
|
||||
def _check_reminders(self):
|
||||
if self.should_stop():
|
||||
return
|
||||
reminders = self.bot.storage.get("reminders", [])
|
||||
current_time = time.time()
|
||||
due_reminders = [r for r in reminders if r["time"] <= current_time]
|
||||
remaining = [r for r in reminders if r["time"] > current_time]
|
||||
|
||||
for reminder in due_reminders:
|
||||
self.bot.send(reminder["user"], f"Reminder: {reminder['message']}")
|
||||
|
||||
if due_reminders:
|
||||
self.bot.storage.set("reminders", remaining)
|
||||
|
||||
def run(self):
|
||||
self.bot.scheduler.start()
|
||||
try:
|
||||
while not self.should_stop():
|
||||
for _ in range(self.bot.queue.qsize()):
|
||||
lxm = self.bot.queue.get()
|
||||
if self.bot.router:
|
||||
self.bot.router.handle_outbound(lxm)
|
||||
time.sleep(1)
|
||||
finally:
|
||||
self.bot.cleanup()
|
||||
@@ -1,6 +1,6 @@
|
||||
import asyncio
|
||||
import time
|
||||
from typing import List, Dict, Any
|
||||
from typing import Any
|
||||
|
||||
|
||||
class CommunityInterfacesManager:
|
||||
@@ -67,7 +67,8 @@ class CommunityInterfacesManager:
|
||||
# but that requires Reticulum to be running with a configured interface to that target.
|
||||
# For "suggested" interfaces, we just check if they are reachable.
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(host, port), timeout=3.0
|
||||
asyncio.open_connection(host, port),
|
||||
timeout=3.0,
|
||||
)
|
||||
writer.close()
|
||||
await writer.wait_closed()
|
||||
@@ -90,7 +91,7 @@ class CommunityInterfacesManager:
|
||||
}
|
||||
self.last_check = time.time()
|
||||
|
||||
async def get_interfaces(self) -> List[Dict[str, Any]]:
|
||||
async def get_interfaces(self) -> list[dict[str, Any]]:
|
||||
# If cache is old or empty, update it
|
||||
if time.time() - self.last_check > self.check_interval or not self.status_cache:
|
||||
# We don't want to block the request, so we could do this in background
|
||||
@@ -100,14 +101,15 @@ class CommunityInterfacesManager:
|
||||
results = []
|
||||
for iface in self.interfaces:
|
||||
status = self.status_cache.get(
|
||||
iface["name"], {"online": False, "last_check": 0}
|
||||
iface["name"],
|
||||
{"online": False, "last_check": 0},
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
**iface,
|
||||
"online": status["online"],
|
||||
"last_check": status["last_check"],
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Sort so online ones are first
|
||||
|
||||
@@ -60,6 +60,8 @@ class ConfigManager:
|
||||
"lxmf_preferred_propagation_node_last_synced_at",
|
||||
None,
|
||||
)
|
||||
self.lxmf_address_hash = self.StringConfig(self, "lxmf_address_hash", None)
|
||||
self.lxst_address_hash = self.StringConfig(self, "lxst_address_hash", None)
|
||||
self.lxmf_local_propagation_node_enabled = self.BoolConfig(
|
||||
self,
|
||||
"lxmf_local_propagation_node_enabled",
|
||||
@@ -119,7 +121,9 @@ class ConfigManager:
|
||||
False,
|
||||
)
|
||||
self.gitea_base_url = self.StringConfig(
|
||||
self, "gitea_base_url", "https://git.quad4.io"
|
||||
self,
|
||||
"gitea_base_url",
|
||||
"https://git.quad4.io",
|
||||
)
|
||||
self.docs_download_urls = self.StringConfig(
|
||||
self,
|
||||
@@ -159,7 +163,9 @@ class ConfigManager:
|
||||
self.voicemail_tts_speed = self.IntConfig(self, "voicemail_tts_speed", 130)
|
||||
self.voicemail_tts_pitch = self.IntConfig(self, "voicemail_tts_pitch", 45)
|
||||
self.voicemail_tts_voice = self.StringConfig(
|
||||
self, "voicemail_tts_voice", "en-us+f3"
|
||||
self,
|
||||
"voicemail_tts_voice",
|
||||
"en-us+f3",
|
||||
)
|
||||
self.voicemail_tts_word_gap = self.IntConfig(self, "voicemail_tts_word_gap", 5)
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@ class Database:
|
||||
self._checkpoint_wal()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Warning: WAL checkpoint during vacuum failed (non-critical): {e}"
|
||||
f"Warning: WAL checkpoint during vacuum failed (non-critical): {e}",
|
||||
)
|
||||
|
||||
self.execute_sql("VACUUM")
|
||||
@@ -226,7 +226,7 @@ class Database:
|
||||
os.makedirs(snapshot_dir, exist_ok=True)
|
||||
# Ensure name is safe for filesystem
|
||||
safe_name = "".join(
|
||||
[c for c in name if c.isalnum() or c in (" ", ".", "-", "_")]
|
||||
[c for c in name if c.isalnum() or c in (" ", ".", "-", "_")],
|
||||
).strip()
|
||||
if not safe_name:
|
||||
safe_name = "unnamed_snapshot"
|
||||
@@ -251,9 +251,10 @@ class Database:
|
||||
"path": full_path,
|
||||
"size": stats.st_size,
|
||||
"created_at": datetime.fromtimestamp(
|
||||
stats.st_mtime, UTC
|
||||
stats.st_mtime,
|
||||
UTC,
|
||||
).isoformat(),
|
||||
}
|
||||
},
|
||||
)
|
||||
return sorted(snapshots, key=lambda x: x["created_at"], reverse=True)
|
||||
|
||||
|
||||
@@ -6,19 +6,34 @@ class ContactsDAO:
|
||||
self.provider = provider
|
||||
|
||||
def add_contact(
|
||||
self, name, remote_identity_hash, preferred_ringtone_id=None, custom_image=None
|
||||
self,
|
||||
name,
|
||||
remote_identity_hash,
|
||||
lxmf_address=None,
|
||||
lxst_address=None,
|
||||
preferred_ringtone_id=None,
|
||||
custom_image=None,
|
||||
):
|
||||
self.provider.execute(
|
||||
"""
|
||||
INSERT INTO contacts (name, remote_identity_hash, preferred_ringtone_id, custom_image)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO contacts (name, remote_identity_hash, lxmf_address, lxst_address, preferred_ringtone_id, custom_image)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(remote_identity_hash) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
lxmf_address = COALESCE(EXCLUDED.lxmf_address, contacts.lxmf_address),
|
||||
lxst_address = COALESCE(EXCLUDED.lxst_address, contacts.lxst_address),
|
||||
preferred_ringtone_id = EXCLUDED.preferred_ringtone_id,
|
||||
custom_image = EXCLUDED.custom_image,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
""",
|
||||
(name, remote_identity_hash, preferred_ringtone_id, custom_image),
|
||||
(
|
||||
name,
|
||||
remote_identity_hash,
|
||||
lxmf_address,
|
||||
lxst_address,
|
||||
preferred_ringtone_id,
|
||||
custom_image,
|
||||
),
|
||||
)
|
||||
|
||||
def get_contacts(self, search=None, limit=100, offset=0):
|
||||
@@ -26,10 +41,17 @@ class ContactsDAO:
|
||||
return self.provider.fetchall(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE name LIKE ? OR remote_identity_hash LIKE ?
|
||||
WHERE name LIKE ? OR remote_identity_hash LIKE ? OR lxmf_address LIKE ? OR lxst_address LIKE ?
|
||||
ORDER BY name ASC LIMIT ? OFFSET ?
|
||||
""",
|
||||
(f"%{search}%", f"%{search}%", limit, offset),
|
||||
(
|
||||
f"%{search}%",
|
||||
f"%{search}%",
|
||||
f"%{search}%",
|
||||
f"%{search}%",
|
||||
limit,
|
||||
offset,
|
||||
),
|
||||
)
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM contacts ORDER BY name ASC LIMIT ? OFFSET ?",
|
||||
@@ -47,6 +69,8 @@ class ContactsDAO:
|
||||
contact_id,
|
||||
name=None,
|
||||
remote_identity_hash=None,
|
||||
lxmf_address=None,
|
||||
lxst_address=None,
|
||||
preferred_ringtone_id=None,
|
||||
custom_image=None,
|
||||
clear_image=False,
|
||||
@@ -60,6 +84,12 @@ class ContactsDAO:
|
||||
if remote_identity_hash is not None:
|
||||
updates.append("remote_identity_hash = ?")
|
||||
params.append(remote_identity_hash)
|
||||
if lxmf_address is not None:
|
||||
updates.append("lxmf_address = ?")
|
||||
params.append(lxmf_address)
|
||||
if lxst_address is not None:
|
||||
updates.append("lxst_address = ?")
|
||||
params.append(lxst_address)
|
||||
if preferred_ringtone_id is not None:
|
||||
updates.append("preferred_ringtone_id = ?")
|
||||
params.append(preferred_ringtone_id)
|
||||
@@ -82,6 +112,6 @@ class ContactsDAO:
|
||||
|
||||
def get_contact_by_identity_hash(self, remote_identity_hash):
|
||||
return self.provider.fetchone(
|
||||
"SELECT * FROM contacts WHERE remote_identity_hash = ?",
|
||||
(remote_identity_hash,),
|
||||
"SELECT * FROM contacts WHERE remote_identity_hash = ? OR lxmf_address = ? OR lxst_address = ?",
|
||||
(remote_identity_hash, remote_identity_hash, remote_identity_hash),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from .provider import DatabaseProvider
|
||||
|
||||
|
||||
@@ -24,7 +25,13 @@ class DebugLogsDAO:
|
||||
)
|
||||
|
||||
def get_logs(
|
||||
self, limit=100, offset=0, search=None, level=None, module=None, is_anomaly=None
|
||||
self,
|
||||
limit=100,
|
||||
offset=0,
|
||||
search=None,
|
||||
level=None,
|
||||
module=None,
|
||||
is_anomaly=None,
|
||||
):
|
||||
sql = "SELECT * FROM debug_logs WHERE 1=1"
|
||||
params = []
|
||||
@@ -83,7 +90,8 @@ class DebugLogsDAO:
|
||||
if row:
|
||||
cutoff_ts = row["timestamp"]
|
||||
self.provider.execute(
|
||||
"DELETE FROM debug_logs WHERE timestamp < ?", (cutoff_ts,)
|
||||
"DELETE FROM debug_logs WHERE timestamp < ?",
|
||||
(cutoff_ts,),
|
||||
)
|
||||
|
||||
def get_anomalies(self, limit=50):
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from .provider import DatabaseProvider
|
||||
|
||||
|
||||
|
||||
@@ -69,13 +69,12 @@ class DatabaseProvider:
|
||||
self.connection.commit()
|
||||
elif commit is False:
|
||||
pass
|
||||
else:
|
||||
# Default behavior: if we're in a manual transaction, don't commit automatically
|
||||
if not self.connection.in_transaction:
|
||||
# In autocommit mode, non-DML statements don't start transactions.
|
||||
# DML statements might if they are part of a BEGIN block.
|
||||
# Actually, in isolation_level=None, NOTHING starts a transaction unless we say BEGIN.
|
||||
pass
|
||||
# Default behavior: if we're in a manual transaction, don't commit automatically
|
||||
elif not self.connection.in_transaction:
|
||||
# In autocommit mode, non-DML statements don't start transactions.
|
||||
# DML statements might if they are part of a BEGIN block.
|
||||
# Actually, in isolation_level=None, NOTHING starts a transaction unless we say BEGIN.
|
||||
pass
|
||||
return cursor
|
||||
|
||||
def begin(self):
|
||||
|
||||
@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class DatabaseSchema:
|
||||
LATEST_VERSION = 34
|
||||
LATEST_VERSION = 35
|
||||
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
@@ -63,21 +63,20 @@ class DatabaseSchema:
|
||||
|
||||
# Use the connection directly to avoid any middle-ware issues
|
||||
res = self._safe_execute(
|
||||
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {stmt_type}"
|
||||
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {stmt_type}",
|
||||
)
|
||||
return res is not None
|
||||
except Exception as e:
|
||||
# Log but don't crash, we might be able to continue
|
||||
print(
|
||||
f"Unexpected error adding column {column_name} to {table_name}: {e}"
|
||||
f"Unexpected error adding column {column_name} to {table_name}: {e}",
|
||||
)
|
||||
return False
|
||||
return True
|
||||
return True
|
||||
|
||||
def _sync_table_columns(self, table_name, create_sql):
|
||||
"""
|
||||
Parses a CREATE TABLE statement and ensures all columns exist in the actual table.
|
||||
"""Parses a CREATE TABLE statement and ensures all columns exist in the actual table.
|
||||
This is a robust way to handle legacy tables that are missing columns.
|
||||
"""
|
||||
# Find the first '(' and the last ')'
|
||||
@@ -111,7 +110,7 @@ class DatabaseSchema:
|
||||
definition = definition.strip()
|
||||
# Skip table-level constraints
|
||||
if not definition or definition.upper().startswith(
|
||||
("PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK")
|
||||
("PRIMARY KEY", "FOREIGN KEY", "UNIQUE", "CHECK"),
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -365,6 +364,8 @@ class DatabaseSchema:
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
remote_identity_hash TEXT UNIQUE,
|
||||
lxmf_address TEXT,
|
||||
lxst_address TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
@@ -923,6 +924,15 @@ class DatabaseSchema:
|
||||
"ALTER TABLE crawl_tasks ADD COLUMN updated_at DATETIME DEFAULT CURRENT_TIMESTAMP",
|
||||
)
|
||||
|
||||
if current_version < 35:
|
||||
# Add lxmf_address and lxst_address to contacts
|
||||
self._safe_execute(
|
||||
"ALTER TABLE contacts ADD COLUMN lxmf_address TEXT DEFAULT NULL",
|
||||
)
|
||||
self._safe_execute(
|
||||
"ALTER TABLE contacts ADD COLUMN lxst_address TEXT DEFAULT NULL",
|
||||
)
|
||||
|
||||
# Update version in config
|
||||
self._safe_execute(
|
||||
"""
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import html
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
import io
|
||||
import html
|
||||
|
||||
import requests
|
||||
|
||||
from meshchatx.src.backend.markdown_renderer import MarkdownRenderer
|
||||
|
||||
|
||||
@@ -46,12 +47,13 @@ class DocsManager:
|
||||
self._update_current_link()
|
||||
|
||||
except OSError as e:
|
||||
logging.error(f"Failed to create documentation directories: {e}")
|
||||
logging.exception(f"Failed to create documentation directories: {e}")
|
||||
self.last_error = str(e)
|
||||
|
||||
# Initial population of MeshChatX docs
|
||||
if os.path.exists(self.meshchatx_docs_dir) and os.access(
|
||||
self.meshchatx_docs_dir, os.W_OK
|
||||
self.meshchatx_docs_dir,
|
||||
os.W_OK,
|
||||
):
|
||||
self.populate_meshchatx_docs()
|
||||
|
||||
@@ -115,7 +117,7 @@ class DocsManager:
|
||||
version_file = os.path.join(self.docs_dir, ".version")
|
||||
if os.path.exists(version_file):
|
||||
try:
|
||||
with open(version_file, "r") as f:
|
||||
with open(version_file) as f:
|
||||
return f.read().strip()
|
||||
except OSError:
|
||||
pass
|
||||
@@ -142,7 +144,7 @@ class DocsManager:
|
||||
# Project root is 3 levels up
|
||||
this_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
search_paths.append(
|
||||
os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs"))
|
||||
os.path.abspath(os.path.join(this_dir, "..", "..", "..", "docs")),
|
||||
)
|
||||
|
||||
src_docs = None
|
||||
@@ -163,13 +165,13 @@ class DocsManager:
|
||||
|
||||
# Only copy if source and destination are different
|
||||
if os.path.abspath(src_path) != os.path.abspath(
|
||||
dest_path
|
||||
dest_path,
|
||||
) and os.access(self.meshchatx_docs_dir, os.W_OK):
|
||||
shutil.copy2(src_path, dest_path)
|
||||
|
||||
# Also pre-render to HTML for easy sharing/viewing
|
||||
try:
|
||||
with open(src_path, "r", encoding="utf-8") as f:
|
||||
with open(src_path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
html_content = MarkdownRenderer.render(content)
|
||||
@@ -199,9 +201,9 @@ class DocsManager:
|
||||
) as f:
|
||||
f.write(full_html)
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to render {file} to HTML: {e}")
|
||||
logging.exception(f"Failed to render {file} to HTML: {e}")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to populate MeshChatX docs: {e}")
|
||||
logging.exception(f"Failed to populate MeshChatX docs: {e}")
|
||||
|
||||
def get_status(self):
|
||||
return {
|
||||
@@ -228,15 +230,15 @@ class DocsManager:
|
||||
if not os.path.exists(self.meshchatx_docs_dir):
|
||||
return docs
|
||||
|
||||
for file in os.listdir(self.meshchatx_docs_dir):
|
||||
if file.endswith((".md", ".txt")):
|
||||
docs.append(
|
||||
{
|
||||
"name": file,
|
||||
"path": file,
|
||||
"type": "markdown" if file.endswith(".md") else "text",
|
||||
}
|
||||
)
|
||||
docs.extend(
|
||||
{
|
||||
"name": file,
|
||||
"path": file,
|
||||
"type": "markdown" if file.endswith(".md") else "text",
|
||||
}
|
||||
for file in os.listdir(self.meshchatx_docs_dir)
|
||||
if file.endswith((".md", ".txt"))
|
||||
)
|
||||
return sorted(docs, key=lambda x: x["name"])
|
||||
|
||||
def get_doc_content(self, path):
|
||||
@@ -244,7 +246,7 @@ class DocsManager:
|
||||
if not os.path.exists(full_path):
|
||||
return None
|
||||
|
||||
with open(full_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
with open(full_path, encoding="utf-8", errors="ignore") as f:
|
||||
content = f.read()
|
||||
|
||||
if path.endswith(".md"):
|
||||
@@ -253,12 +255,11 @@ class DocsManager:
|
||||
"html": MarkdownRenderer.render(content),
|
||||
"type": "markdown",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"content": content,
|
||||
"html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
|
||||
"type": "text",
|
||||
}
|
||||
return {
|
||||
"content": content,
|
||||
"html": f"<pre class='whitespace-pre-wrap font-mono'>{html.escape(content)}</pre>",
|
||||
"type": "text",
|
||||
}
|
||||
|
||||
def export_docs(self):
|
||||
"""Creates a zip of all docs and returns the bytes."""
|
||||
@@ -269,7 +270,8 @@ class DocsManager:
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
rel_path = os.path.join(
|
||||
"reticulum-docs", os.path.relpath(file_path, self.docs_dir)
|
||||
"reticulum-docs",
|
||||
os.path.relpath(file_path, self.docs_dir),
|
||||
)
|
||||
zip_file.write(file_path, rel_path)
|
||||
|
||||
@@ -300,7 +302,9 @@ class DocsManager:
|
||||
file_path = os.path.join(self.meshchatx_docs_dir, file)
|
||||
try:
|
||||
with open(
|
||||
file_path, "r", encoding="utf-8", errors="ignore"
|
||||
file_path,
|
||||
encoding="utf-8",
|
||||
errors="ignore",
|
||||
) as f:
|
||||
content = f.read()
|
||||
if query in content.lower():
|
||||
@@ -320,10 +324,10 @@ class DocsManager:
|
||||
"path": f"/meshchatx-docs/{file}",
|
||||
"snippet": snippet,
|
||||
"source": "MeshChatX",
|
||||
}
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"Error searching MeshChatX doc {file}: {e}")
|
||||
logging.exception(f"Error searching MeshChatX doc {file}: {e}")
|
||||
|
||||
# 2. Search Reticulum Docs
|
||||
if self.has_docs():
|
||||
@@ -405,7 +409,7 @@ class DocsManager:
|
||||
"path": f"/reticulum-docs/{rel_path}",
|
||||
"snippet": snippet,
|
||||
"source": "Reticulum",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if len(results) >= 25: # Limit results
|
||||
@@ -469,7 +473,7 @@ class DocsManager:
|
||||
downloaded_size += len(chunk)
|
||||
if total_size > 0:
|
||||
self.download_progress = int(
|
||||
(downloaded_size / total_size) * 90
|
||||
(downloaded_size / total_size) * 90,
|
||||
)
|
||||
|
||||
# Extract
|
||||
|
||||
@@ -1,28 +1,31 @@
|
||||
import os
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
|
||||
import RNS
|
||||
from meshchatx.src.backend.database import Database
|
||||
from meshchatx.src.backend.integrity_manager import IntegrityManager
|
||||
from meshchatx.src.backend.config_manager import ConfigManager
|
||||
from meshchatx.src.backend.message_handler import MessageHandler
|
||||
|
||||
from meshchatx.src.backend.announce_handler import AnnounceHandler
|
||||
from meshchatx.src.backend.announce_manager import AnnounceManager
|
||||
from meshchatx.src.backend.archiver_manager import ArchiverManager
|
||||
from meshchatx.src.backend.map_manager import MapManager
|
||||
from meshchatx.src.backend.bot_handler import BotHandler
|
||||
from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
|
||||
from meshchatx.src.backend.config_manager import ConfigManager
|
||||
from meshchatx.src.backend.database import Database
|
||||
from meshchatx.src.backend.docs_manager import DocsManager
|
||||
from meshchatx.src.backend.forwarding_manager import ForwardingManager
|
||||
from meshchatx.src.backend.integrity_manager import IntegrityManager
|
||||
from meshchatx.src.backend.map_manager import MapManager
|
||||
from meshchatx.src.backend.meshchat_utils import create_lxmf_router
|
||||
from meshchatx.src.backend.message_handler import MessageHandler
|
||||
from meshchatx.src.backend.nomadnet_utils import NomadNetworkManager
|
||||
from meshchatx.src.backend.telephone_manager import TelephoneManager
|
||||
from meshchatx.src.backend.voicemail_manager import VoicemailManager
|
||||
from meshchatx.src.backend.ringtone_manager import RingtoneManager
|
||||
from meshchatx.src.backend.rncp_handler import RNCPHandler
|
||||
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
|
||||
from meshchatx.src.backend.rnpath_handler import RNPathHandler
|
||||
from meshchatx.src.backend.rnprobe_handler import RNProbeHandler
|
||||
from meshchatx.src.backend.rnstatus_handler import RNStatusHandler
|
||||
from meshchatx.src.backend.telephone_manager import TelephoneManager
|
||||
from meshchatx.src.backend.translator_handler import TranslatorHandler
|
||||
from meshchatx.src.backend.forwarding_manager import ForwardingManager
|
||||
from meshchatx.src.backend.meshchat_utils import create_lxmf_router
|
||||
from meshchatx.src.backend.announce_handler import AnnounceHandler
|
||||
from meshchatx.src.backend.community_interfaces import CommunityInterfacesManager
|
||||
from meshchatx.src.backend.voicemail_manager import VoicemailManager
|
||||
|
||||
|
||||
class IdentityContext:
|
||||
@@ -71,12 +74,15 @@ class IdentityContext:
|
||||
self.rnstatus_handler = None
|
||||
self.rnprobe_handler = None
|
||||
self.translator_handler = None
|
||||
self.bot_handler = None
|
||||
self.forwarding_manager = None
|
||||
self.community_interfaces_manager = None
|
||||
self.local_lxmf_destination = None
|
||||
self.announce_handlers = []
|
||||
self.integrity_manager = IntegrityManager(
|
||||
self.storage_path, self.database_path, self.identity_hash
|
||||
self.storage_path,
|
||||
self.database_path,
|
||||
self.identity_hash,
|
||||
)
|
||||
|
||||
self.running = False
|
||||
@@ -102,7 +108,7 @@ class IdentityContext:
|
||||
is_ok, issues = self.integrity_manager.check_integrity()
|
||||
if not is_ok:
|
||||
print(
|
||||
f"INTEGRITY WARNING for {self.identity_hash}: {', '.join(issues)}"
|
||||
f"INTEGRITY WARNING for {self.identity_hash}: {', '.join(issues)}",
|
||||
)
|
||||
if not hasattr(self.app, "integrity_issues"):
|
||||
self.app.integrity_issues = []
|
||||
@@ -120,7 +126,7 @@ class IdentityContext:
|
||||
if not self.app.auto_recover and not getattr(self.app, "emergency", False):
|
||||
raise
|
||||
print(
|
||||
f"Database initialization failed for {self.identity_hash}, attempting recovery: {exc}"
|
||||
f"Database initialization failed for {self.identity_hash}, attempting recovery: {exc}",
|
||||
)
|
||||
if not getattr(self.app, "emergency", False):
|
||||
self.app._run_startup_auto_recovery()
|
||||
@@ -151,8 +157,8 @@ class IdentityContext:
|
||||
self.app.get_public_path(),
|
||||
project_root=os.path.dirname(
|
||||
os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
)
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
),
|
||||
),
|
||||
storage_dir=self.storage_path,
|
||||
)
|
||||
@@ -197,7 +203,7 @@ class IdentityContext:
|
||||
|
||||
# Register delivery callback
|
||||
self.message_router.register_delivery_callback(
|
||||
lambda msg: self.app.on_lxmf_delivery(msg, context=self)
|
||||
lambda msg: self.app.on_lxmf_delivery(msg, context=self),
|
||||
)
|
||||
|
||||
# 5. Initialize Handlers and Managers
|
||||
@@ -224,6 +230,15 @@ class IdentityContext:
|
||||
enabled=translator_enabled,
|
||||
)
|
||||
|
||||
self.bot_handler = BotHandler(
|
||||
identity_path=self.storage_path,
|
||||
config_manager=self.config,
|
||||
)
|
||||
try:
|
||||
self.bot_handler.restore_enabled_bots()
|
||||
except Exception as exc:
|
||||
print(f"Failed to restore bots: {exc}")
|
||||
|
||||
# Initialize managers
|
||||
self.telephone_manager = TelephoneManager(
|
||||
self.identity,
|
||||
@@ -236,17 +251,19 @@ class IdentityContext:
|
||||
)
|
||||
self.telephone_manager.on_initiation_status_callback = (
|
||||
lambda status, target: self.app.on_telephone_initiation_status(
|
||||
status, target, context=self
|
||||
status,
|
||||
target,
|
||||
context=self,
|
||||
)
|
||||
)
|
||||
self.telephone_manager.register_ringing_callback(
|
||||
lambda call: self.app.on_incoming_telephone_call(call, context=self)
|
||||
lambda call: self.app.on_incoming_telephone_call(call, context=self),
|
||||
)
|
||||
self.telephone_manager.register_established_callback(
|
||||
lambda call: self.app.on_telephone_call_established(call, context=self)
|
||||
lambda call: self.app.on_telephone_call_established(call, context=self),
|
||||
)
|
||||
self.telephone_manager.register_ended_callback(
|
||||
lambda call: self.app.on_telephone_call_ended(call, context=self)
|
||||
lambda call: self.app.on_telephone_call_ended(call, context=self),
|
||||
)
|
||||
|
||||
# Only initialize telephone hardware/profile if not in emergency mode
|
||||
@@ -287,7 +304,7 @@ class IdentityContext:
|
||||
):
|
||||
if not self.docs_manager.has_docs():
|
||||
print(
|
||||
f"Triggering initial documentation download for {self.identity_hash}..."
|
||||
f"Triggering initial documentation download for {self.identity_hash}...",
|
||||
)
|
||||
self.docs_manager.update_docs()
|
||||
self.config.initial_docs_download_attempted.set(True)
|
||||
@@ -338,13 +355,23 @@ class IdentityContext:
|
||||
AnnounceHandler(
|
||||
"lxst.telephony",
|
||||
lambda aspect, dh, ai, ad, aph: self.app.on_telephone_announce_received(
|
||||
aspect, dh, ai, ad, aph, context=self
|
||||
aspect,
|
||||
dh,
|
||||
ai,
|
||||
ad,
|
||||
aph,
|
||||
context=self,
|
||||
),
|
||||
),
|
||||
AnnounceHandler(
|
||||
"lxmf.delivery",
|
||||
lambda aspect, dh, ai, ad, aph: self.app.on_lxmf_announce_received(
|
||||
aspect, dh, ai, ad, aph, context=self
|
||||
aspect,
|
||||
dh,
|
||||
ai,
|
||||
ad,
|
||||
aph,
|
||||
context=self,
|
||||
),
|
||||
),
|
||||
AnnounceHandler(
|
||||
@@ -354,7 +381,12 @@ class IdentityContext:
|
||||
ai,
|
||||
ad,
|
||||
aph: self.app.on_lxmf_propagation_announce_received(
|
||||
aspect, dh, ai, ad, aph, context=self
|
||||
aspect,
|
||||
dh,
|
||||
ai,
|
||||
ad,
|
||||
aph,
|
||||
context=self,
|
||||
),
|
||||
),
|
||||
AnnounceHandler(
|
||||
@@ -364,7 +396,12 @@ class IdentityContext:
|
||||
ai,
|
||||
ad,
|
||||
aph: self.app.on_nomadnet_node_announce_received(
|
||||
aspect, dh, ai, ad, aph, context=self
|
||||
aspect,
|
||||
dh,
|
||||
ai,
|
||||
ad,
|
||||
aph,
|
||||
context=self,
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -389,7 +426,7 @@ class IdentityContext:
|
||||
if self.message_router:
|
||||
if hasattr(self.message_router, "delivery_destinations"):
|
||||
for dest_hash in list(
|
||||
self.message_router.delivery_destinations.keys()
|
||||
self.message_router.delivery_destinations.keys(),
|
||||
):
|
||||
dest = self.message_router.delivery_destinations[dest_hash]
|
||||
RNS.Transport.deregister_destination(dest)
|
||||
@@ -399,7 +436,7 @@ class IdentityContext:
|
||||
and self.message_router.propagation_destination
|
||||
):
|
||||
RNS.Transport.deregister_destination(
|
||||
self.message_router.propagation_destination
|
||||
self.message_router.propagation_destination,
|
||||
)
|
||||
|
||||
if self.telephone_manager and self.telephone_manager.telephone:
|
||||
@@ -408,7 +445,7 @@ class IdentityContext:
|
||||
and self.telephone_manager.telephone.destination
|
||||
):
|
||||
RNS.Transport.deregister_destination(
|
||||
self.telephone_manager.telephone.destination
|
||||
self.telephone_manager.telephone.destination,
|
||||
)
|
||||
|
||||
self.app.cleanup_rns_state_for_identity(self.identity.hash)
|
||||
@@ -423,7 +460,7 @@ class IdentityContext:
|
||||
self.message_router.exit_handler()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error while tearing down LXMRouter for {self.identity_hash}: {e}"
|
||||
f"Error while tearing down LXMRouter for {self.identity_hash}: {e}",
|
||||
)
|
||||
|
||||
# 4. Stop telephone and voicemail
|
||||
@@ -432,16 +469,22 @@ class IdentityContext:
|
||||
self.telephone_manager.teardown()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error while tearing down telephone for {self.identity_hash}: {e}"
|
||||
f"Error while tearing down telephone for {self.identity_hash}: {e}",
|
||||
)
|
||||
|
||||
if self.bot_handler:
|
||||
try:
|
||||
self.bot_handler.stop_all()
|
||||
except Exception as e:
|
||||
print(f"Error while stopping bots for {self.identity_hash}: {e}")
|
||||
|
||||
if self.database:
|
||||
try:
|
||||
# 1. Checkpoint WAL and close database cleanly to ensure file is stable for hashing
|
||||
self.database._checkpoint_and_close()
|
||||
except Exception as e:
|
||||
print(
|
||||
f"Error closing database during teardown for {self.identity_hash}: {e}"
|
||||
f"Error closing database during teardown for {self.identity_hash}: {e}",
|
||||
)
|
||||
|
||||
# 2. Save integrity manifest AFTER closing to capture final stable state
|
||||
|
||||
@@ -50,7 +50,7 @@ class IdentityManager:
|
||||
metadata = None
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, "r") as f:
|
||||
with open(metadata_path) as f:
|
||||
metadata = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -62,10 +62,10 @@ class IdentityManager:
|
||||
"display_name": metadata.get("display_name", "Anonymous Peer"),
|
||||
"icon_name": metadata.get("icon_name"),
|
||||
"icon_foreground_colour": metadata.get(
|
||||
"icon_foreground_colour"
|
||||
"icon_foreground_colour",
|
||||
),
|
||||
"icon_background_colour": metadata.get(
|
||||
"icon_background_colour"
|
||||
"icon_background_colour",
|
||||
),
|
||||
"lxmf_address": metadata.get("lxmf_address"),
|
||||
"lxst_address": metadata.get("lxst_address"),
|
||||
@@ -137,14 +137,17 @@ class IdentityManager:
|
||||
|
||||
def create_identity(self, display_name=None):
|
||||
new_identity = RNS.Identity(create_keys=True)
|
||||
identity_hash = new_identity.hash.hex()
|
||||
return self._save_new_identity(new_identity, display_name or "Anonymous Peer")
|
||||
|
||||
def _save_new_identity(self, identity, display_name):
|
||||
identity_hash = identity.hash.hex()
|
||||
|
||||
identity_dir = os.path.join(self.storage_dir, "identities", identity_hash)
|
||||
os.makedirs(identity_dir, exist_ok=True)
|
||||
|
||||
identity_file = os.path.join(identity_dir, "identity")
|
||||
with open(identity_file, "wb") as f:
|
||||
f.write(new_identity.get_private_key())
|
||||
f.write(identity.get_private_key())
|
||||
|
||||
db_path = os.path.join(identity_dir, "database.db")
|
||||
|
||||
@@ -160,7 +163,7 @@ class IdentityManager:
|
||||
|
||||
# Save metadata
|
||||
metadata = {
|
||||
"display_name": display_name or "Anonymous Peer",
|
||||
"display_name": display_name,
|
||||
"icon_name": None,
|
||||
"icon_foreground_colour": None,
|
||||
"icon_background_colour": None,
|
||||
@@ -171,7 +174,7 @@ class IdentityManager:
|
||||
|
||||
return {
|
||||
"hash": identity_hash,
|
||||
"display_name": display_name or "Anonymous Peer",
|
||||
"display_name": display_name,
|
||||
}
|
||||
|
||||
def update_metadata_cache(self, identity_hash: str, metadata: dict):
|
||||
@@ -185,7 +188,7 @@ class IdentityManager:
|
||||
existing_metadata = {}
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, "r") as f:
|
||||
with open(metadata_path) as f:
|
||||
existing_metadata = json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -206,20 +209,20 @@ class IdentityManager:
|
||||
return False
|
||||
|
||||
def restore_identity_from_bytes(self, identity_bytes: bytes) -> dict:
|
||||
target_path = self.identity_file_path or os.path.join(
|
||||
self.storage_dir,
|
||||
"identity",
|
||||
)
|
||||
os.makedirs(os.path.dirname(target_path), exist_ok=True)
|
||||
with open(target_path, "wb") as f:
|
||||
f.write(identity_bytes)
|
||||
return {"path": target_path, "size": os.path.getsize(target_path)}
|
||||
try:
|
||||
# We use RNS.Identity.from_bytes to validate and get the hash
|
||||
identity = RNS.Identity.from_bytes(identity_bytes)
|
||||
if not identity:
|
||||
raise ValueError("Could not load identity from bytes")
|
||||
|
||||
return self._save_new_identity(identity, "Restored Identity")
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Failed to restore identity: {exc}")
|
||||
|
||||
def restore_identity_from_base32(self, base32_value: str) -> dict:
|
||||
try:
|
||||
identity_bytes = base64.b32decode(base32_value, casefold=True)
|
||||
return self.restore_identity_from_bytes(identity_bytes)
|
||||
except Exception as exc:
|
||||
msg = f"Invalid base32 identity: {exc}"
|
||||
raise ValueError(msg) from exc
|
||||
|
||||
return self.restore_identity_from_bytes(identity_bytes)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import os
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import os
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class IntegrityManager:
|
||||
@@ -30,7 +30,7 @@ class IntegrityManager:
|
||||
return True, ["Initial run - no manifest yet"]
|
||||
|
||||
try:
|
||||
with open(self.manifest_path, "r") as f:
|
||||
with open(self.manifest_path) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
issues = []
|
||||
@@ -39,7 +39,7 @@ class IntegrityManager:
|
||||
db_rel = str(self.database_path.relative_to(self.storage_dir))
|
||||
actual_db_hash = self._hash_file(self.database_path)
|
||||
if actual_db_hash and actual_db_hash != manifest.get("files", {}).get(
|
||||
db_rel
|
||||
db_rel,
|
||||
):
|
||||
issues.append(f"Database modified: {db_rel}")
|
||||
|
||||
@@ -70,7 +70,8 @@ class IntegrityManager:
|
||||
m_time = manifest.get("time", "Unknown")
|
||||
m_id = manifest.get("identity", "Unknown")
|
||||
issues.insert(
|
||||
0, f"Last integrity snapshot: {m_date} {m_time} (Identity: {m_id})"
|
||||
0,
|
||||
f"Last integrity snapshot: {m_date} {m_time} (Identity: {m_id})",
|
||||
)
|
||||
|
||||
# Check if identity matches
|
||||
@@ -84,7 +85,7 @@ class IntegrityManager:
|
||||
self.issues = issues
|
||||
return len(issues) == 0, issues
|
||||
except Exception as e:
|
||||
return False, [f"Integrity check failed: {str(e)}"]
|
||||
return False, [f"Integrity check failed: {e!s}"]
|
||||
|
||||
def save_manifest(self):
|
||||
"""Snapshot the current state of critical files."""
|
||||
|
||||
@@ -3,10 +3,11 @@ import time
|
||||
|
||||
import RNS
|
||||
from RNS.Interfaces.Interface import Interface
|
||||
from websockets.sync.server import Server, ServerConnection, serve
|
||||
|
||||
from meshchatx.src.backend.interfaces.WebsocketClientInterface import (
|
||||
WebsocketClientInterface,
|
||||
)
|
||||
from websockets.sync.server import Server, ServerConnection, serve
|
||||
|
||||
|
||||
class WebsocketServerInterface(Interface):
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import base64
|
||||
import json
|
||||
|
||||
import LXMF
|
||||
|
||||
from meshchatx.src.backend.telemetry_utils import Telemeter
|
||||
|
||||
|
||||
|
||||
@@ -191,9 +191,9 @@ class MapManager:
|
||||
for z in zoom_levels:
|
||||
x1, y1 = self._lonlat_to_tile(min_lon, max_lat, z)
|
||||
x2, y2 = self._lonlat_to_tile(max_lon, min_lat, z)
|
||||
for x in range(x1, x2 + 1):
|
||||
for y in range(y1, y2 + 1):
|
||||
tiles_to_download.append((z, x, y))
|
||||
tiles_to_download.extend(
|
||||
(z, x, y) for x in range(x1, x2 + 1) for y in range(y1, y2 + 1)
|
||||
)
|
||||
|
||||
total_tiles = len(tiles_to_download)
|
||||
self._export_progress[export_id]["total"] = total_tiles
|
||||
@@ -265,7 +265,7 @@ class MapManager:
|
||||
return None
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor(
|
||||
max_workers=max_workers
|
||||
max_workers=max_workers,
|
||||
) as executor:
|
||||
future_to_tile = {
|
||||
executor.submit(download_tile, tile): tile
|
||||
@@ -299,7 +299,8 @@ class MapManager:
|
||||
):
|
||||
try:
|
||||
cursor.executemany(
|
||||
"INSERT INTO tiles VALUES (?, ?, ?, ?)", batch_data
|
||||
"INSERT INTO tiles VALUES (?, ?, ?, ?)",
|
||||
batch_data,
|
||||
)
|
||||
conn.commit()
|
||||
batch_data = []
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import re
|
||||
import html
|
||||
import re
|
||||
|
||||
|
||||
class MarkdownRenderer:
|
||||
@@ -24,12 +24,15 @@ class MarkdownRenderer:
|
||||
code = match.group(2)
|
||||
placeholder = f"[[CB{len(code_blocks)}]]"
|
||||
code_blocks.append(
|
||||
f'<pre class="bg-gray-800 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 p-4 rounded-lg my-4 overflow-x-auto border border-gray-700 dark:border-zinc-800 font-mono text-sm"><code class="language-{lang} text-inherit">{code}</code></pre>'
|
||||
f'<pre class="bg-gray-800 dark:bg-zinc-900 text-zinc-100 dark:text-zinc-100 p-4 rounded-lg my-4 overflow-x-auto border border-gray-700 dark:border-zinc-800 font-mono text-sm"><code class="language-{lang} text-inherit">{code}</code></pre>',
|
||||
)
|
||||
return placeholder
|
||||
|
||||
text = re.sub(
|
||||
r"```(\w+)?\n(.*?)\n```", code_block_placeholder, text, flags=re.DOTALL
|
||||
r"```(\w+)?\n(.*?)\n```",
|
||||
code_block_placeholder,
|
||||
text,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
# Horizontal Rules
|
||||
@@ -134,7 +137,10 @@ class MarkdownRenderer:
|
||||
return f'<ul class="my-4 space-y-1">{html_items}</ul>'
|
||||
|
||||
text = re.sub(
|
||||
r"((?:^[*-] .*\n?)+)", unordered_list_repl, text, flags=re.MULTILINE
|
||||
r"((?:^[*-] .*\n?)+)",
|
||||
unordered_list_repl,
|
||||
text,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
def ordered_list_repl(match):
|
||||
@@ -146,7 +152,10 @@ class MarkdownRenderer:
|
||||
return f'<ol class="my-4 space-y-1">{html_items}</ol>'
|
||||
|
||||
text = re.sub(
|
||||
r"((?:^\d+\. .*\n?)+)", ordered_list_repl, text, flags=re.MULTILINE
|
||||
r"((?:^\d+\. .*\n?)+)",
|
||||
ordered_list_repl,
|
||||
text,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
|
||||
# Paragraphs - double newline to p tag
|
||||
@@ -169,7 +178,7 @@ class MarkdownRenderer:
|
||||
# Replace single newlines with <br> for line breaks within paragraphs
|
||||
part = part.replace("\n", "<br>")
|
||||
processed_parts.append(
|
||||
f'<p class="my-4 leading-relaxed text-gray-800 dark:text-zinc-200">{part}</p>'
|
||||
f'<p class="my-4 leading-relaxed text-gray-800 dark:text-zinc-200">{part}</p>',
|
||||
)
|
||||
|
||||
text = "\n".join(processed_parts)
|
||||
|
||||
@@ -9,8 +9,7 @@ from LXMF import LXMRouter
|
||||
|
||||
|
||||
def create_lxmf_router(identity, storagepath, propagation_cost=None):
|
||||
"""
|
||||
Creates an LXMF.LXMRouter instance safely, avoiding signal handler crashes
|
||||
"""Creates an LXMF.LXMRouter instance safely, avoiding signal handler crashes
|
||||
when called from non-main threads.
|
||||
"""
|
||||
if propagation_cost is None:
|
||||
|
||||
@@ -77,7 +77,11 @@ class MessageHandler:
|
||||
) m2 ON m1.peer_hash = m2.peer_hash AND m1.timestamp = m2.max_ts
|
||||
LEFT JOIN announces a ON a.destination_hash = m1.peer_hash
|
||||
LEFT JOIN custom_destination_display_names c ON c.destination_hash = m1.peer_hash
|
||||
LEFT JOIN contacts con ON con.remote_identity_hash = m1.peer_hash
|
||||
LEFT JOIN contacts con ON (
|
||||
con.remote_identity_hash = m1.peer_hash OR
|
||||
con.lxmf_address = m1.peer_hash OR
|
||||
con.lxst_address = m1.peer_hash
|
||||
)
|
||||
LEFT JOIN lxmf_user_icons i ON i.destination_hash = m1.peer_hash
|
||||
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = m1.peer_hash
|
||||
"""
|
||||
@@ -86,7 +90,7 @@ class MessageHandler:
|
||||
|
||||
if filter_unread:
|
||||
where_clauses.append(
|
||||
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))"
|
||||
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))",
|
||||
)
|
||||
|
||||
if filter_failed:
|
||||
@@ -94,7 +98,7 @@ class MessageHandler:
|
||||
|
||||
if filter_has_attachments:
|
||||
where_clauses.append(
|
||||
"(m1.fields IS NOT NULL AND m1.fields != '{}' AND m1.fields != '')"
|
||||
"(m1.fields IS NOT NULL AND m1.fields != '{}' AND m1.fields != '')",
|
||||
)
|
||||
|
||||
if search:
|
||||
@@ -105,7 +109,7 @@ class MessageHandler:
|
||||
OR m1.peer_hash IN (SELECT peer_hash FROM lxmf_messages WHERE title LIKE ? OR content LIKE ?))
|
||||
""")
|
||||
params.extend(
|
||||
[like_term, like_term, like_term, like_term, like_term, like_term]
|
||||
[like_term, like_term, like_term, like_term, like_term, like_term],
|
||||
)
|
||||
|
||||
if where_clauses:
|
||||
|
||||
@@ -64,7 +64,8 @@ class PersistentLogHandler(logging.Handler):
|
||||
# Regex to extract IP and User-Agent from aiohttp access log
|
||||
# Format: IP [date] "GET ..." status size "referer" "User-Agent"
|
||||
match = re.search(
|
||||
r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"", message
|
||||
r"^([\d\.\:]+) .* \"[^\"]+\" \d+ \d+ \"[^\"]*\" \"([^\"]+)\"",
|
||||
message,
|
||||
)
|
||||
if match:
|
||||
ip = match.group(1)
|
||||
@@ -180,7 +181,13 @@ class PersistentLogHandler(logging.Handler):
|
||||
self.flush_lock.release()
|
||||
|
||||
def get_logs(
|
||||
self, limit=100, offset=0, search=None, level=None, module=None, is_anomaly=None
|
||||
self,
|
||||
limit=100,
|
||||
offset=0,
|
||||
search=None,
|
||||
level=None,
|
||||
module=None,
|
||||
is_anomaly=None,
|
||||
):
|
||||
if self.database:
|
||||
# Flush current buffer first to ensure we have latest logs
|
||||
@@ -196,34 +203,33 @@ class PersistentLogHandler(logging.Handler):
|
||||
module=module,
|
||||
is_anomaly=is_anomaly,
|
||||
)
|
||||
else:
|
||||
# Fallback to in-memory buffer if DB not yet available
|
||||
logs = list(self.logs_buffer)
|
||||
if search:
|
||||
logs = [
|
||||
log
|
||||
for log in logs
|
||||
if search.lower() in log["message"].lower()
|
||||
or search.lower() in log["module"].lower()
|
||||
]
|
||||
if level:
|
||||
logs = [log for log in logs if log["level"] == level]
|
||||
if is_anomaly is not None:
|
||||
logs = [
|
||||
log
|
||||
for log in logs
|
||||
if log["is_anomaly"] == (1 if is_anomaly else 0)
|
||||
]
|
||||
# Fallback to in-memory buffer if DB not yet available
|
||||
logs = list(self.logs_buffer)
|
||||
if search:
|
||||
logs = [
|
||||
log
|
||||
for log in logs
|
||||
if search.lower() in log["message"].lower()
|
||||
or search.lower() in log["module"].lower()
|
||||
]
|
||||
if level:
|
||||
logs = [log for log in logs if log["level"] == level]
|
||||
if is_anomaly is not None:
|
||||
logs = [
|
||||
log for log in logs if log["is_anomaly"] == (1 if is_anomaly else 0)
|
||||
]
|
||||
|
||||
# Sort descending
|
||||
logs.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
return logs[offset : offset + limit]
|
||||
# Sort descending
|
||||
logs.sort(key=lambda x: x["timestamp"], reverse=True)
|
||||
return logs[offset : offset + limit]
|
||||
|
||||
def get_total_count(self, search=None, level=None, module=None, is_anomaly=None):
|
||||
with self.lock:
|
||||
if self.database:
|
||||
return self.database.debug_logs.get_total_count(
|
||||
search=search, level=level, module=module, is_anomaly=is_anomaly
|
||||
search=search,
|
||||
level=level,
|
||||
module=module,
|
||||
is_anomaly=is_anomaly,
|
||||
)
|
||||
else:
|
||||
return len(self.logs_buffer)
|
||||
return len(self.logs_buffer)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import sys
|
||||
import os
|
||||
import traceback
|
||||
import platform
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
import psutil
|
||||
import RNS
|
||||
|
||||
|
||||
class CrashRecovery:
|
||||
"""
|
||||
A diagnostic utility that intercepts application crashes and provides
|
||||
"""A diagnostic utility that intercepts application crashes and provides
|
||||
meaningful error reports and system state analysis.
|
||||
"""
|
||||
|
||||
@@ -33,18 +33,14 @@ class CrashRecovery:
|
||||
self.enabled = False
|
||||
|
||||
def install(self):
|
||||
"""
|
||||
Installs the crash recovery exception hook into the system.
|
||||
"""
|
||||
"""Installs the crash recovery exception hook into the system."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
sys.excepthook = self.handle_exception
|
||||
|
||||
def disable(self):
|
||||
"""
|
||||
Disables the crash recovery system manually.
|
||||
"""
|
||||
"""Disables the crash recovery system manually."""
|
||||
self.enabled = False
|
||||
|
||||
def update_paths(
|
||||
@@ -54,9 +50,7 @@ class CrashRecovery:
|
||||
public_dir=None,
|
||||
reticulum_config_dir=None,
|
||||
):
|
||||
"""
|
||||
Updates the internal paths used for system diagnosis.
|
||||
"""
|
||||
"""Updates the internal paths used for system diagnosis."""
|
||||
if storage_dir:
|
||||
self.storage_dir = storage_dir
|
||||
if database_path:
|
||||
@@ -67,9 +61,7 @@ class CrashRecovery:
|
||||
self.reticulum_config_dir = reticulum_config_dir
|
||||
|
||||
def handle_exception(self, exc_type, exc_value, exc_traceback):
|
||||
"""
|
||||
Intercepts unhandled exceptions to provide a detailed diagnosis report.
|
||||
"""
|
||||
"""Intercepts unhandled exceptions to provide a detailed diagnosis report."""
|
||||
# Let keyboard interrupts pass through normally
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||
@@ -100,13 +92,13 @@ class CrashRecovery:
|
||||
out.write("Recovery Suggestions:\n")
|
||||
out.write(" 1. Review the 'System Environment Diagnosis' section above.\n")
|
||||
out.write(
|
||||
" 2. Verify that all dependencies are installed (poetry install or pip install -r requirements.txt).\n"
|
||||
" 2. Verify that all dependencies are installed (poetry install or pip install -r requirements.txt).\n",
|
||||
)
|
||||
out.write(
|
||||
" 3. If database corruption is suspected, try starting with --auto-recover.\n"
|
||||
" 3. If database corruption is suspected, try starting with --auto-recover.\n",
|
||||
)
|
||||
out.write(
|
||||
" 4. If the issue persists, report it to Ivan over another LXMF client: 7cc8d66b4f6a0e0e49d34af7f6077b5a\n"
|
||||
" 4. If the issue persists, report it to Ivan over another LXMF client: 7cc8d66b4f6a0e0e49d34af7f6077b5a\n",
|
||||
)
|
||||
out.write("=" * 70 + "\n\n")
|
||||
out.flush()
|
||||
@@ -115,12 +107,10 @@ class CrashRecovery:
|
||||
sys.exit(1)
|
||||
|
||||
def run_diagnosis(self, file=sys.stderr):
|
||||
"""
|
||||
Performs a series of OS-agnostic checks on the application's environment.
|
||||
"""
|
||||
"""Performs a series of OS-agnostic checks on the application's environment."""
|
||||
# Basic System Info
|
||||
file.write(
|
||||
f"- OS: {platform.system()} {platform.release()} ({platform.machine()})\n"
|
||||
f"- OS: {platform.system()} {platform.release()} ({platform.machine()})\n",
|
||||
)
|
||||
file.write(f"- Python: {sys.version.split()[0]}\n")
|
||||
|
||||
@@ -128,7 +118,7 @@ class CrashRecovery:
|
||||
try:
|
||||
mem = psutil.virtual_memory()
|
||||
file.write(
|
||||
f"- Memory: {mem.percent}% used ({mem.available / (1024**2):.1f} MB available)\n"
|
||||
f"- Memory: {mem.percent}% used ({mem.available / (1024**2):.1f} MB available)\n",
|
||||
)
|
||||
if mem.percent > 95:
|
||||
file.write(" [CRITICAL] System memory is dangerously low!\n")
|
||||
@@ -140,12 +130,12 @@ class CrashRecovery:
|
||||
file.write(f"- Storage Path: {self.storage_dir}\n")
|
||||
if not os.path.exists(self.storage_dir):
|
||||
file.write(
|
||||
" [ERROR] Storage path does not exist. Check MESHCHAT_STORAGE_DIR.\n"
|
||||
" [ERROR] Storage path does not exist. Check MESHCHAT_STORAGE_DIR.\n",
|
||||
)
|
||||
else:
|
||||
if not os.access(self.storage_dir, os.W_OK):
|
||||
file.write(
|
||||
" [ERROR] Storage path is NOT writable. Check filesystem permissions.\n"
|
||||
" [ERROR] Storage path is NOT writable. Check filesystem permissions.\n",
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -154,7 +144,7 @@ class CrashRecovery:
|
||||
file.write(f" - Disk Space: {free_mb:.1f} MB free\n")
|
||||
if free_mb < 50:
|
||||
file.write(
|
||||
" [CRITICAL] Disk space is critically low (< 50MB)!\n"
|
||||
" [CRITICAL] Disk space is critically low (< 50MB)!\n",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -165,27 +155,28 @@ class CrashRecovery:
|
||||
if os.path.exists(self.database_path):
|
||||
if os.path.getsize(self.database_path) == 0:
|
||||
file.write(
|
||||
" [WARNING] Database file exists but is empty (0 bytes).\n"
|
||||
" [WARNING] Database file exists but is empty (0 bytes).\n",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
# Open in read-only mode for safety during crash handling
|
||||
conn = sqlite3.connect(
|
||||
f"file:{self.database_path}?mode=ro", uri=True
|
||||
f"file:{self.database_path}?mode=ro",
|
||||
uri=True,
|
||||
)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA integrity_check")
|
||||
res = cursor.fetchone()[0]
|
||||
if res != "ok":
|
||||
file.write(
|
||||
f" [ERROR] Database corruption detected: {res}\n"
|
||||
f" [ERROR] Database corruption detected: {res}\n",
|
||||
)
|
||||
else:
|
||||
file.write(" - Integrity: OK\n")
|
||||
conn.close()
|
||||
except sqlite3.DatabaseError as e:
|
||||
file.write(
|
||||
f" [ERROR] Database is unreadable or not a SQLite file: {e}\n"
|
||||
f" [ERROR] Database is unreadable or not a SQLite file: {e}\n",
|
||||
)
|
||||
except Exception as e:
|
||||
file.write(f" [ERROR] Database check failed: {e}\n")
|
||||
@@ -197,13 +188,13 @@ class CrashRecovery:
|
||||
file.write(f"- Frontend Assets: {self.public_dir}\n")
|
||||
if not os.path.exists(self.public_dir):
|
||||
file.write(
|
||||
" [ERROR] Frontend directory is missing. Web interface will fail to load.\n"
|
||||
" [ERROR] Frontend directory is missing. Web interface will fail to load.\n",
|
||||
)
|
||||
else:
|
||||
index_path = os.path.join(self.public_dir, "index.html")
|
||||
if not os.path.exists(index_path):
|
||||
file.write(
|
||||
" [ERROR] index.html not found in frontend directory!\n"
|
||||
" [ERROR] index.html not found in frontend directory!\n",
|
||||
)
|
||||
else:
|
||||
file.write(" - Frontend Status: Assets verified\n")
|
||||
@@ -212,9 +203,7 @@ class CrashRecovery:
|
||||
self.run_reticulum_diagnosis(file=file)
|
||||
|
||||
def run_reticulum_diagnosis(self, file=sys.stderr):
|
||||
"""
|
||||
Diagnoses the Reticulum Network Stack environment.
|
||||
"""
|
||||
"""Diagnoses the Reticulum Network Stack environment."""
|
||||
file.write("- Reticulum Network Stack:\n")
|
||||
|
||||
# Check config directory
|
||||
@@ -231,11 +220,11 @@ class CrashRecovery:
|
||||
else:
|
||||
try:
|
||||
# Basic config validation
|
||||
with open(config_file, "r") as f:
|
||||
with open(config_file) as f:
|
||||
content = f.read()
|
||||
if "[reticulum]" not in content:
|
||||
file.write(
|
||||
" [ERROR] Reticulum config file is invalid (missing [reticulum] section).\n"
|
||||
" [ERROR] Reticulum config file is invalid (missing [reticulum] section).\n",
|
||||
)
|
||||
else:
|
||||
file.write(" - Config File: OK\n")
|
||||
@@ -255,7 +244,7 @@ class CrashRecovery:
|
||||
if os.path.exists(logfile):
|
||||
file.write(f" - Recent Log Entries ({logfile}):\n")
|
||||
try:
|
||||
with open(logfile, "r") as f:
|
||||
with open(logfile) as f:
|
||||
lines = f.readlines()
|
||||
if not lines:
|
||||
file.write(" (Log file is empty)\n")
|
||||
@@ -283,7 +272,7 @@ class CrashRecovery:
|
||||
file.write(f" > {iface} [{status}]\n")
|
||||
else:
|
||||
file.write(
|
||||
" - Active Interfaces: None registered (Reticulum may not be initialized yet)\n"
|
||||
" - Active Interfaces: None registered (Reticulum may not be initialized yet)\n",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -295,7 +284,7 @@ class CrashRecovery:
|
||||
for conn in psutil.net_connections():
|
||||
if conn.laddr.port == port and conn.status == "LISTEN":
|
||||
file.write(
|
||||
f" [ALERT] Port {port} is already in use by PID {conn.pid}. Potential conflict.\n"
|
||||
f" [ALERT] Port {port} is already in use by PID {conn.pid}. Potential conflict.\n",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -55,7 +55,7 @@ class RNPathHandler:
|
||||
"timestamp": entry.get("timestamp"),
|
||||
"announce_hash": announce_hash,
|
||||
"state": state,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Sort: Responsive first, then by hops, then by interface
|
||||
@@ -64,19 +64,23 @@ class RNPathHandler:
|
||||
0 if e["state"] == RNS.Transport.STATE_RESPONSIVE else 1,
|
||||
e["hops"],
|
||||
e["interface"],
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
total = len(formatted_table)
|
||||
responsive_count = len(
|
||||
[e for e in formatted_table if e["state"] == RNS.Transport.STATE_RESPONSIVE]
|
||||
[
|
||||
e
|
||||
for e in formatted_table
|
||||
if e["state"] == RNS.Transport.STATE_RESPONSIVE
|
||||
],
|
||||
)
|
||||
unresponsive_count = len(
|
||||
[
|
||||
e
|
||||
for e in formatted_table
|
||||
if e["state"] == RNS.Transport.STATE_UNRESPONSIVE
|
||||
]
|
||||
],
|
||||
)
|
||||
|
||||
# Pagination
|
||||
@@ -96,17 +100,16 @@ class RNPathHandler:
|
||||
|
||||
def get_rate_table(self):
|
||||
table = self.reticulum.get_rate_table()
|
||||
formatted_table = []
|
||||
for entry in table:
|
||||
formatted_table.append(
|
||||
{
|
||||
"hash": entry["hash"].hex(),
|
||||
"last": entry["last"],
|
||||
"timestamps": entry["timestamps"],
|
||||
"rate_violations": entry["rate_violations"],
|
||||
"blocked_until": entry["blocked_until"],
|
||||
}
|
||||
)
|
||||
formatted_table = [
|
||||
{
|
||||
"hash": entry["hash"].hex(),
|
||||
"last": entry["last"],
|
||||
"timestamps": entry["timestamps"],
|
||||
"rate_violations": entry["rate_violations"],
|
||||
"blocked_until": entry["blocked_until"],
|
||||
}
|
||||
for entry in table
|
||||
]
|
||||
return sorted(formatted_table, key=lambda e: e["last"])
|
||||
|
||||
def drop_path(self, destination_hash: str) -> bool:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import RNS
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,11 @@ class TelephoneManager:
|
||||
# 6: STATUS_ESTABLISHED
|
||||
|
||||
def __init__(
|
||||
self, identity: RNS.Identity, config_manager=None, storage_dir=None, db=None
|
||||
self,
|
||||
identity: RNS.Identity,
|
||||
config_manager=None,
|
||||
storage_dir=None,
|
||||
db=None,
|
||||
):
|
||||
self.identity = identity
|
||||
self.config_manager = config_manager
|
||||
@@ -177,7 +181,8 @@ class TelephoneManager:
|
||||
# Pack display name in LXMF-compatible app data format
|
||||
app_data = msgpack.packb([display_name, None, None])
|
||||
self.telephone.destination.announce(
|
||||
app_data=app_data, attached_interface=attached_interface
|
||||
app_data=app_data,
|
||||
attached_interface=attached_interface,
|
||||
)
|
||||
self.telephone.last_announce = time.time()
|
||||
else:
|
||||
@@ -190,7 +195,8 @@ class TelephoneManager:
|
||||
if self.on_initiation_status_callback:
|
||||
try:
|
||||
self.on_initiation_status_callback(
|
||||
self.initiation_status, self.initiation_target_hash
|
||||
self.initiation_status,
|
||||
self.initiation_target_hash,
|
||||
)
|
||||
except Exception as e:
|
||||
RNS.log(
|
||||
@@ -229,7 +235,7 @@ class TelephoneManager:
|
||||
if not announce:
|
||||
# 3) By identity_hash field (if user entered identity hash but we missed recall, or other announce types)
|
||||
announces = self.db.announces.get_filtered_announces(
|
||||
identity_hash=target_hash_hex
|
||||
identity_hash=target_hash_hex,
|
||||
)
|
||||
if announces:
|
||||
announce = announces[0]
|
||||
@@ -248,7 +254,7 @@ class TelephoneManager:
|
||||
if announce.get("identity_public_key"):
|
||||
try:
|
||||
return RNS.Identity.from_bytes(
|
||||
base64.b64decode(announce["identity_public_key"])
|
||||
base64.b64decode(announce["identity_public_key"]),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -297,7 +303,7 @@ class TelephoneManager:
|
||||
# Use a thread for the blocking LXST call, but monitor status for early exit
|
||||
# if established elsewhere or timed out/hung up
|
||||
call_task = asyncio.create_task(
|
||||
asyncio.to_thread(self.telephone.call, destination_identity)
|
||||
asyncio.to_thread(self.telephone.call, destination_identity),
|
||||
)
|
||||
|
||||
start_wait = time.time()
|
||||
@@ -340,7 +346,7 @@ class TelephoneManager:
|
||||
return self.telephone.active_call
|
||||
|
||||
except Exception as e:
|
||||
self._update_initiation_status(f"Failed: {str(e)}")
|
||||
self._update_initiation_status(f"Failed: {e!s}")
|
||||
await asyncio.sleep(3)
|
||||
raise
|
||||
finally:
|
||||
@@ -379,7 +385,8 @@ class TelephoneManager:
|
||||
self.telephone.audio_input.start()
|
||||
except Exception as e:
|
||||
RNS.log(
|
||||
f"Failed to start audio input for unmute: {e}", RNS.LOG_ERROR
|
||||
f"Failed to start audio input for unmute: {e}",
|
||||
RNS.LOG_ERROR,
|
||||
)
|
||||
|
||||
# Still call the internal method just in case
|
||||
@@ -415,7 +422,8 @@ class TelephoneManager:
|
||||
self.telephone.audio_output.start()
|
||||
except Exception as e:
|
||||
RNS.log(
|
||||
f"Failed to start audio output for unmute: {e}", RNS.LOG_ERROR
|
||||
f"Failed to start audio output for unmute: {e}",
|
||||
RNS.LOG_ERROR,
|
||||
)
|
||||
|
||||
# Still call the internal method just in case
|
||||
|
||||
@@ -556,7 +556,8 @@ class VoicemailManager:
|
||||
os.remove(filepath)
|
||||
os.rename(temp_path, filepath)
|
||||
RNS.log(
|
||||
f"Voicemail: Fixed recording format for {filepath}", RNS.LOG_DEBUG
|
||||
f"Voicemail: Fixed recording format for {filepath}",
|
||||
RNS.LOG_DEBUG,
|
||||
)
|
||||
else:
|
||||
RNS.log(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import RNS
|
||||
@@ -89,9 +88,9 @@ class WebAudioBridge:
|
||||
self.telephone_manager = telephone_manager
|
||||
self.config_manager = config_manager
|
||||
self.clients = set()
|
||||
self.tx_source: Optional[WebAudioSource] = None
|
||||
self.rx_sink: Optional[WebAudioSink] = None
|
||||
self.rx_tee: Optional[Tee] = None
|
||||
self.tx_source: WebAudioSource | None = None
|
||||
self.rx_sink: WebAudioSink | None = None
|
||||
self.rx_tee: Tee | None = None
|
||||
self.loop = asyncio.get_event_loop()
|
||||
self.lock = threading.Lock()
|
||||
|
||||
@@ -137,8 +136,8 @@ class WebAudioBridge:
|
||||
{
|
||||
"type": "web_audio.ready",
|
||||
"frame_ms": frame_ms,
|
||||
}
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
def push_client_frame(self, pcm_bytes: bytes):
|
||||
@@ -173,7 +172,8 @@ class WebAudioBridge:
|
||||
tele.transmit_mixer.start()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
RNS.log(
|
||||
f"WebAudioBridge: failed to swap transmit path: {exc}", RNS.LOG_ERROR
|
||||
f"WebAudioBridge: failed to swap transmit path: {exc}",
|
||||
RNS.LOG_ERROR,
|
||||
)
|
||||
|
||||
def _ensure_rx_tee(self, tele):
|
||||
|
||||
@@ -630,6 +630,7 @@ export default {
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
GlobalEmitter.off("config-updated", this.onConfigUpdatedExternally);
|
||||
},
|
||||
mounted() {
|
||||
// listen for websocket messages
|
||||
@@ -650,6 +651,8 @@ export default {
|
||||
this.syncPropagationNode();
|
||||
});
|
||||
|
||||
GlobalEmitter.on("config-updated", this.onConfigUpdatedExternally);
|
||||
|
||||
GlobalEmitter.on("keyboard-shortcut", (action) => {
|
||||
this.handleKeyboardShortcut(action);
|
||||
});
|
||||
@@ -691,6 +694,12 @@ export default {
|
||||
}, 15000);
|
||||
},
|
||||
methods: {
|
||||
onConfigUpdatedExternally(newConfig) {
|
||||
if (!newConfig) return;
|
||||
this.config = newConfig;
|
||||
GlobalState.config = newConfig;
|
||||
this.displayName = newConfig.display_name;
|
||||
},
|
||||
applyThemePreference(theme) {
|
||||
const mode = theme === "dark" ? "dark" : "light";
|
||||
if (typeof document !== "undefined") {
|
||||
|
||||
@@ -24,13 +24,23 @@
|
||||
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="notifications.length > 0"
|
||||
type="button"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors"
|
||||
@click.stop="clearAllNotifications"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
@click="closeDropdown"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -139,6 +149,7 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: ["notifications-cleared"],
|
||||
data() {
|
||||
return {
|
||||
isDropdownOpen: false,
|
||||
@@ -233,6 +244,37 @@ export default {
|
||||
console.error("Failed to mark notifications as viewed", e);
|
||||
}
|
||||
},
|
||||
async clearAllNotifications() {
|
||||
try {
|
||||
await window.axios.post("/api/v1/notifications/mark-as-viewed", {
|
||||
destination_hashes: [],
|
||||
notification_ids: [],
|
||||
});
|
||||
|
||||
const response = await window.axios.get("/api/v1/lxmf/conversations");
|
||||
const conversations = response.data.conversations || [];
|
||||
|
||||
for (const conversation of conversations) {
|
||||
if (conversation.is_unread) {
|
||||
try {
|
||||
await window.axios.get(
|
||||
`/api/v1/lxmf/conversations/${conversation.destination_hash}/mark-as-read`
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`Failed to mark conversation as read: ${conversation.destination_hash}`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const GlobalState = (await import("../js/GlobalState")).default;
|
||||
GlobalState.unreadConversationsCount = 0;
|
||||
|
||||
await this.loadNotifications();
|
||||
this.$emit("notifications-cleared");
|
||||
} catch (e) {
|
||||
console.error("Failed to clear notifications", e);
|
||||
}
|
||||
},
|
||||
onNotificationClick(notification) {
|
||||
this.closeDropdown();
|
||||
if (notification.type === "lxmf_message") {
|
||||
|
||||
@@ -556,6 +556,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Backups -->
|
||||
<div v-if="autoBackups.length > 0" class="space-y-6">
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div
|
||||
class="font-black text-gray-900 dark:text-white text-sm tracking-tight flex items-center gap-2"
|
||||
>
|
||||
<v-icon icon="mdi-history" size="16" class="text-blue-500"></v-icon>
|
||||
Automatic Backups
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
Automated daily snapshots of your database.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="backup in autoBackups"
|
||||
:key="backup.path"
|
||||
class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-blue-500/20 transition-all group"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
|
||||
>{{ backup.name }}</span
|
||||
>
|
||||
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
|
||||
>{{ formatBytes(backup.size) }} • {{ backup.created_at }}</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
|
||||
@click="restoreFromSnapshot(backup.path)"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Identity Section -->
|
||||
<div class="bg-red-500/5 p-6 rounded-2xl border border-red-500/10 space-y-6">
|
||||
<div class="flex items-center gap-4 text-red-500">
|
||||
@@ -686,6 +728,7 @@ export default {
|
||||
snapshotInProgress: false,
|
||||
snapshotMessage: "",
|
||||
snapshotError: "",
|
||||
autoBackups: [],
|
||||
identityBackupMessage: "",
|
||||
identityBackupError: "",
|
||||
identityBase32: "",
|
||||
@@ -713,6 +756,7 @@ export default {
|
||||
this.getConfig();
|
||||
this.getDatabaseHealth();
|
||||
this.listSnapshots();
|
||||
this.listAutoBackups();
|
||||
// Update stats every 5 seconds
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.getAppInfo();
|
||||
@@ -738,6 +782,14 @@ export default {
|
||||
console.log("Failed to list snapshots", e);
|
||||
}
|
||||
},
|
||||
async listAutoBackups() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/database/backups");
|
||||
this.autoBackups = response.data;
|
||||
} catch (e) {
|
||||
console.log("Failed to list auto-backups", e);
|
||||
}
|
||||
},
|
||||
async createSnapshot() {
|
||||
if (this.snapshotInProgress) return;
|
||||
this.snapshotInProgress = true;
|
||||
@@ -1071,7 +1123,7 @@ export default {
|
||||
const response = await window.axios.post("/api/v1/identity/restore", formData, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
|
||||
this.identityRestoreMessage = response.data.message || "Identity imported.";
|
||||
} catch (e) {
|
||||
this.identityRestoreError = "Identity restore failed";
|
||||
console.log(e);
|
||||
@@ -1094,7 +1146,7 @@ export default {
|
||||
const response = await window.axios.post("/api/v1/identity/restore", {
|
||||
base32: this.identityRestoreBase32.trim(),
|
||||
});
|
||||
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
|
||||
this.identityRestoreMessage = response.data.message || "Identity imported.";
|
||||
} catch (e) {
|
||||
this.identityRestoreError = "Identity restore failed";
|
||||
console.log(e);
|
||||
|
||||
@@ -516,12 +516,18 @@
|
||||
:label="$t('call.allow_calls_from_contacts_only')"
|
||||
@update:model-value="toggleAllowCallsFromContactsOnly"
|
||||
/>
|
||||
<Toggle
|
||||
id="web-audio-toggle"
|
||||
:model-value="config?.telephone_web_audio_enabled"
|
||||
label="Browser/Electron Audio"
|
||||
@update:model-value="onToggleWebAudio"
|
||||
/>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Toggle
|
||||
id="web-audio-toggle"
|
||||
:model-value="config?.telephone_web_audio_enabled"
|
||||
label="Web Audio Bridge"
|
||||
@update:model-value="onToggleWebAudio"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400 px-1">
|
||||
Web audio bridge allows web/electron to hook into LXST backend for
|
||||
passing microphone and audio streams to active telephone calls.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 shrink-0">
|
||||
<!-- <Toggle
|
||||
@@ -676,6 +682,7 @@
|
||||
<div class="relative shrink-0">
|
||||
<LxmfUserIcon
|
||||
:custom-image="
|
||||
entry.contact_image ||
|
||||
getContactByHash(entry.remote_identity_hash)?.custom_image
|
||||
"
|
||||
:icon-name="entry.remote_icon ? entry.remote_icon.icon_name : ''"
|
||||
@@ -845,18 +852,12 @@
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="shrink-0">
|
||||
<LxmfUserIcon
|
||||
v-if="announce.lxmf_user_icon"
|
||||
:icon-name="announce.lxmf_user_icon.icon_name"
|
||||
:icon-foreground-colour="announce.lxmf_user_icon.foreground_colour"
|
||||
:icon-background-colour="announce.lxmf_user_icon.background_colour"
|
||||
:custom-image="announce.contact_image"
|
||||
:icon-name="announce.lxmf_user_icon?.icon_name"
|
||||
:icon-foreground-colour="announce.lxmf_user_icon?.foreground_colour"
|
||||
:icon-background-colour="announce.lxmf_user_icon?.background_colour"
|
||||
class="size-10"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="size-10 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center font-bold shrink-0"
|
||||
>
|
||||
{{ (announce.display_name || "A")[0].toUpperCase() }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -1461,16 +1462,34 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mt-1">
|
||||
<span
|
||||
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
|
||||
:title="contact.remote_identity_hash"
|
||||
@click.stop="copyHash(contact.remote_identity_hash)"
|
||||
>
|
||||
{{ formatDestinationHash(contact.remote_identity_hash) }}
|
||||
</span>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span
|
||||
class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
|
||||
:title="contact.remote_identity_hash"
|
||||
@click.stop="copyHash(contact.remote_identity_hash)"
|
||||
>
|
||||
ID: {{ formatDestinationHash(contact.remote_identity_hash) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="contact.lxmf_address"
|
||||
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
|
||||
:title="contact.lxmf_address"
|
||||
@click.stop="copyHash(contact.lxmf_address)"
|
||||
>
|
||||
LXMF: {{ formatDestinationHash(contact.lxmf_address) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="contact.lxst_address"
|
||||
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate cursor-pointer hover:text-blue-500 transition-colors"
|
||||
:title="contact.lxst_address"
|
||||
@click.stop="copyHash(contact.lxst_address)"
|
||||
>
|
||||
LXST: {{ formatDestinationHash(contact.lxst_address) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors"
|
||||
class="text-[10px] bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider hover:bg-blue-200 dark:hover:bg-blue-900/50 transition-colors shrink-0"
|
||||
@click="
|
||||
destinationHash =
|
||||
contact.remote_telephony_hash ||
|
||||
@@ -2044,6 +2063,34 @@
|
||||
placeholder="e.g. a39610c89d18bb48c73e429582423c24"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
|
||||
>
|
||||
{{ $t("app.lxmf_address") }}
|
||||
</label>
|
||||
<input
|
||||
v-model="contactForm.lxmf_address"
|
||||
type="text"
|
||||
class="input-field font-mono text-xs"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
|
||||
>
|
||||
LXST Address
|
||||
</label>
|
||||
<input
|
||||
v-model="contactForm.lxst_address"
|
||||
type="text"
|
||||
class="input-field font-mono text-xs"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
class="block text-xs font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider mb-1.5 ml-1"
|
||||
@@ -2944,6 +2991,8 @@ export default {
|
||||
this.contactForm = {
|
||||
name: "",
|
||||
remote_identity_hash: "",
|
||||
lxmf_address: "",
|
||||
lxst_address: "",
|
||||
preferred_ringtone_id: null,
|
||||
custom_image: null,
|
||||
};
|
||||
@@ -2955,6 +3004,8 @@ export default {
|
||||
id: contact.id,
|
||||
name: contact.name,
|
||||
remote_identity_hash: contact.remote_identity_hash,
|
||||
lxmf_address: contact.lxmf_address || "",
|
||||
lxst_address: contact.lxst_address || "",
|
||||
preferred_ringtone_id: contact.preferred_ringtone_id,
|
||||
custom_image: contact.custom_image,
|
||||
};
|
||||
|
||||
@@ -1076,6 +1076,207 @@
|
||||
</template>
|
||||
</ExpandingSection>
|
||||
|
||||
<ExpandingSection>
|
||||
<template #title>Interface Discovery</template>
|
||||
<template #content>
|
||||
<div class="p-2 space-y-3">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col mr-auto">
|
||||
<FormLabel class="mb-1">Advertise this Interface</FormLabel>
|
||||
<FormSubLabel>
|
||||
Broadcasts connection details so peers can find and connect to this interface.
|
||||
</FormSubLabel>
|
||||
</div>
|
||||
<Toggle v-model="discovery.discoverable" class="my-auto mx-2" />
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-500 dark:text-zinc-300">
|
||||
LXMF must be installed to publish discovery announces. When enabled, Reticulum handles
|
||||
signing, stamping, and periodic announces for this interface.
|
||||
</div>
|
||||
|
||||
<div v-if="discovery.discoverable" class="space-y-3">
|
||||
<div>
|
||||
<FormLabel class="mb-1">Discovery Name</FormLabel>
|
||||
<input
|
||||
v-model="discovery.discovery_name"
|
||||
type="text"
|
||||
placeholder="Human friendly name"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FormLabel class="mb-1">Reachable On</FormLabel>
|
||||
<input
|
||||
v-model="discovery.reachable_on"
|
||||
type="text"
|
||||
placeholder="Hostname, IP, or resolver script path"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="mb-1">Announce Interval (minutes)</FormLabel>
|
||||
<input
|
||||
v-model.number="discovery.announce_interval"
|
||||
type="number"
|
||||
min="5"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FormLabel class="mb-1">Stamp Value</FormLabel>
|
||||
<input
|
||||
v-model.number="discovery.discovery_stamp_value"
|
||||
type="number"
|
||||
min="1"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Toggle id="discovery-encrypt" v-model="discovery.discovery_encrypt" />
|
||||
<FormLabel for="discovery-encrypt" class="ml-2">Encrypt Announces</FormLabel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<Toggle id="publish-ifac" v-model="discovery.publish_ifac" />
|
||||
<FormLabel for="publish-ifac" class="ml-2">Include IFAC Credentials</FormLabel>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<FormLabel class="mb-1">Latitude</FormLabel>
|
||||
<input
|
||||
v-model.number="discovery.latitude"
|
||||
type="number"
|
||||
step="0.00001"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="mb-1">Longitude</FormLabel>
|
||||
<input
|
||||
v-model.number="discovery.longitude"
|
||||
type="number"
|
||||
step="0.00001"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="mb-1">Height (m)</FormLabel>
|
||||
<input v-model.number="discovery.height" type="number" class="input-field" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<FormLabel class="mb-1">Discovery Frequency (Hz)</FormLabel>
|
||||
<input
|
||||
v-model.number="discovery.discovery_frequency"
|
||||
type="number"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="mb-1">Discovery Bandwidth (Hz)</FormLabel>
|
||||
<input
|
||||
v-model.number="discovery.discovery_bandwidth"
|
||||
type="number"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="mb-1">Discovery Modulation</FormLabel>
|
||||
<input
|
||||
v-model="discovery.discovery_modulation"
|
||||
type="text"
|
||||
placeholder="e.g. LoRa"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400">
|
||||
If announce encryption is enabled, a valid network identity path is required in the
|
||||
Reticulum configuration.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ExpandingSection>
|
||||
|
||||
<ExpandingSection>
|
||||
<template #title>Discover Interfaces (Peer)</template>
|
||||
<template #content>
|
||||
<div class="p-2 space-y-3">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col mr-auto">
|
||||
<FormLabel class="mb-1">Enable Discovery Listener</FormLabel>
|
||||
<FormSubLabel>
|
||||
Listen for announced interfaces and optionally auto-connect to them.
|
||||
</FormSubLabel>
|
||||
</div>
|
||||
<Toggle v-model="reticulumDiscovery.discover_interfaces" class="my-auto mx-2" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<FormLabel class="mb-1">Allowed Sources (comma separated)</FormLabel>
|
||||
<input
|
||||
v-model="reticulumDiscovery.interface_discovery_sources"
|
||||
type="text"
|
||||
placeholder="Identity hashes"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="mb-1">Required Stamp Value</FormLabel>
|
||||
<input
|
||||
v-model.number="reticulumDiscovery.required_discovery_value"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="mb-1">Auto-connect Slots</FormLabel>
|
||||
<input
|
||||
v-model.number="reticulumDiscovery.autoconnect_discovered_interfaces"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input-field"
|
||||
/>
|
||||
<FormSubLabel>Set to 0 to disable auto-connect.</FormSubLabel>
|
||||
</div>
|
||||
<div>
|
||||
<FormLabel class="mb-1">Network Identity Path</FormLabel>
|
||||
<input
|
||||
v-model="reticulumDiscovery.network_identity"
|
||||
type="text"
|
||||
placeholder="~/.reticulum/storage/identities/..."
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip text-xs"
|
||||
:disabled="savingDiscovery"
|
||||
@click="saveReticulumDiscoveryConfig"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin-reverse': savingDiscovery }"
|
||||
/>
|
||||
<span class="ml-1">Save Discovery Preferences</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ExpandingSection>
|
||||
|
||||
<!-- add/save interface button -->
|
||||
<div class="p-2 bg-white rounded shadow divide-y divide-gray-200 dark:bg-zinc-900">
|
||||
<button
|
||||
@@ -1100,10 +1301,12 @@ import FormLabel from "../forms/FormLabel.vue";
|
||||
import FormSubLabel from "../forms/FormSubLabel.vue";
|
||||
import Toggle from "../forms/Toggle.vue";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "AddInterfacePage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
FormSubLabel,
|
||||
FormLabel,
|
||||
ExpandingSection,
|
||||
@@ -1147,6 +1350,32 @@ export default {
|
||||
ifac_size: null,
|
||||
},
|
||||
|
||||
discovery: {
|
||||
discoverable: false,
|
||||
discovery_name: "",
|
||||
announce_interval: 360,
|
||||
reachable_on: "",
|
||||
discovery_stamp_value: 14,
|
||||
discovery_encrypt: false,
|
||||
publish_ifac: false,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
height: null,
|
||||
discovery_frequency: null,
|
||||
discovery_bandwidth: null,
|
||||
discovery_modulation: null,
|
||||
},
|
||||
|
||||
reticulumDiscovery: {
|
||||
discover_interfaces: false,
|
||||
interface_discovery_sources: "",
|
||||
required_discovery_value: null,
|
||||
autoconnect_discovered_interfaces: 0,
|
||||
network_identity: "",
|
||||
},
|
||||
|
||||
savingDiscovery: false,
|
||||
|
||||
newInterfaceForwardIp: null,
|
||||
newInterfaceForwardPort: null,
|
||||
|
||||
@@ -1250,6 +1479,7 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.getConfig();
|
||||
this.loadReticulumDiscoveryConfig();
|
||||
this.loadComports();
|
||||
this.loadCommunityInterfaces();
|
||||
|
||||
@@ -1279,6 +1509,66 @@ export default {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
parseBool(value) {
|
||||
if (typeof value === "string") {
|
||||
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
|
||||
}
|
||||
return Boolean(value);
|
||||
},
|
||||
async loadReticulumDiscoveryConfig() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/reticulum/discovery`);
|
||||
const discovery = response.data?.discovery ?? {};
|
||||
this.reticulumDiscovery.discover_interfaces = this.parseBool(discovery.discover_interfaces);
|
||||
this.reticulumDiscovery.interface_discovery_sources = discovery.interface_discovery_sources ?? "";
|
||||
this.reticulumDiscovery.required_discovery_value =
|
||||
discovery.required_discovery_value !== undefined &&
|
||||
discovery.required_discovery_value !== null &&
|
||||
discovery.required_discovery_value !== ""
|
||||
? Number(discovery.required_discovery_value)
|
||||
: null;
|
||||
this.reticulumDiscovery.autoconnect_discovered_interfaces =
|
||||
discovery.autoconnect_discovered_interfaces !== undefined &&
|
||||
discovery.autoconnect_discovered_interfaces !== null &&
|
||||
discovery.autoconnect_discovered_interfaces !== ""
|
||||
? Number(discovery.autoconnect_discovered_interfaces)
|
||||
: 0;
|
||||
this.reticulumDiscovery.network_identity = discovery.network_identity ?? "";
|
||||
} catch (e) {
|
||||
// safe to ignore if discovery config cannot be loaded
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async saveReticulumDiscoveryConfig() {
|
||||
if (this.savingDiscovery) return;
|
||||
this.savingDiscovery = true;
|
||||
try {
|
||||
const payload = {
|
||||
discover_interfaces: this.reticulumDiscovery.discover_interfaces,
|
||||
interface_discovery_sources: this.reticulumDiscovery.interface_discovery_sources || null,
|
||||
required_discovery_value:
|
||||
this.reticulumDiscovery.required_discovery_value === null ||
|
||||
this.reticulumDiscovery.required_discovery_value === ""
|
||||
? null
|
||||
: Number(this.reticulumDiscovery.required_discovery_value),
|
||||
autoconnect_discovered_interfaces:
|
||||
this.reticulumDiscovery.autoconnect_discovered_interfaces === null ||
|
||||
this.reticulumDiscovery.autoconnect_discovered_interfaces === ""
|
||||
? 0
|
||||
: Number(this.reticulumDiscovery.autoconnect_discovered_interfaces),
|
||||
network_identity: this.reticulumDiscovery.network_identity || null,
|
||||
};
|
||||
|
||||
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
|
||||
ToastUtils.success("Discovery settings saved");
|
||||
await this.loadReticulumDiscoveryConfig();
|
||||
} catch (e) {
|
||||
ToastUtils.error("Failed to save discovery settings");
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.savingDiscovery = false;
|
||||
}
|
||||
},
|
||||
async loadComports() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/comports`);
|
||||
@@ -1408,6 +1698,22 @@ export default {
|
||||
this.sharedInterfaceSettings.network_name = iface.network_name;
|
||||
this.sharedInterfaceSettings.passphrase = iface.passphrase;
|
||||
this.sharedInterfaceSettings.ifac_size = iface.ifac_size;
|
||||
|
||||
// interface discovery
|
||||
this.discovery.discoverable = this.parseBool(iface.discoverable);
|
||||
this.discovery.discovery_name = iface.discovery_name ?? "";
|
||||
this.discovery.announce_interval = iface.announce_interval ?? this.discovery.announce_interval;
|
||||
this.discovery.reachable_on = iface.reachable_on ?? "";
|
||||
this.discovery.discovery_stamp_value =
|
||||
iface.discovery_stamp_value ?? this.discovery.discovery_stamp_value;
|
||||
this.discovery.discovery_encrypt = this.parseBool(iface.discovery_encrypt);
|
||||
this.discovery.publish_ifac = this.parseBool(iface.publish_ifac);
|
||||
this.discovery.latitude = iface.latitude !== undefined ? Number(iface.latitude) : null;
|
||||
this.discovery.longitude = iface.longitude !== undefined ? Number(iface.longitude) : null;
|
||||
this.discovery.height = iface.height !== undefined ? Number(iface.height) : null;
|
||||
this.discovery.discovery_frequency = iface.discovery_frequency ?? null;
|
||||
this.discovery.discovery_bandwidth = iface.discovery_bandwidth ?? null;
|
||||
this.discovery.discovery_modulation = iface.discovery_modulation ?? null;
|
||||
} catch {
|
||||
// do nothing if failed to load interfaces
|
||||
}
|
||||
@@ -1430,6 +1736,15 @@ export default {
|
||||
});
|
||||
}
|
||||
|
||||
const discoveryEnabled = this.discovery.discoverable === true;
|
||||
const isRadioInterface = ["RNodeInterface", "RNodeIPInterface"].includes(this.newInterfaceType);
|
||||
const fallbackDiscoveryFrequency =
|
||||
this.discovery.discovery_frequency ??
|
||||
(discoveryEnabled && isRadioInterface ? this.calculateFrequencyInHz() : null);
|
||||
const fallbackDiscoveryBandwidth =
|
||||
this.discovery.discovery_bandwidth ??
|
||||
(discoveryEnabled && isRadioInterface ? this.newInterfaceBandwidth : null);
|
||||
|
||||
// add interface
|
||||
const response = await window.axios.post(`/api/v1/reticulum/interfaces/add`, {
|
||||
allow_overwriting_interface: this.isEditingInterface,
|
||||
@@ -1506,6 +1821,32 @@ export default {
|
||||
airtime_limit_long: this.newInterfaceAirtimeLimitLong,
|
||||
airtime_limit_short: this.newInterfaceAirtimeLimitShort,
|
||||
|
||||
// discovery options
|
||||
discoverable: discoveryEnabled ? "yes" : null,
|
||||
discovery_name: discoveryEnabled ? this.discovery.discovery_name : null,
|
||||
announce_interval:
|
||||
discoveryEnabled && this.discovery.announce_interval !== null
|
||||
? Number(this.discovery.announce_interval)
|
||||
: null,
|
||||
reachable_on: discoveryEnabled ? this.discovery.reachable_on : null,
|
||||
discovery_stamp_value:
|
||||
discoveryEnabled && this.discovery.discovery_stamp_value !== null
|
||||
? Number(this.discovery.discovery_stamp_value)
|
||||
: null,
|
||||
discovery_encrypt: discoveryEnabled ? this.discovery.discovery_encrypt : null,
|
||||
publish_ifac: discoveryEnabled ? this.discovery.publish_ifac : null,
|
||||
latitude:
|
||||
discoveryEnabled && this.discovery.latitude !== null ? Number(this.discovery.latitude) : null,
|
||||
longitude:
|
||||
discoveryEnabled && this.discovery.longitude !== null ? Number(this.discovery.longitude) : null,
|
||||
height: discoveryEnabled && this.discovery.height !== null ? Number(this.discovery.height) : null,
|
||||
discovery_frequency: discoveryEnabled ? fallbackDiscoveryFrequency : null,
|
||||
discovery_bandwidth: discoveryEnabled ? fallbackDiscoveryBandwidth : null,
|
||||
discovery_modulation:
|
||||
discoveryEnabled && this.discovery.discovery_modulation
|
||||
? this.discovery.discovery_modulation
|
||||
: null,
|
||||
|
||||
// settings that can be added to any interface type
|
||||
mode: this.sharedInterfaceSettings.mode || "full",
|
||||
bitrate: this.sharedInterfaceSettings.bitrate,
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
<span :class="statusChipClass">{{
|
||||
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
|
||||
}}</span>
|
||||
<span v-if="isDiscoverable()" class="discoverable-chip">Discoverable</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ description }}
|
||||
@@ -244,6 +245,13 @@ export default {
|
||||
onIFACSignatureClick: function (ifacSignature) {
|
||||
DialogUtils.alert(ifacSignature);
|
||||
},
|
||||
isDiscoverable() {
|
||||
const value = this.iface.discoverable;
|
||||
if (typeof value === "string") {
|
||||
return ["true", "yes", "1", "on"].includes(value.toLowerCase());
|
||||
}
|
||||
return Boolean(value);
|
||||
},
|
||||
isInterfaceEnabled: function (iface) {
|
||||
return Utils.isInterfaceEnabled(iface);
|
||||
},
|
||||
@@ -292,6 +300,9 @@ export default {
|
||||
.ifac-line {
|
||||
@apply text-xs flex flex-wrap items-center gap-1;
|
||||
}
|
||||
.discoverable-chip {
|
||||
@apply inline-flex items-center rounded-full bg-blue-100 text-blue-700 px-2 py-0.5 text-xs font-semibold dark:bg-blue-900/50 dark:text-blue-200;
|
||||
}
|
||||
.detail-grid {
|
||||
@apply grid gap-3 sm:grid-cols-2;
|
||||
}
|
||||
|
||||
@@ -120,7 +120,115 @@
|
||||
<div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 xl:grid-cols-2">
|
||||
<div class="glass-card space-y-4">
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Discovery
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interface Discovery</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Publish your interfaces for others to find, or listen for announced entrypoints and
|
||||
auto-connect to them.
|
||||
</div>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'interfaces.add' }" class="secondary-chip text-sm">
|
||||
<MaterialDesignIcon icon-name="lan" class="w-4 h-4" />
|
||||
Configure Per-Interface
|
||||
</RouterLink>
|
||||
</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||
<div class="font-semibold text-gray-900 dark:text-white">Publish (Server)</div>
|
||||
<div>
|
||||
Enable discovery while adding or editing an interface to broadcast reachable details.
|
||||
Reticulum will sign and stamp announces automatically.
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Requires LXMF in the Python environment. Transport is optional for publishing, but
|
||||
usually recommended so peers can connect back.
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center">
|
||||
<div class="flex flex-col mr-auto">
|
||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Discover Interfaces (Peer)
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Listen for discovery announces and optionally auto-connect to available
|
||||
interfaces.
|
||||
</div>
|
||||
</div>
|
||||
<Toggle v-model="discoveryConfig.discover_interfaces" class="my-auto mx-2" />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||
Allowed Sources
|
||||
</div>
|
||||
<input
|
||||
v-model="discoveryConfig.interface_discovery_sources"
|
||||
type="text"
|
||||
placeholder="Comma separated identity hashes"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||
Required Stamp Value
|
||||
</div>
|
||||
<input
|
||||
v-model.number="discoveryConfig.required_discovery_value"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||
Auto-connect Slots
|
||||
</div>
|
||||
<input
|
||||
v-model.number="discoveryConfig.autoconnect_discovered_interfaces"
|
||||
type="number"
|
||||
min="0"
|
||||
class="input-field"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">0 disables auto-connect.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||
Network Identity Path
|
||||
</div>
|
||||
<input
|
||||
v-model="discoveryConfig.network_identity"
|
||||
type="text"
|
||||
placeholder="~/.reticulum/storage/identities/..."
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip text-xs"
|
||||
:disabled="savingDiscovery"
|
||||
@click="saveDiscoveryConfig"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin-reverse': savingDiscovery }"
|
||||
/>
|
||||
<span class="ml-1">Save Discovery Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredInterfaces.length !== 0" class="grid gap-4 xl:grid-cols-2">
|
||||
<Interface
|
||||
v-for="iface of filteredInterfaces"
|
||||
:key="iface._name"
|
||||
@@ -150,10 +258,12 @@ import DownloadUtils from "../../js/DownloadUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
import Toggle from "../forms/Toggle.vue";
|
||||
|
||||
export default {
|
||||
name: "InterfacesPage",
|
||||
components: {
|
||||
Toggle,
|
||||
ImportInterfacesModal,
|
||||
Interface,
|
||||
MaterialDesignIcon,
|
||||
@@ -168,6 +278,14 @@ export default {
|
||||
typeFilter: "all",
|
||||
reloadingRns: false,
|
||||
isReticulumRunning: true,
|
||||
discoveryConfig: {
|
||||
discover_interfaces: false,
|
||||
interface_discovery_sources: "",
|
||||
required_discovery_value: null,
|
||||
autoconnect_discovered_interfaces: 0,
|
||||
network_identity: "",
|
||||
},
|
||||
savingDiscovery: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -246,6 +364,7 @@ export default {
|
||||
mounted() {
|
||||
this.loadInterfaces();
|
||||
this.updateInterfaceStats();
|
||||
this.loadDiscoveryConfig();
|
||||
|
||||
// update info every few seconds
|
||||
this.reloadInterval = setInterval(() => {
|
||||
@@ -387,6 +506,65 @@ export default {
|
||||
this.trackInterfaceChange();
|
||||
}
|
||||
},
|
||||
parseBool(value) {
|
||||
if (typeof value === "string") {
|
||||
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
|
||||
}
|
||||
return Boolean(value);
|
||||
},
|
||||
async loadDiscoveryConfig() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/reticulum/discovery`);
|
||||
const discovery = response.data?.discovery ?? {};
|
||||
this.discoveryConfig.discover_interfaces = this.parseBool(discovery.discover_interfaces);
|
||||
this.discoveryConfig.interface_discovery_sources = discovery.interface_discovery_sources ?? "";
|
||||
this.discoveryConfig.required_discovery_value =
|
||||
discovery.required_discovery_value !== undefined &&
|
||||
discovery.required_discovery_value !== null &&
|
||||
discovery.required_discovery_value !== ""
|
||||
? Number(discovery.required_discovery_value)
|
||||
: null;
|
||||
this.discoveryConfig.autoconnect_discovered_interfaces =
|
||||
discovery.autoconnect_discovered_interfaces !== undefined &&
|
||||
discovery.autoconnect_discovered_interfaces !== null &&
|
||||
discovery.autoconnect_discovered_interfaces !== ""
|
||||
? Number(discovery.autoconnect_discovered_interfaces)
|
||||
: 0;
|
||||
this.discoveryConfig.network_identity = discovery.network_identity ?? "";
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async saveDiscoveryConfig() {
|
||||
if (this.savingDiscovery) return;
|
||||
this.savingDiscovery = true;
|
||||
try {
|
||||
const payload = {
|
||||
discover_interfaces: this.discoveryConfig.discover_interfaces,
|
||||
interface_discovery_sources: this.discoveryConfig.interface_discovery_sources || null,
|
||||
required_discovery_value:
|
||||
this.discoveryConfig.required_discovery_value === null ||
|
||||
this.discoveryConfig.required_discovery_value === ""
|
||||
? null
|
||||
: Number(this.discoveryConfig.required_discovery_value),
|
||||
autoconnect_discovered_interfaces:
|
||||
this.discoveryConfig.autoconnect_discovered_interfaces === null ||
|
||||
this.discoveryConfig.autoconnect_discovered_interfaces === ""
|
||||
? 0
|
||||
: Number(this.discoveryConfig.autoconnect_discovered_interfaces),
|
||||
network_identity: this.discoveryConfig.network_identity || null,
|
||||
};
|
||||
|
||||
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
|
||||
ToastUtils.success("Discovery settings saved");
|
||||
await this.loadDiscoveryConfig();
|
||||
} catch (e) {
|
||||
ToastUtils.error("Failed to save discovery settings");
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.savingDiscovery = false;
|
||||
}
|
||||
},
|
||||
setStatusFilter(value) {
|
||||
this.statusFilter = value;
|
||||
},
|
||||
|
||||
@@ -116,6 +116,22 @@
|
||||
>
|
||||
<v-icon icon="mdi-trash-can-outline" size="18" class="sm:!size-5"></v-icon>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedFeature"
|
||||
class="p-1.5 sm:p-2 rounded-xl bg-blue-100 dark:bg-blue-900/30 text-blue-600 transition-all hover:scale-110 active:scale-90"
|
||||
title="Edit note"
|
||||
@click="startEditingNote(selectedFeature)"
|
||||
>
|
||||
<v-icon icon="mdi-note-edit-outline" size="18" class="sm:!size-5"></v-icon>
|
||||
</button>
|
||||
<button
|
||||
v-if="selectedFeature && !selectedFeature.get('telemetry')"
|
||||
class="p-1.5 sm:p-2 rounded-xl bg-red-100 dark:bg-red-900/30 text-red-600 transition-all hover:scale-110 active:scale-90 animate-pulse"
|
||||
title="Delete selected item"
|
||||
@click="deleteSelectedFeature"
|
||||
>
|
||||
<v-icon icon="mdi-selection-remove" size="18" class="sm:!size-5"></v-icon>
|
||||
</button>
|
||||
<div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
|
||||
<button
|
||||
class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
|
||||
@@ -211,18 +227,27 @@
|
||||
|
||||
<!-- note hover tooltip -->
|
||||
<div
|
||||
v-if="hoveredNote && !editingFeature"
|
||||
v-if="
|
||||
hoveredFeature &&
|
||||
(hoveredFeature.get('note') ||
|
||||
(hoveredFeature.get('telemetry') && hoveredFeature.get('telemetry').note)) &&
|
||||
!editingFeature
|
||||
"
|
||||
class="absolute pointer-events-none z-50 bg-white/90 dark:bg-zinc-900/90 backdrop-blur border border-gray-200 dark:border-zinc-700 rounded-lg shadow-xl p-2 text-sm text-gray-900 dark:text-zinc-100 max-w-xs transform -translate-x-1/2 -translate-y-full mb-4"
|
||||
:style="{
|
||||
left: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[0] + 'px',
|
||||
top: map.getPixelFromCoordinate(hoveredNote.getGeometry().getCoordinates())[1] + 'px',
|
||||
left: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[0] + 'px',
|
||||
top: map.getPixelFromCoordinate(hoveredFeature.getGeometry().getCoordinates())[1] + 'px',
|
||||
}"
|
||||
>
|
||||
<div class="font-bold flex items-center gap-1 mb-1 text-amber-500">
|
||||
<MaterialDesignIcon icon-name="note-text" class="size-4" />
|
||||
<span>Note</span>
|
||||
<span>{{
|
||||
hoveredFeature.get("telemetry") ? hoveredFeature.get("peer")?.display_name || "Peer" : "Note"
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap break-words">
|
||||
{{ hoveredFeature.get("note") || hoveredFeature.get("telemetry")?.note }}
|
||||
</div>
|
||||
<div class="whitespace-pre-wrap break-words">{{ hoveredNote.get("note") || "Empty note" }}</div>
|
||||
</div>
|
||||
|
||||
<!-- inline note editor (overlay) -->
|
||||
@@ -266,6 +291,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- context menu -->
|
||||
<div
|
||||
v-if="showContextMenu"
|
||||
class="fixed z-[120] bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-2xl overflow-hidden text-sm text-gray-900 dark:text-zinc-100"
|
||||
:style="{ left: contextMenuPos.x + 'px', top: contextMenuPos.y + 'px' }"
|
||||
>
|
||||
<div class="px-3 py-2 font-bold border-b border-gray-100 dark:border-zinc-800">
|
||||
{{ contextMenuFeature ? "Feature actions" : "Map actions" }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<button
|
||||
v-if="contextMenuFeature"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||
@click="contextSelectFeature"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="cursor-default" class="size-4" />
|
||||
<span>Select / Move</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenuFeature"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||
@click="contextAddNote"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="note-edit" class="size-4" />
|
||||
<span>Add / Edit Note</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="contextMenuFeature && !contextMenuFeature.get('telemetry')"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-left text-red-600"
|
||||
@click="contextDeleteFeature"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete" class="size-4" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||
@click="contextCopyCoords"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="crosshairs-gps" class="size-4" />
|
||||
<span>Copy coords</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!contextMenuFeature"
|
||||
class="flex items-center gap-2 px-3 py-2 hover:bg-gray-100 dark:hover:bg-zinc-800 text-left"
|
||||
@click="contextClearMap"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
|
||||
<span>Clear drawings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- loading skeleton for map -->
|
||||
<div v-if="!isMapLoaded" class="absolute inset-0 z-0 bg-slate-100 dark:bg-zinc-900 animate-pulse">
|
||||
<div class="grid grid-cols-4 grid-rows-4 h-full w-full gap-1 p-1 opacity-20">
|
||||
@@ -550,11 +627,11 @@
|
||||
>
|
||||
<div class="flex justify-between space-x-4">
|
||||
<span class="opacity-50 uppercase tracking-tighter">Lat</span>
|
||||
<span class="text-gray-900 dark:text-zinc-100">{{ currentCenter[1].toFixed(6) }}</span>
|
||||
<span class="text-gray-900 dark:text-zinc-100">{{ displayCoords[1].toFixed(6) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between space-x-4">
|
||||
<span class="opacity-50 uppercase tracking-tighter">Lon</span>
|
||||
<span class="text-gray-900 dark:text-zinc-100">{{ currentCenter[0].toFixed(6) }}</span>
|
||||
<span class="text-gray-900 dark:text-zinc-100">{{ displayCoords[0].toFixed(6) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -710,11 +787,11 @@
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Lat:</span>
|
||||
<span class="font-mono">{{ currentCenter[1].toFixed(5) }}</span>
|
||||
<span class="font-mono">{{ displayCoords[1].toFixed(5) }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Lon:</span>
|
||||
<span class="font-mono">{{ currentCenter[0].toFixed(5) }}</span>
|
||||
<span class="font-mono">{{ displayCoords[0].toFixed(5) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -967,15 +1044,18 @@ import XYZ from "ol/source/XYZ";
|
||||
import VectorSource from "ol/source/Vector";
|
||||
import Feature from "ol/Feature";
|
||||
import Point from "ol/geom/Point";
|
||||
import { Style, Text, Fill, Stroke, Circle as CircleStyle } from "ol/style";
|
||||
import { Style, Text, Fill, Stroke, Circle as CircleStyle, Icon } from "ol/style";
|
||||
import { fromLonLat, toLonLat } from "ol/proj";
|
||||
import { defaults as defaultControls } from "ol/control";
|
||||
import DragBox from "ol/interaction/DragBox";
|
||||
import Draw from "ol/interaction/Draw";
|
||||
import Modify from "ol/interaction/Modify";
|
||||
import Snap from "ol/interaction/Snap";
|
||||
import Select from "ol/interaction/Select";
|
||||
import Translate from "ol/interaction/Translate";
|
||||
import { getArea, getLength } from "ol/sphere";
|
||||
import { LineString, Polygon } from "ol/geom";
|
||||
import { LineString, Polygon, Circle } from "ol/geom";
|
||||
import { fromCircle } from "ol/geom/Polygon";
|
||||
import { unByKey } from "ol/Observable";
|
||||
import Overlay from "ol/Overlay";
|
||||
import GeoJSON from "ol/format/GeoJSON";
|
||||
@@ -1001,6 +1081,7 @@ export default {
|
||||
isSettingsOpen: false,
|
||||
currentCenter: [0, 0],
|
||||
currentZoom: 2,
|
||||
cursorCoords: null,
|
||||
config: null,
|
||||
peers: {},
|
||||
|
||||
@@ -1059,8 +1140,8 @@ export default {
|
||||
drawType: null, // 'Point', 'LineString', 'Polygon', 'Circle' or null
|
||||
isDrawing: false,
|
||||
drawingTools: [
|
||||
{ type: "Select", icon: "cursor-default" },
|
||||
{ type: "Point", icon: "map-marker-plus" },
|
||||
{ type: "Note", icon: "note-text-outline" },
|
||||
{ type: "LineString", icon: "vector-line" },
|
||||
{ type: "Polygon", icon: "vector-polygon" },
|
||||
{ type: "Circle", icon: "circle-outline" },
|
||||
@@ -1074,6 +1155,7 @@ export default {
|
||||
helpTooltip: null,
|
||||
measureTooltipElement: null,
|
||||
measureTooltip: null,
|
||||
measurementOverlays: [],
|
||||
|
||||
// drawing storage
|
||||
savedDrawings: [],
|
||||
@@ -1081,13 +1163,22 @@ export default {
|
||||
// note editing
|
||||
editingFeature: null,
|
||||
noteText: "",
|
||||
hoveredNote: null,
|
||||
hoveredFeature: null,
|
||||
noteOverlay: null,
|
||||
showNoteModal: false,
|
||||
showSaveDrawingModal: false,
|
||||
newDrawingName: "",
|
||||
isLoadingDrawings: false,
|
||||
showLoadDrawingModal: false,
|
||||
styleCache: {},
|
||||
selectedFeature: null,
|
||||
select: null,
|
||||
translate: null,
|
||||
// context menu
|
||||
showContextMenu: false,
|
||||
contextMenuPos: { x: 0, y: 0 },
|
||||
contextMenuFeature: null,
|
||||
contextMenuCoord: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -1104,6 +1195,9 @@ export default {
|
||||
}
|
||||
return total;
|
||||
},
|
||||
displayCoords() {
|
||||
return this.cursorCoords || this.currentCenter;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
showSaveDrawingModal(val) {
|
||||
@@ -1148,7 +1242,9 @@ export default {
|
||||
dataProjection: "EPSG:4326",
|
||||
featureProjection: "EPSG:3857",
|
||||
});
|
||||
console.log("Restoring persisted drawings, count:", features.length);
|
||||
this.drawSource.addFeatures(features);
|
||||
this.rebuildMeasurementOverlays();
|
||||
} catch (e) {
|
||||
console.error("Failed to restore persisted drawings", e);
|
||||
}
|
||||
@@ -1201,6 +1297,19 @@ export default {
|
||||
}, 30000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.map && this.map.getViewport()) {
|
||||
this.map.getViewport().removeEventListener("contextmenu", this.onContextMenu);
|
||||
}
|
||||
document.removeEventListener("click", this.handleGlobalClick);
|
||||
if (this._saveStateTimer) {
|
||||
clearTimeout(this._saveStateTimer);
|
||||
this._saveStateTimer = null;
|
||||
}
|
||||
if (this._pendingSaveResolvers && this._pendingSaveResolvers.length > 0) {
|
||||
const pending = this._pendingSaveResolvers.slice();
|
||||
this._pendingSaveResolvers = [];
|
||||
this.saveMapStateImmediate().then(() => pending.forEach((p) => p.resolve()));
|
||||
}
|
||||
if (this.reloadInterval) clearInterval(this.reloadInterval);
|
||||
if (this.exportInterval) clearInterval(this.exportInterval);
|
||||
if (this.searchTimeout) clearTimeout(this.searchTimeout);
|
||||
@@ -1209,17 +1318,37 @@ export default {
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
},
|
||||
methods: {
|
||||
async saveMapState() {
|
||||
saveMapState() {
|
||||
if (!this._pendingSaveResolvers) {
|
||||
this._pendingSaveResolvers = [];
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
this._pendingSaveResolvers.push({ resolve, reject });
|
||||
if (this._saveStateTimer) clearTimeout(this._saveStateTimer);
|
||||
this._saveStateTimer = setTimeout(async () => {
|
||||
const pending = this._pendingSaveResolvers.slice();
|
||||
this._pendingSaveResolvers = [];
|
||||
this._saveStateTimer = null;
|
||||
try {
|
||||
await this.saveMapStateImmediate();
|
||||
pending.forEach((p) => p.resolve());
|
||||
} catch (e) {
|
||||
pending.forEach((p) => p.reject(e));
|
||||
}
|
||||
}, 150);
|
||||
});
|
||||
},
|
||||
async saveMapStateImmediate() {
|
||||
try {
|
||||
// Serialize drawings
|
||||
let drawings = null;
|
||||
if (this.drawSource) {
|
||||
const format = new GeoJSON();
|
||||
drawings = format.writeFeatures(this.drawSource.getFeatures());
|
||||
const features = this.serializeFeatures(this.drawSource.getFeatures());
|
||||
drawings = format.writeFeatures(features, {
|
||||
dataProjection: "EPSG:4326",
|
||||
featureProjection: "EPSG:3857",
|
||||
});
|
||||
}
|
||||
|
||||
// Use JSON.parse/stringify to strip Vue Proxies and ensure plain objects/arrays
|
||||
// This prevents DataCloneError when saving to IndexedDB
|
||||
const state = JSON.parse(
|
||||
JSON.stringify({
|
||||
center: this.currentCenter,
|
||||
@@ -1231,6 +1360,7 @@ export default {
|
||||
})
|
||||
);
|
||||
await TileCache.setMapState("last_view", state);
|
||||
console.log("Map state persisted to cache, drawings size:", drawings ? drawings.length : 0);
|
||||
} catch (e) {
|
||||
console.error("Failed to save map state", e);
|
||||
}
|
||||
@@ -1340,20 +1470,19 @@ export default {
|
||||
source: this.drawSource,
|
||||
style: (feature) => {
|
||||
const type = feature.get("type");
|
||||
if (type === "note") {
|
||||
return new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 10,
|
||||
fill: new Fill({
|
||||
color: "#f59e0b",
|
||||
}),
|
||||
stroke: new Stroke({
|
||||
color: "#ffffff",
|
||||
width: 2,
|
||||
}),
|
||||
}),
|
||||
// Use a simple circle for now as custom fonts in canvas can be tricky
|
||||
// or use the built-in Text style if we are sure it works
|
||||
const geometry = feature.getGeometry();
|
||||
const geomType = geometry ? geometry.getType() : null;
|
||||
|
||||
if (type === "note" || geomType === "Point") {
|
||||
const isNote = type === "note";
|
||||
return this.createMarkerStyle({
|
||||
iconColor: isNote ? "#f59e0b" : "#3b82f6",
|
||||
bgColor: "#ffffff",
|
||||
label: isNote && feature.get("note") ? "Note" : "",
|
||||
isStale: false,
|
||||
iconPath: isNote
|
||||
? "M14 2H6c-1.1 0-2 .9-2 2v16c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return new Style({
|
||||
@@ -1375,6 +1504,7 @@ export default {
|
||||
zIndex: 50,
|
||||
});
|
||||
this.map.addLayer(this.drawLayer);
|
||||
this.attachDrawPersistence();
|
||||
|
||||
this.noteOverlay = new Overlay({
|
||||
element: this.$refs.noteOverlayElement,
|
||||
@@ -1387,16 +1517,83 @@ export default {
|
||||
this.map.addOverlay(this.noteOverlay);
|
||||
|
||||
this.modify = new Modify({ source: this.drawSource });
|
||||
this.modify.on("modifyend", () => this.saveMapState());
|
||||
this.modify.on("modifystart", (e) => {
|
||||
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
|
||||
feats.forEach((f) => this.clearMeasurementOverlay(f));
|
||||
});
|
||||
this.modify.on("modifyend", (e) => {
|
||||
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
|
||||
feats.forEach((f) => this.finalizeMeasurementOverlay(f));
|
||||
this.saveMapState();
|
||||
});
|
||||
this.map.addInteraction(this.modify);
|
||||
|
||||
this.select = new Select({
|
||||
layers: [this.drawLayer],
|
||||
hitTolerance: 15, // High tolerance for touch/offgrid
|
||||
style: null, // Keep original feature style
|
||||
});
|
||||
this.select.on("select", (e) => {
|
||||
this.selectedFeature = e.selected[0] || null;
|
||||
});
|
||||
this.map.addInteraction(this.select);
|
||||
|
||||
this.translate = new Translate({
|
||||
features: this.select.getFeatures(),
|
||||
layers: [this.drawLayer], // Only move drawing layer items, not telemetry
|
||||
});
|
||||
this.translate.on("translateend", (e) => {
|
||||
const feats = (e.features && e.features.getArray()) || this.select.getFeatures().getArray();
|
||||
feats.forEach((f) => this.finalizeMeasurementOverlay(f));
|
||||
this.saveMapState();
|
||||
});
|
||||
this.map.addInteraction(this.translate);
|
||||
|
||||
// Default to Select tool
|
||||
this.drawType = "Select";
|
||||
this.select.setActive(true);
|
||||
this.translate.setActive(true);
|
||||
this.modify.setActive(true);
|
||||
|
||||
this.snap = new Snap({ source: this.drawSource });
|
||||
this.map.addInteraction(this.snap);
|
||||
|
||||
// Right-click context menu
|
||||
this.map.getViewport().addEventListener("contextmenu", this.onContextMenu);
|
||||
|
||||
// setup telemetry markers
|
||||
this.markerSource = new VectorSource();
|
||||
this.markerLayer = new VectorLayer({
|
||||
source: this.markerSource,
|
||||
style: (feature) => {
|
||||
const t = feature.get("telemetry");
|
||||
const peer = feature.get("peer");
|
||||
const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
|
||||
|
||||
// Calculate staleness
|
||||
const now = Date.now();
|
||||
const updatedAt = t.updated_at
|
||||
? new Date(t.updated_at).getTime()
|
||||
: t.timestamp
|
||||
? t.timestamp * 1000
|
||||
: now;
|
||||
const isStale = now - updatedAt > 10 * 60 * 1000;
|
||||
|
||||
let iconColor = "#2563eb";
|
||||
let bgColor = "#ffffff";
|
||||
|
||||
if (peer?.lxmf_user_icon) {
|
||||
iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
|
||||
bgColor = peer.lxmf_user_icon.background_colour || bgColor;
|
||||
}
|
||||
|
||||
return this.createMarkerStyle({
|
||||
iconColor,
|
||||
bgColor,
|
||||
label: displayName,
|
||||
isStale,
|
||||
});
|
||||
},
|
||||
zIndex: 100,
|
||||
});
|
||||
this.map.addLayer(this.markerLayer);
|
||||
@@ -1404,12 +1601,19 @@ export default {
|
||||
this.map.on("pointermove", this.handleMapPointerMove);
|
||||
this.map.on("click", (evt) => {
|
||||
this.handleMapClick(evt);
|
||||
this.closeContextMenu();
|
||||
const feature = this.map.forEachFeatureAtPixel(evt.pixel, (f) => f);
|
||||
if (feature && feature.get("telemetry")) {
|
||||
this.onMarkerClick(feature);
|
||||
} else {
|
||||
this.selectedMarker = null;
|
||||
}
|
||||
|
||||
// Deselect drawing if clicking empty space
|
||||
if (!feature && this.select) {
|
||||
this.select.getFeatures().clear();
|
||||
this.selectedFeature = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.currentCenter = [defaultLon, defaultLat];
|
||||
@@ -1431,6 +1635,9 @@ export default {
|
||||
|
||||
this.map.addInteraction(this.dragBox);
|
||||
this.isMapLoaded = true;
|
||||
|
||||
// Close context menu when clicking elsewhere
|
||||
document.addEventListener("click", this.handleGlobalClick);
|
||||
},
|
||||
isLocalUrl(url) {
|
||||
if (!url) return false;
|
||||
@@ -2057,10 +2264,29 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
attachDrawPersistence() {
|
||||
if (!this.drawSource) return;
|
||||
const persist = () => this.saveMapState();
|
||||
this.drawSource.on("addfeature", persist);
|
||||
this.drawSource.on("removefeature", persist);
|
||||
this.drawSource.on("changefeature", persist);
|
||||
this.drawSource.on("clear", persist);
|
||||
},
|
||||
|
||||
deleteSelectedFeature() {
|
||||
if (this.selectedFeature && this.drawSource) {
|
||||
this.clearMeasurementOverlay(this.selectedFeature);
|
||||
this.drawSource.removeFeature(this.selectedFeature);
|
||||
if (this.select) this.select.getFeatures().clear();
|
||||
this.selectedFeature = null;
|
||||
this.saveMapState();
|
||||
}
|
||||
},
|
||||
|
||||
// Drawing methods
|
||||
toggleDraw(type) {
|
||||
if (!this.map) return;
|
||||
if (this.drawType === type && !this.isMeasuring) {
|
||||
if (this.drawType === type && !this.isDrawing) {
|
||||
this.stopDrawing();
|
||||
return;
|
||||
}
|
||||
@@ -2069,27 +2295,79 @@ export default {
|
||||
this.isMeasuring = false;
|
||||
this.drawType = type;
|
||||
|
||||
if (type === "Select") {
|
||||
if (this.select) this.select.setActive(true);
|
||||
if (this.translate) this.translate.setActive(true);
|
||||
if (this.modify) this.modify.setActive(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable selection/translation while drawing
|
||||
if (this.select) this.select.setActive(false);
|
||||
if (this.translate) this.translate.setActive(false);
|
||||
if (this.modify) this.modify.setActive(false);
|
||||
|
||||
this.draw = new Draw({
|
||||
source: this.drawSource,
|
||||
type: type === "Note" ? "Point" : type,
|
||||
type: type,
|
||||
});
|
||||
|
||||
this.draw.on("drawstart", () => {
|
||||
this.draw.on("drawstart", (evt) => {
|
||||
this.isDrawing = true;
|
||||
this.sketch = evt.feature;
|
||||
|
||||
// For LineString, Polygon, and Circle, show measure tooltip while drawing
|
||||
if (type === "LineString" || type === "Polygon" || type === "Circle") {
|
||||
this.createMeasureTooltip();
|
||||
this._drawListener = this.sketch.getGeometry().on("change", (e) => {
|
||||
const geom = e.target;
|
||||
let output;
|
||||
let tooltipCoord;
|
||||
if (geom instanceof Polygon) {
|
||||
output = this.formatArea(geom);
|
||||
tooltipCoord = geom.getInteriorPoint().getCoordinates();
|
||||
} else if (geom instanceof LineString) {
|
||||
output = this.formatLength(geom);
|
||||
tooltipCoord = geom.getLastCoordinate();
|
||||
} else if (geom instanceof Circle) {
|
||||
const radius = geom.getRadius();
|
||||
const center = geom.getCenter();
|
||||
// Calculate radius distance in projection (sphere-aware)
|
||||
const edge = [center[0] + radius, center[1]];
|
||||
const line = new LineString([center, edge]);
|
||||
output = `Radius: ${this.formatLength(line)}`;
|
||||
tooltipCoord = edge;
|
||||
}
|
||||
if (output) {
|
||||
this.measureTooltipElement.innerHTML = output;
|
||||
this.measureTooltip.setPosition(tooltipCoord);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
this.draw.on("drawend", (evt) => {
|
||||
this.isDrawing = false;
|
||||
const feature = evt.feature;
|
||||
if (type === "Note") {
|
||||
feature.set("type", "note");
|
||||
feature.set("note", "");
|
||||
// Open edit box after a short delay to let the feature settle
|
||||
setTimeout(() => {
|
||||
this.startEditingNote(feature);
|
||||
}, 200);
|
||||
feature.set("type", "draw"); // Tag as custom drawing for styling
|
||||
|
||||
// Clean up sketch listener and tooltips unless it was the Measure tool
|
||||
if (this._drawListener) {
|
||||
unByKey(this._drawListener);
|
||||
this._drawListener = null;
|
||||
}
|
||||
// Use setTimeout to ensure the feature is actually in the source before saving
|
||||
this.sketch = null;
|
||||
|
||||
// Finalize measurement overlay for the drawn feature
|
||||
this.finalizeMeasurementOverlay(feature);
|
||||
this.cleanupMeasureTooltip();
|
||||
|
||||
// Re-enable select/translate/modify after drawing
|
||||
if (this.select) this.select.setActive(true);
|
||||
if (this.translate) this.translate.setActive(true);
|
||||
if (this.modify) this.modify.setActive(true);
|
||||
this.drawType = "Select";
|
||||
|
||||
setTimeout(() => this.saveMapState(), 100);
|
||||
});
|
||||
|
||||
@@ -2098,7 +2376,8 @@ export default {
|
||||
|
||||
startEditingNote(feature) {
|
||||
this.editingFeature = feature;
|
||||
this.noteText = feature.get("note") || "";
|
||||
const telemetry = feature.get("telemetry");
|
||||
this.noteText = telemetry ? telemetry.note || "" : feature.get("note") || "";
|
||||
if (this.isMobileScreen) {
|
||||
this.showNoteModal = true;
|
||||
} else {
|
||||
@@ -2109,13 +2388,29 @@ export default {
|
||||
updateNoteOverlay() {
|
||||
if (!this.editingFeature || !this.map) return;
|
||||
const geometry = this.editingFeature.getGeometry();
|
||||
const coord = geometry.getCoordinates();
|
||||
let coord;
|
||||
if (geometry instanceof Point) {
|
||||
coord = geometry.getCoordinates();
|
||||
} else if (geometry instanceof LineString) {
|
||||
coord = geometry.getCoordinateAt(0.5); // Middle of line
|
||||
} else if (geometry instanceof Polygon) {
|
||||
coord = geometry.getInteriorPoint().getCoordinates();
|
||||
} else if (geometry instanceof Circle) {
|
||||
coord = geometry.getCenter();
|
||||
} else {
|
||||
coord = this.map.getView().getCenter();
|
||||
}
|
||||
this.noteOverlay.setPosition(coord);
|
||||
},
|
||||
|
||||
saveNote() {
|
||||
if (this.editingFeature) {
|
||||
this.editingFeature.set("note", this.noteText);
|
||||
const telemetry = this.editingFeature.get("telemetry");
|
||||
if (telemetry) {
|
||||
telemetry.note = this.noteText;
|
||||
} else {
|
||||
this.editingFeature.set("note", this.noteText);
|
||||
}
|
||||
this.saveMapState();
|
||||
}
|
||||
this.closeNoteEditor();
|
||||
@@ -2144,19 +2439,190 @@ export default {
|
||||
this.closeNoteEditor();
|
||||
},
|
||||
|
||||
// Measurement helpers
|
||||
cleanupMeasureTooltip() {
|
||||
if (this.measureTooltipElement && this.measureTooltipElement.parentNode) {
|
||||
this.measureTooltipElement.parentNode.removeChild(this.measureTooltipElement);
|
||||
}
|
||||
if (this.measureTooltip) {
|
||||
this.map.removeOverlay(this.measureTooltip);
|
||||
}
|
||||
this.measureTooltipElement = null;
|
||||
this.measureTooltip = null;
|
||||
},
|
||||
getMeasurementForGeometry(geom) {
|
||||
if (geom instanceof Polygon) {
|
||||
return {
|
||||
text: this.formatArea(geom),
|
||||
coord: geom.getInteriorPoint().getCoordinates(),
|
||||
};
|
||||
}
|
||||
if (geom instanceof LineString) {
|
||||
return {
|
||||
text: this.formatLength(geom),
|
||||
coord: geom.getLastCoordinate(),
|
||||
};
|
||||
}
|
||||
if (geom instanceof Circle) {
|
||||
const center = geom.getCenter();
|
||||
const edge = [center[0] + geom.getRadius(), center[1]];
|
||||
const line = new LineString([center, edge]);
|
||||
return {
|
||||
text: `Radius: ${this.formatLength(line)}`,
|
||||
coord: edge,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
clearMeasurementOverlay(feature) {
|
||||
const overlay = feature.get("_measureOverlay");
|
||||
if (overlay) {
|
||||
this.map.removeOverlay(overlay);
|
||||
feature.unset("_measureOverlay", true);
|
||||
}
|
||||
},
|
||||
finalizeMeasurementOverlay(feature) {
|
||||
if (!this.map) return;
|
||||
this.clearMeasurementOverlay(feature);
|
||||
const geom = feature.getGeometry();
|
||||
const measurement = this.getMeasurementForGeometry(geom);
|
||||
if (!measurement) return;
|
||||
const el = document.createElement("div");
|
||||
el.className = "ol-tooltip ol-tooltip-static";
|
||||
el.innerHTML = measurement.text;
|
||||
const overlay = new Overlay({
|
||||
element: el,
|
||||
offset: [0, -7],
|
||||
positioning: "bottom-center",
|
||||
});
|
||||
overlay.set("isMeasureTooltip", true);
|
||||
this.map.addOverlay(overlay);
|
||||
overlay.setPosition(measurement.coord);
|
||||
feature.set("_measureOverlay", overlay);
|
||||
},
|
||||
rebuildMeasurementOverlays() {
|
||||
if (!this.drawSource || !this.map) return;
|
||||
// Remove all existing measure overlays
|
||||
const overlays = this.map.getOverlays().getArray();
|
||||
for (let i = overlays.length - 1; i >= 0; i--) {
|
||||
const ov = overlays[i];
|
||||
if (ov.get && ov.get("isMeasureTooltip")) {
|
||||
this.map.removeOverlay(ov);
|
||||
}
|
||||
}
|
||||
// Rebuild for all features
|
||||
this.drawSource.getFeatures().forEach((f) => {
|
||||
f.unset("_measureOverlay", true);
|
||||
this.finalizeMeasurementOverlay(f);
|
||||
});
|
||||
},
|
||||
serializeFeatures(features) {
|
||||
return features.map((f) => {
|
||||
const clone = f.clone();
|
||||
clone.unset("_measureOverlay", true); // avoid circular refs
|
||||
const geom = clone.getGeometry();
|
||||
if (geom instanceof Circle) {
|
||||
clone.setGeometry(fromCircle(geom, 128));
|
||||
}
|
||||
return clone;
|
||||
});
|
||||
},
|
||||
// Context menu handlers
|
||||
onContextMenu(evt) {
|
||||
if (!this.map) return;
|
||||
evt.preventDefault();
|
||||
const pixel = this.map.getEventPixel(evt);
|
||||
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
|
||||
this.contextMenuFeature = feature || null;
|
||||
this.contextMenuCoord = toLonLat(this.map.getCoordinateFromPixel(pixel));
|
||||
this.contextMenuPos = { x: evt.clientX, y: evt.clientY };
|
||||
if (feature && this.select) {
|
||||
this.select.getFeatures().clear();
|
||||
this.select.getFeatures().push(feature);
|
||||
this.selectedFeature = feature;
|
||||
}
|
||||
this.showContextMenu = true;
|
||||
},
|
||||
closeContextMenu() {
|
||||
this.showContextMenu = false;
|
||||
},
|
||||
contextSelectFeature() {
|
||||
if (!this.contextMenuFeature || !this.select || !this.translate) {
|
||||
this.closeContextMenu();
|
||||
return;
|
||||
}
|
||||
this.select.setActive(true);
|
||||
this.translate.setActive(true);
|
||||
this.modify?.setActive(true);
|
||||
this.select.getFeatures().clear();
|
||||
this.select.getFeatures().push(this.contextMenuFeature);
|
||||
this.selectedFeature = this.contextMenuFeature;
|
||||
this.drawType = "Select";
|
||||
this.closeContextMenu();
|
||||
},
|
||||
contextDeleteFeature() {
|
||||
if (this.contextMenuFeature && !this.contextMenuFeature.get("telemetry")) {
|
||||
this.drawSource.removeFeature(this.contextMenuFeature);
|
||||
this.saveMapState();
|
||||
}
|
||||
this.closeContextMenu();
|
||||
},
|
||||
contextAddNote() {
|
||||
if (this.contextMenuFeature) {
|
||||
this.startEditingNote(this.contextMenuFeature);
|
||||
}
|
||||
this.closeContextMenu();
|
||||
},
|
||||
async contextCopyCoords() {
|
||||
if (!this.contextMenuCoord) {
|
||||
this.closeContextMenu();
|
||||
return;
|
||||
}
|
||||
const [lon, lat] = this.contextMenuCoord;
|
||||
const text = `${lat.toFixed(6)}, ${lon.toFixed(6)}`;
|
||||
try {
|
||||
if (navigator?.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
ToastUtils.success("Copied coordinates");
|
||||
} else {
|
||||
ToastUtils.success(text);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Copy failed", e);
|
||||
ToastUtils.warning(text);
|
||||
}
|
||||
this.closeContextMenu();
|
||||
},
|
||||
contextClearMap() {
|
||||
this.clearDrawings();
|
||||
this.closeContextMenu();
|
||||
},
|
||||
// Clear all overlays on escape/context close
|
||||
handleGlobalClick() {
|
||||
if (this.showContextMenu) {
|
||||
this.closeContextMenu();
|
||||
}
|
||||
},
|
||||
|
||||
handleMapPointerMove(evt) {
|
||||
if (!this.map) return;
|
||||
const lonLat = toLonLat(evt.coordinate);
|
||||
this.cursorCoords = [lonLat[0], lonLat[1]];
|
||||
if (evt.dragging || this.isDrawing || this.isMeasuring) return;
|
||||
|
||||
const pixel = this.map.getEventPixel(evt.originalEvent);
|
||||
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f, {
|
||||
layerFilter: (l) => l === this.drawLayer,
|
||||
});
|
||||
const feature = this.map.forEachFeatureAtPixel(pixel, (f) => f);
|
||||
|
||||
if (feature && feature.get("type") === "note") {
|
||||
this.hoveredNote = feature;
|
||||
if (feature) {
|
||||
const hasNote = feature.get("note") || (feature.get("telemetry") && feature.get("telemetry").note);
|
||||
if (hasNote) {
|
||||
this.hoveredFeature = feature;
|
||||
} else {
|
||||
this.hoveredFeature = null;
|
||||
}
|
||||
this.map.getTargetElement().style.cursor = "pointer";
|
||||
} else {
|
||||
this.hoveredNote = null;
|
||||
this.hoveredFeature = null;
|
||||
this.map.getTargetElement().style.cursor = "";
|
||||
}
|
||||
},
|
||||
@@ -2181,6 +2647,9 @@ export default {
|
||||
this.map.removeInteraction(this.draw);
|
||||
this.draw = null;
|
||||
}
|
||||
if (this.select) this.select.setActive(true);
|
||||
if (this.translate) this.translate.setActive(true);
|
||||
if (this.modify) this.modify.setActive(true);
|
||||
this.drawType = null;
|
||||
this.isDrawing = false;
|
||||
this.stopMeasuring();
|
||||
@@ -2402,8 +2871,11 @@ export default {
|
||||
}
|
||||
|
||||
const format = new GeoJSON();
|
||||
const features = this.drawSource.getFeatures();
|
||||
const json = format.writeFeatures(features);
|
||||
const features = this.serializeFeatures(this.drawSource.getFeatures());
|
||||
const json = format.writeFeatures(features, {
|
||||
dataProjection: "EPSG:4326",
|
||||
featureProjection: "EPSG:3857",
|
||||
});
|
||||
|
||||
try {
|
||||
await window.axios.post("/api/v1/map/drawings", {
|
||||
@@ -2426,6 +2898,7 @@ export default {
|
||||
});
|
||||
this.drawSource.clear();
|
||||
this.drawSource.addFeatures(features);
|
||||
await this.saveMapState();
|
||||
this.showLoadDrawingModal = false;
|
||||
ToastUtils.success(`Loaded "${drawing.name}"`);
|
||||
},
|
||||
@@ -2493,44 +2966,47 @@ export default {
|
||||
const loc = t.telemetry?.location;
|
||||
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
|
||||
|
||||
const peer = this.peers[t.destination_hash];
|
||||
const displayName = peer?.display_name || t.destination_hash.substring(0, 8);
|
||||
|
||||
const feature = new Feature({
|
||||
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
|
||||
telemetry: t,
|
||||
peer: peer,
|
||||
peer: this.peers[t.destination_hash],
|
||||
});
|
||||
|
||||
// Default style
|
||||
let iconColor = "#3b82f6";
|
||||
let bgColor = "#ffffff";
|
||||
|
||||
if (peer?.lxmf_user_icon) {
|
||||
iconColor = peer.lxmf_user_icon.foreground_colour || iconColor;
|
||||
bgColor = peer.lxmf_user_icon.background_colour || bgColor;
|
||||
}
|
||||
|
||||
feature.setStyle(
|
||||
new Style({
|
||||
image: new CircleStyle({
|
||||
radius: 8,
|
||||
fill: new Fill({ color: bgColor }),
|
||||
stroke: new Stroke({ color: iconColor, width: 2 }),
|
||||
}),
|
||||
text: new Text({
|
||||
text: displayName,
|
||||
offsetY: -15,
|
||||
font: "bold 11px sans-serif",
|
||||
fill: new Fill({ color: "#000" }),
|
||||
stroke: new Stroke({ color: "#fff", width: 2 }),
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
this.markerSource.addFeature(feature);
|
||||
}
|
||||
},
|
||||
createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
|
||||
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
|
||||
if (this.styleCache[cacheKey]) return this.styleCache[cacheKey];
|
||||
|
||||
const markerFill = isStale ? "#d1d5db" : bgColor;
|
||||
const markerStroke = isStale ? "#9ca3af" : iconColor;
|
||||
const path =
|
||||
iconPath ||
|
||||
"M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7Zm0 11a2 2 0 1 1 0-4 2 2 0 0 1 0 4Z";
|
||||
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="${path}" fill="${markerFill}" stroke="${markerStroke}" stroke-width="1.5"/></svg>`;
|
||||
const src = "data:image/svg+xml;base64," + btoa(unescape(encodeURIComponent(svg)));
|
||||
|
||||
const style = new Style({
|
||||
image: new Icon({
|
||||
src: src,
|
||||
anchor: [0.5, 1],
|
||||
scale: 1.6, // Reduced from 2.5
|
||||
imgSize: [24, 24],
|
||||
}),
|
||||
text: new Text({
|
||||
text: label,
|
||||
offsetY: -45, // Adjusted from -60
|
||||
font: "bold 12px sans-serif",
|
||||
fill: new Fill({ color: isStale ? "#6b7280" : "#111827" }),
|
||||
stroke: new Stroke({ color: "#ffffff", width: 3 }),
|
||||
}),
|
||||
});
|
||||
|
||||
this.styleCache[cacheKey] = style;
|
||||
return style;
|
||||
},
|
||||
onMarkerClick(feature) {
|
||||
this.selectedMarker = {
|
||||
telemetry: feature.get("telemetry"),
|
||||
|
||||
@@ -180,6 +180,18 @@
|
||||
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate">
|
||||
{{ contact.remote_identity_hash }}
|
||||
</div>
|
||||
<div
|
||||
v-if="contact.lxmf_address"
|
||||
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
|
||||
>
|
||||
LXMF: {{ contact.lxmf_address }}
|
||||
</div>
|
||||
<div
|
||||
v-if="contact.lxst_address"
|
||||
class="text-[9px] text-gray-400 dark:text-zinc-500 font-mono truncate"
|
||||
>
|
||||
LXST: {{ contact.lxst_address }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -264,11 +276,17 @@
|
||||
<span class="text-sm font-bold">Contact Shared</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="size-10 flex items-center justify-center rounded-full bg-blue-100 dark:bg-blue-800 text-blue-600 dark:text-blue-200 font-bold"
|
||||
>
|
||||
{{ getParsedItems(chatItem).contact.name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<LxmfUserIcon
|
||||
:custom-image="getParsedItems(chatItem).contact.custom_image"
|
||||
:icon-name="getParsedItems(chatItem).contact.lxmf_user_icon?.icon_name"
|
||||
:icon-foreground-colour="
|
||||
getParsedItems(chatItem).contact.lxmf_user_icon?.foreground_colour
|
||||
"
|
||||
:icon-background-colour="
|
||||
getParsedItems(chatItem).contact.lxmf_user_icon?.background_colour
|
||||
"
|
||||
icon-class="size-10"
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-bold text-gray-900 dark:text-white truncate">
|
||||
{{ getParsedItems(chatItem).contact.name }}
|
||||
@@ -278,6 +296,18 @@
|
||||
>
|
||||
{{ getParsedItems(chatItem).contact.hash }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getParsedItems(chatItem).contact.lxmf_address"
|
||||
class="text-[9px] font-mono text-gray-400 dark:text-zinc-500 truncate"
|
||||
>
|
||||
LXMF: {{ getParsedItems(chatItem).contact.lxmf_address }}
|
||||
</div>
|
||||
<div
|
||||
v-if="getParsedItems(chatItem).contact.lxst_address"
|
||||
class="text-[9px] font-mono text-gray-400 dark:text-zinc-500 truncate"
|
||||
>
|
||||
LXST: {{ getParsedItems(chatItem).contact.lxst_address }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -286,7 +316,9 @@
|
||||
@click="
|
||||
addContact(
|
||||
getParsedItems(chatItem).contact.name,
|
||||
getParsedItems(chatItem).contact.hash
|
||||
getParsedItems(chatItem).contact.hash,
|
||||
getParsedItems(chatItem).contact.lxmf_address,
|
||||
getParsedItems(chatItem).contact.lxst_address
|
||||
)
|
||||
"
|
||||
>
|
||||
@@ -1858,12 +1890,30 @@ export default {
|
||||
paperMessage: null,
|
||||
};
|
||||
|
||||
// Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9>
|
||||
const contactMatch = content.match(/^Contact:\s+(.+?)\s+<([a-fA-F0-9]{32})>$/i);
|
||||
// Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9> [LXMF: ...] [LXST: ...]
|
||||
const contactMatch = content.match(
|
||||
/^Contact:\s+(.+?)\s+<([a-fA-F0-9]{32})>(?:\s+\[LXMF:\s+([a-fA-F0-9]{32})\])?(?:\s+\[LXST:\s+([a-fA-F0-9]{32})\])?/i
|
||||
);
|
||||
if (contactMatch) {
|
||||
const contactHash = contactMatch[2];
|
||||
const lxmfAddress = contactMatch[3];
|
||||
const lxstAddress = contactMatch[4];
|
||||
|
||||
// try to find enriched info from existing conversations/peers
|
||||
const existing = this.conversations.find(
|
||||
(c) =>
|
||||
c.destination_hash === contactHash ||
|
||||
c.destination_hash === lxmfAddress ||
|
||||
c.destination_hash === lxstAddress
|
||||
);
|
||||
|
||||
items.contact = {
|
||||
name: contactMatch[1],
|
||||
hash: contactMatch[2],
|
||||
hash: contactHash,
|
||||
lxmf_address: lxmfAddress,
|
||||
lxst_address: lxstAddress,
|
||||
custom_image: existing?.contact_image,
|
||||
lxmf_user_icon: existing?.lxmf_user_icon,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1881,7 +1931,7 @@ export default {
|
||||
|
||||
return items;
|
||||
},
|
||||
async addContact(name, hash) {
|
||||
async addContact(name, hash, lxmf_address = null, lxst_address = null) {
|
||||
try {
|
||||
// Check if contact already exists
|
||||
const checkResponse = await window.axios.get(`/api/v1/telephone/contacts/check/${hash}`);
|
||||
@@ -1893,6 +1943,8 @@ export default {
|
||||
await window.axios.post("/api/v1/telephone/contacts", {
|
||||
name: name,
|
||||
remote_identity_hash: hash,
|
||||
lxmf_address: lxmf_address,
|
||||
lxst_address: lxst_address,
|
||||
});
|
||||
ToastUtils.success(`Added ${name} to contacts`);
|
||||
} catch (e) {
|
||||
@@ -2539,10 +2591,13 @@ export default {
|
||||
ToastUtils.error("Failed to load contacts");
|
||||
}
|
||||
},
|
||||
async shareContact(contact) {
|
||||
this.newMessageText = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
|
||||
shareContact(contact) {
|
||||
let sharedString = `Contact: ${contact.name} <${contact.remote_identity_hash}>`;
|
||||
if (contact.lxmf_address) sharedString += ` [LXMF: ${contact.lxmf_address}]`;
|
||||
if (contact.lxst_address) sharedString += ` [LXST: ${contact.lxst_address}]`;
|
||||
this.newMessageText = sharedString;
|
||||
this.isShareContactModalOpen = false;
|
||||
await this.sendMessage();
|
||||
this.sendMessage();
|
||||
},
|
||||
shareAsPaperMessage(chatItem) {
|
||||
this.paperMessageHash = chatItem.lxmf_message.hash;
|
||||
|
||||
@@ -192,6 +192,7 @@ export default {
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
|
||||
GlobalEmitter.off("refresh-conversations", this.requestConversationsRefresh);
|
||||
},
|
||||
mounted() {
|
||||
// listen for websocket messages
|
||||
@@ -371,6 +372,20 @@ export default {
|
||||
this.conversations = newConversations;
|
||||
}
|
||||
|
||||
for (const conversation of newConversations) {
|
||||
if (!conversation?.destination_hash) continue;
|
||||
const existingPeer = this.peers[conversation.destination_hash] || {};
|
||||
this.peers[conversation.destination_hash] = {
|
||||
...existingPeer,
|
||||
destination_hash: conversation.destination_hash,
|
||||
display_name: conversation.display_name ?? existingPeer.display_name,
|
||||
custom_display_name: conversation.custom_display_name ?? existingPeer.custom_display_name,
|
||||
contact_image: conversation.contact_image ?? existingPeer.contact_image,
|
||||
lxmf_user_icon: conversation.lxmf_user_icon ?? existingPeer.lxmf_user_icon,
|
||||
updated_at: conversation.updated_at ?? existingPeer.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
this.hasLoadedConversations = true;
|
||||
this.hasMoreConversations = newConversations.length === this.pageSize;
|
||||
} catch (e) {
|
||||
@@ -402,7 +417,8 @@ export default {
|
||||
return params;
|
||||
},
|
||||
updatePeerFromAnnounce: function (announce) {
|
||||
this.peers[announce.destination_hash] = announce;
|
||||
const existing = this.peers[announce.destination_hash] || {};
|
||||
this.peers[announce.destination_hash] = { ...existing, ...announce };
|
||||
},
|
||||
onPeerClick: function (peer) {
|
||||
// update selected peer
|
||||
|
||||
@@ -267,6 +267,7 @@
|
||||
|
||||
<div class="my-auto mr-2">
|
||||
<LxmfUserIcon
|
||||
:custom-image="peer.contact_image"
|
||||
:icon-name="peer.lxmf_user_icon?.icon_name"
|
||||
:icon-foreground-colour="peer.lxmf_user_icon?.foreground_colour"
|
||||
:icon-background-colour="peer.lxmf_user_icon?.background_colour"
|
||||
@@ -457,6 +458,9 @@ export default {
|
||||
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
|
||||
});
|
||||
},
|
||||
hasUnreadConversations() {
|
||||
return this.conversations.some((c) => c.is_unread);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isBlocked(destinationHash) {
|
||||
|
||||
@@ -212,6 +212,8 @@ import LxmfUserIcon from "../LxmfUserIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||
|
||||
export default {
|
||||
name: "ProfileIconPage",
|
||||
@@ -324,6 +326,8 @@ export default {
|
||||
try {
|
||||
const response = await window.axios.patch("/api/v1/config", config);
|
||||
this.config = response.data.config;
|
||||
GlobalState.config = response.data.config;
|
||||
GlobalEmitter.emit("config-updated", response.data.config);
|
||||
this.saveOriginalValues();
|
||||
|
||||
if (!silent) {
|
||||
|
||||
326
meshchatx/src/frontend/components/tools/BotsPage.vue
Normal file
326
meshchatx/src/frontend/components/tools/BotsPage.vue
Normal file
@@ -0,0 +1,326 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto w-full px-4 md:px-8 py-6">
|
||||
<div class="space-y-4 w-full max-w-4xl mx-auto">
|
||||
<div class="glass-card space-y-5">
|
||||
<div class="space-y-2">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("bots.bot_framework") }}
|
||||
</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("bots.title") }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $t("bots.description") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Create New Bot -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("bots.create_new_bot") }}
|
||||
</h3>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="glass-card !p-4 hover:border-blue-400 transition cursor-pointer flex flex-col justify-between"
|
||||
@click="selectTemplate(template)"
|
||||
>
|
||||
<div>
|
||||
<div class="font-bold text-gray-900 dark:text-white">{{ template.name }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ template.description }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="primary-chip w-full mt-4 py-2"
|
||||
@click.stop="selectTemplate(template)"
|
||||
>
|
||||
{{ $t("bots.select") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- More bots coming soon -->
|
||||
<div
|
||||
class="glass-card !p-4 border-dashed border-2 border-gray-200 dark:border-zinc-800 flex flex-col items-center justify-center opacity-60"
|
||||
>
|
||||
<div class="p-2 bg-gray-100 dark:bg-zinc-800 rounded-lg mb-2">
|
||||
<MaterialDesignIcon
|
||||
icon-name="plus"
|
||||
class="size-6 text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400 text-center">
|
||||
{{ $t("bots.more_bots_coming") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Bots -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("bots.saved_bots") }}
|
||||
</h3>
|
||||
<div v-if="bots.length === 0" class="text-sm text-gray-500 italic">
|
||||
{{ $t("bots.no_bots_running") }}
|
||||
</div>
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="bot in bots"
|
||||
:key="bot.id"
|
||||
class="glass-card !p-4 flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
|
||||
<MaterialDesignIcon
|
||||
icon-name="robot"
|
||||
class="size-6 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-bold text-gray-900 dark:text-white">{{ bot.name }}</div>
|
||||
<div class="text-xs font-mono text-gray-500">
|
||||
{{ runningMap[bot.id]?.address || "Not running" }}
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-400">
|
||||
{{ bot.template_id || bot.template }}
|
||||
</div>
|
||||
<div v-if="bot.storage_dir" class="text-[10px] text-gray-400">
|
||||
{{ bot.storage_dir }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<template v-if="runningMap[bot.id]">
|
||||
<button
|
||||
class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
:title="$t('bots.stop_bot')"
|
||||
@click="stopBot(bot.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="stop" class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors"
|
||||
:title="$t('bots.restart_bot')"
|
||||
@click="restartExisting(bot)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="refresh" class="size-5" />
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button class="primary-chip px-3 py-1 text-xs" @click="startExisting(bot)">
|
||||
{{ $t("bots.start_bot") }}
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="p-2 text-gray-500 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
:title="$t('bots.export_identity')"
|
||||
@click="exportIdentity(bot.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="export" class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
|
||||
:title="$t('bots.delete_bot')"
|
||||
@click="deleteBot(bot.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="delete" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Start Bot Modal -->
|
||||
<div
|
||||
v-if="selectedTemplate"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
@click.self="selectedTemplate = null"
|
||||
>
|
||||
<div class="glass-card max-w-md w-full space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("bots.start_bot") }}: {{ selectedTemplate.name }}
|
||||
</h3>
|
||||
<button
|
||||
class="p-1 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg"
|
||||
@click="selectedTemplate = null"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="glass-label">{{ $t("bots.bot_name") }}</label>
|
||||
<input
|
||||
v-model="newBotName"
|
||||
type="text"
|
||||
:placeholder="selectedTemplate.name"
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{{ selectedTemplate.description }}
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-end pt-2">
|
||||
<button class="secondary-chip px-6 py-2" @click="selectedTemplate = null">
|
||||
{{ $t("bots.cancel") }}
|
||||
</button>
|
||||
<button class="primary-chip px-6 py-2" :disabled="isStarting" @click="startBot">
|
||||
<span
|
||||
v-if="isStarting"
|
||||
class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"
|
||||
></span>
|
||||
{{ $t("bots.start_bot") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: "BotsPage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
bots: [],
|
||||
templates: [],
|
||||
runningBots: [],
|
||||
selectedTemplate: null,
|
||||
newBotName: "",
|
||||
isStarting: false,
|
||||
loading: true,
|
||||
refreshInterval: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
runningMap() {
|
||||
const map = {};
|
||||
this.runningBots.forEach((b) => {
|
||||
map[b.id] = b;
|
||||
});
|
||||
return map;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.getStatus();
|
||||
this.refreshInterval = setInterval(this.getStatus, 5000);
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async getStatus() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/bots/status");
|
||||
this.bots = response.data.status.bots || [];
|
||||
this.runningBots = response.data.status.running_bots;
|
||||
this.templates = response.data.templates;
|
||||
this.loading = false;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
selectTemplate(template) {
|
||||
this.selectedTemplate = template;
|
||||
this.newBotName = template.name;
|
||||
},
|
||||
async startBot() {
|
||||
if (this.isStarting) return;
|
||||
this.isStarting = true;
|
||||
try {
|
||||
await window.axios.post("/api/v1/bots/start", {
|
||||
template_id: this.selectedTemplate.id,
|
||||
name: this.newBotName,
|
||||
});
|
||||
ToastUtils.success(this.$t("bots.bot_started"));
|
||||
this.selectedTemplate = null;
|
||||
this.getStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
|
||||
} finally {
|
||||
this.isStarting = false;
|
||||
}
|
||||
},
|
||||
async stopBot(botId) {
|
||||
try {
|
||||
await window.axios.post("/api/v1/bots/stop", { bot_id: botId });
|
||||
ToastUtils.success(this.$t("bots.bot_stopped"));
|
||||
this.getStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("bots.failed_to_stop"));
|
||||
}
|
||||
},
|
||||
async startExisting(bot) {
|
||||
try {
|
||||
await window.axios.post("/api/v1/bots/start", {
|
||||
bot_id: bot.id,
|
||||
template_id: bot.template_id,
|
||||
name: bot.name,
|
||||
});
|
||||
ToastUtils.success(this.$t("bots.bot_started"));
|
||||
this.getStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
|
||||
}
|
||||
},
|
||||
async restartExisting(bot) {
|
||||
try {
|
||||
await window.axios.post("/api/v1/bots/restart", { bot_id: bot.id });
|
||||
ToastUtils.success(this.$t("bots.bot_started"));
|
||||
this.getStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(e.response?.data?.message || this.$t("bots.failed_to_start"));
|
||||
}
|
||||
},
|
||||
async deleteBot(botId) {
|
||||
if (!confirm(this.$t("common.delete_confirm"))) return;
|
||||
try {
|
||||
await window.axios.post("/api/v1/bots/delete", { bot_id: botId });
|
||||
ToastUtils.success(this.$t("bots.bot_deleted"));
|
||||
this.getStatus();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("bots.failed_to_delete"));
|
||||
}
|
||||
},
|
||||
exportIdentity(botId) {
|
||||
window.open(`/api/v1/bots/export?bot_id=${botId}`, "_blank");
|
||||
},
|
||||
copyToClipboard(text) {
|
||||
navigator.clipboard.writeText(text);
|
||||
ToastUtils.success(this.$t("translator.copied_to_clipboard"));
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glass-label {
|
||||
@apply block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-1;
|
||||
}
|
||||
</style>
|
||||
@@ -3,7 +3,7 @@
|
||||
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto w-full">
|
||||
<div class="space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
|
||||
<div class="space-y-4 p-4 md:p-6 lg:p-8 w-full">
|
||||
<div class="glass-card space-y-3">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("tools.utilities") }}
|
||||
@@ -16,184 +16,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
|
||||
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
|
||||
<MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.ping.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.ping.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
<div class="glass-card">
|
||||
<div class="relative">
|
||||
<MaterialDesignIcon
|
||||
icon-name="magnify"
|
||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('common.search')"
|
||||
class="w-full pl-10 pr-4 py-3 bg-white/50 dark:bg-zinc-900/50 border border-gray-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterLink :to="{ name: 'rnprobe' }" class="tool-card glass-card">
|
||||
<div
|
||||
class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
|
||||
<div class="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<RouterLink
|
||||
v-for="tool in filteredTools"
|
||||
:key="tool.name"
|
||||
:to="tool.route"
|
||||
:class="['tool-card', 'glass-card', tool.customClass].filter(Boolean)"
|
||||
>
|
||||
<div :class="tool.iconBg">
|
||||
<MaterialDesignIcon v-if="tool.icon" :icon-name="tool.icon" class="w-6 h-6" />
|
||||
<img
|
||||
v-else-if="tool.image"
|
||||
:src="tool.image"
|
||||
:class="tool.imageClass"
|
||||
:alt="tool.imageAlt"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.rnprobe.title") }}</div>
|
||||
<div class="tool-card__title">{{ tool.title }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.rnprobe.description") }}
|
||||
{{ tool.description }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'rncp' }" class="tool-card glass-card">
|
||||
<div
|
||||
class="tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="swap-horizontal" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.rncp.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.rncp.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'rnstatus' }" class="tool-card glass-card">
|
||||
<div
|
||||
class="tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="chart-line" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.rnstatus.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.rnstatus.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'rnpath' }" class="tool-card glass-card">
|
||||
<div
|
||||
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="route" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.rnpath.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.rnpath.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'translator' }" class="tool-card glass-card">
|
||||
<div
|
||||
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="translate" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.translator.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.translator.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'forwarder' }" class="tool-card glass-card">
|
||||
<div class="tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200">
|
||||
<MaterialDesignIcon icon-name="email-send-outline" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.forwarder.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.forwarder.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'documentation' }" class="tool-card glass-card">
|
||||
<div class="tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200">
|
||||
<MaterialDesignIcon icon-name="book-open-variant" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("docs.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("docs.subtitle") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'micron-editor' }" class="tool-card glass-card">
|
||||
<div class="tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200">
|
||||
<MaterialDesignIcon icon-name="code-tags" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.micron_editor.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.micron_editor.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'paper-message' }" class="tool-card glass-card">
|
||||
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
|
||||
<MaterialDesignIcon icon-name="qrcode" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.paper_message.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.paper_message.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink :to="{ name: 'rnode-flasher' }" class="tool-card glass-card">
|
||||
<div
|
||||
class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
|
||||
>
|
||||
<img :src="rnodeLogoPath" class="w-8 h-8 rounded-full" alt="RNode" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ $t("tools.rnode_flasher.title") }}</div>
|
||||
<div class="tool-card__description">
|
||||
{{ $t("tools.rnode_flasher.description") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div v-if="tool.extraAction" class="flex items-center gap-2">
|
||||
<a
|
||||
href="/rnode-flasher/index.html"
|
||||
target="_blank"
|
||||
:href="tool.extraAction.href"
|
||||
:target="tool.extraAction.target"
|
||||
class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
|
||||
@click.stop
|
||||
>
|
||||
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
|
||||
<MaterialDesignIcon :icon-name="tool.extraAction.icon" class="size-5" />
|
||||
</a>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</div>
|
||||
<MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<RouterLink :to="{ name: 'debug-logs' }" class="tool-card glass-card border-dashed border-2">
|
||||
<div class="tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
|
||||
<MaterialDesignIcon icon-name="console" class="w-6 h-6" />
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">Debug Logs</div>
|
||||
<div class="tool-card__description">
|
||||
View and export internal system logs for troubleshooting.
|
||||
</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
<div v-if="filteredTools.length === 0" class="glass-card text-center py-12">
|
||||
<MaterialDesignIcon icon-name="magnify" class="w-12 h-12 mx-auto mb-4 text-gray-400" />
|
||||
<div class="text-gray-600 dark:text-gray-400">{{ $t("common.no_results") }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -210,8 +87,148 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
rnodeLogoPath: "/rnode-flasher/reticulum_logo_512.png",
|
||||
searchQuery: "",
|
||||
tools: [
|
||||
{
|
||||
name: "ping",
|
||||
route: { name: "ping" },
|
||||
icon: "radar",
|
||||
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
|
||||
titleKey: "tools.ping.title",
|
||||
descriptionKey: "tools.ping.description",
|
||||
},
|
||||
{
|
||||
name: "rnprobe",
|
||||
route: { name: "rnprobe" },
|
||||
icon: "radar",
|
||||
iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
|
||||
titleKey: "tools.rnprobe.title",
|
||||
descriptionKey: "tools.rnprobe.description",
|
||||
},
|
||||
{
|
||||
name: "rncp",
|
||||
route: { name: "rncp" },
|
||||
icon: "swap-horizontal",
|
||||
iconBg: "tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200",
|
||||
titleKey: "tools.rncp.title",
|
||||
descriptionKey: "tools.rncp.description",
|
||||
},
|
||||
{
|
||||
name: "rnstatus",
|
||||
route: { name: "rnstatus" },
|
||||
icon: "chart-line",
|
||||
iconBg: "tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200",
|
||||
titleKey: "tools.rnstatus.title",
|
||||
descriptionKey: "tools.rnstatus.description",
|
||||
},
|
||||
{
|
||||
name: "rnpath",
|
||||
route: { name: "rnpath" },
|
||||
icon: "route",
|
||||
iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
|
||||
titleKey: "tools.rnpath.title",
|
||||
descriptionKey: "tools.rnpath.description",
|
||||
},
|
||||
{
|
||||
name: "translator",
|
||||
route: { name: "translator" },
|
||||
icon: "translate",
|
||||
iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
|
||||
titleKey: "tools.translator.title",
|
||||
descriptionKey: "tools.translator.description",
|
||||
},
|
||||
{
|
||||
name: "bots",
|
||||
route: { name: "bots" },
|
||||
icon: "robot",
|
||||
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
|
||||
titleKey: "tools.bots.title",
|
||||
descriptionKey: "tools.bots.description",
|
||||
},
|
||||
{
|
||||
name: "forwarder",
|
||||
route: { name: "forwarder" },
|
||||
icon: "email-send-outline",
|
||||
iconBg: "tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200",
|
||||
titleKey: "tools.forwarder.title",
|
||||
descriptionKey: "tools.forwarder.description",
|
||||
},
|
||||
{
|
||||
name: "documentation",
|
||||
route: { name: "documentation" },
|
||||
icon: "book-open-variant",
|
||||
iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
|
||||
titleKey: "docs.title",
|
||||
descriptionKey: "docs.subtitle",
|
||||
},
|
||||
{
|
||||
name: "micron-editor",
|
||||
route: { name: "micron-editor" },
|
||||
icon: "code-tags",
|
||||
iconBg: "tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200",
|
||||
titleKey: "tools.micron_editor.title",
|
||||
descriptionKey: "tools.micron_editor.description",
|
||||
},
|
||||
{
|
||||
name: "paper-message",
|
||||
route: { name: "paper-message" },
|
||||
icon: "qrcode",
|
||||
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
|
||||
titleKey: "tools.paper_message.title",
|
||||
descriptionKey: "tools.paper_message.description",
|
||||
},
|
||||
{
|
||||
name: "rnode-flasher",
|
||||
route: { name: "rnode-flasher" },
|
||||
icon: null,
|
||||
image: "/rnode-flasher/reticulum_logo_512.png",
|
||||
imageClass: "w-8 h-8 rounded-full",
|
||||
imageAlt: "RNode",
|
||||
iconBg: "tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200",
|
||||
titleKey: "tools.rnode_flasher.title",
|
||||
descriptionKey: "tools.rnode_flasher.description",
|
||||
extraAction: {
|
||||
href: "/rnode-flasher/index.html",
|
||||
target: "_blank",
|
||||
icon: "open-in-new",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "debug-logs",
|
||||
route: { name: "debug-logs" },
|
||||
icon: "console",
|
||||
iconBg: "tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400",
|
||||
titleKey: null,
|
||||
title: "Debug Logs",
|
||||
descriptionKey: null,
|
||||
description: "View and export internal system logs for troubleshooting.",
|
||||
customClass: "border-dashed border-2",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
filteredTools() {
|
||||
const toolsWithTranslations = this.tools.map((tool) => ({
|
||||
...tool,
|
||||
title: tool.title || (tool.titleKey ? this.$t(tool.titleKey) : ""),
|
||||
description: tool.description || (tool.descriptionKey ? this.$t(tool.descriptionKey) : ""),
|
||||
}));
|
||||
|
||||
if (!this.searchQuery.trim()) {
|
||||
return toolsWithTranslations;
|
||||
}
|
||||
|
||||
const query = this.searchQuery.toLowerCase().trim();
|
||||
return toolsWithTranslations.filter((tool) => {
|
||||
return (
|
||||
tool.title.toLowerCase().includes(query) ||
|
||||
tool.description.toLowerCase().includes(query) ||
|
||||
tool.name.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
{
|
||||
"bots": {
|
||||
"title": "LXMFy-Bots",
|
||||
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy.",
|
||||
"bot_framework": "Bot-Framework",
|
||||
"lxmfy_not_detected": "LXMFy nicht erkannt",
|
||||
"lxmfy_not_detected_desc": "Um Bots zu verwenden, müssen Sie das LXMFy-Paket installieren:",
|
||||
"install_via_pip": "Über pip installieren",
|
||||
"create_new_bot": "Neuen Bot erstellen",
|
||||
"running_bots": "Laufende Bots",
|
||||
"no_bots_running": "Derzeit laufen keine Bots.",
|
||||
"select": "Auswählen",
|
||||
"start_bot": "Bot starten",
|
||||
"stop_bot": "Bot stoppen",
|
||||
"restart_bot": "Bot neu starten",
|
||||
"saved_bots": "Gespeicherte Bots",
|
||||
"bot_name": "Bot-Name",
|
||||
"cancel": "Abbrechen",
|
||||
"bot_started": "Bot erfolgreich gestartet",
|
||||
"bot_stopped": "Bot gestoppt",
|
||||
"failed_to_start": "Bot konnte nicht gestartet werden",
|
||||
"failed_to_stop": "Bot konnte nicht gestoppt werden",
|
||||
"delete_bot": "Bot löschen",
|
||||
"export_identity": "Identität exportieren",
|
||||
"bot_deleted": "Bot erfolgreich gelöscht",
|
||||
"failed_to_delete": "Bot konnte nicht gelöscht werden",
|
||||
"more_bots_coming": "Weitere Bots folgen in Kürze!"
|
||||
},
|
||||
"app": {
|
||||
"name": "Reticulum MeshChatX",
|
||||
"sync_messages": "Nachrichten synchronisieren",
|
||||
@@ -183,7 +210,9 @@
|
||||
"shutdown": "Ausschalten",
|
||||
"acknowledge_reset": "Bestätigen & Zurücksetzen",
|
||||
"confirm": "Bestätigen",
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden."
|
||||
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden.",
|
||||
"search": "Werkzeuge suchen...",
|
||||
"no_results": "Keine Werkzeuge gefunden"
|
||||
},
|
||||
"identities": {
|
||||
"title": "Identitäten",
|
||||
@@ -665,6 +694,10 @@
|
||||
"paper_message": {
|
||||
"title": "Papiernachricht",
|
||||
"description": "Erstellen und lesen Sie LXMF-signierte Papiernachrichten über QR-Codes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy-Bots",
|
||||
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy."
|
||||
}
|
||||
},
|
||||
"ping": {
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
{
|
||||
"bots": {
|
||||
"title": "LXMFy Bots",
|
||||
"description": "Manage automated bots for echo, notes, and reminders using LXMFy.",
|
||||
"bot_framework": "Bot Framework",
|
||||
"lxmfy_not_detected": "LXMFy not detected",
|
||||
"lxmfy_not_detected_desc": "To use bots, you must install the LXMFy package:",
|
||||
"install_via_pip": "Install via pip",
|
||||
"create_new_bot": "Create New Bot",
|
||||
"running_bots": "Running Bots",
|
||||
"no_bots_running": "No bots are currently running.",
|
||||
"select": "Select",
|
||||
"start_bot": "Start Bot",
|
||||
"stop_bot": "Stop Bot",
|
||||
"restart_bot": "Restart Bot",
|
||||
"saved_bots": "Saved Bots",
|
||||
"bot_name": "Bot Name",
|
||||
"cancel": "Cancel",
|
||||
"bot_started": "Bot started successfully",
|
||||
"bot_stopped": "Bot stopped",
|
||||
"failed_to_start": "Failed to start bot",
|
||||
"failed_to_stop": "Failed to stop bot",
|
||||
"delete_bot": "Delete Bot",
|
||||
"export_identity": "Export Identity",
|
||||
"bot_deleted": "Bot deleted successfully",
|
||||
"failed_to_delete": "Failed to delete bot",
|
||||
"more_bots_coming": "More bots coming soon!"
|
||||
},
|
||||
"app": {
|
||||
"name": "Reticulum MeshChatX",
|
||||
"sync_messages": "Sync Messages",
|
||||
@@ -183,7 +210,9 @@
|
||||
"shutdown": "Shutdown",
|
||||
"acknowledge_reset": "Acknowledge & Reset",
|
||||
"confirm": "Confirm",
|
||||
"delete_confirm": "Are you sure you want to delete this? This cannot be undone."
|
||||
"delete_confirm": "Are you sure you want to delete this? This cannot be undone.",
|
||||
"search": "Search tools...",
|
||||
"no_results": "No tools found"
|
||||
},
|
||||
"identities": {
|
||||
"title": "Identities",
|
||||
@@ -665,6 +694,10 @@
|
||||
"paper_message": {
|
||||
"title": "Paper Message",
|
||||
"description": "Generate and read LXMF signed paper messages via QR codes."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy Bots",
|
||||
"description": "Manage automated bots for echo, notes, and reminders using LXMFy."
|
||||
}
|
||||
},
|
||||
"ping": {
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
{
|
||||
"bots": {
|
||||
"title": "Боты LXMFy",
|
||||
"description": "Управление автоматическими ботами для эха, заметок и напоминаний с помощью LXMFy.",
|
||||
"bot_framework": "Фреймворк ботов",
|
||||
"lxmfy_not_detected": "LXMFy не обнаружен",
|
||||
"lxmfy_not_detected_desc": "Для использования ботов необходимо установить пакет LXMFy:",
|
||||
"install_via_pip": "Установить через pip",
|
||||
"create_new_bot": "Создать нового бота",
|
||||
"running_bots": "Запущенные боты",
|
||||
"no_bots_running": "В данный момент нет запущенных ботов.",
|
||||
"select": "Выбрать",
|
||||
"start_bot": "Запустить бота",
|
||||
"stop_bot": "Остановить бота",
|
||||
"restart_bot": "Перезапустить бота",
|
||||
"saved_bots": "Сохраненные боты",
|
||||
"bot_name": "Имя бота",
|
||||
"cancel": "Отмена",
|
||||
"bot_started": "Бот успешно запущен",
|
||||
"bot_stopped": "Бот остановлен",
|
||||
"failed_to_start": "Не удалось запустить бота",
|
||||
"failed_to_stop": "Не удалось остановить бота",
|
||||
"delete_bot": "Удалить бота",
|
||||
"export_identity": "Экспорт личности",
|
||||
"bot_deleted": "Бот успешно удален",
|
||||
"failed_to_delete": "Не удалось удалить бота",
|
||||
"more_bots_coming": "Скоро появятся новые боты!"
|
||||
},
|
||||
"app": {
|
||||
"name": "Reticulum MeshChatX",
|
||||
"sync_messages": "Синхронизировать сообщения",
|
||||
@@ -183,7 +210,9 @@
|
||||
"shutdown": "Выключить",
|
||||
"acknowledge_reset": "Подтвердить и сбросить",
|
||||
"confirm": "Подтвердить",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить."
|
||||
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
|
||||
"search": "Поиск инструментов...",
|
||||
"no_results": "Инструменты не найдены"
|
||||
},
|
||||
"identities": {
|
||||
"title": "Личности",
|
||||
@@ -665,6 +694,10 @@
|
||||
"paper_message": {
|
||||
"title": "Бумажное сообщение",
|
||||
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy Боты",
|
||||
"description": "Управление автоматизированными ботами для эха, заметок и напоминаний с помощью LXMFy."
|
||||
}
|
||||
},
|
||||
"ping": {
|
||||
|
||||
@@ -189,6 +189,11 @@ const router = createRouter({
|
||||
path: "/translator",
|
||||
component: defineAsyncComponent(() => import("./components/translator/TranslatorPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "bots",
|
||||
path: "/bots",
|
||||
component: defineAsyncComponent(() => import("./components/tools/BotsPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "forwarder",
|
||||
path: "/forwarder",
|
||||
|
||||
Reference in New Issue
Block a user