interface discovery, folders for messages, map nodes from discovery, maintenance tools.
This commit is contained in:
@@ -42,6 +42,7 @@ from cryptography.hazmat.backends import default_backend
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
from RNS.Discovery import InterfaceDiscovery
|
||||
from serial.tools import list_ports
|
||||
|
||||
from meshchatx.src.backend.async_utils import AsyncUtils
|
||||
@@ -2152,8 +2153,37 @@ class ReticulumMeshChat:
|
||||
@routes.get("/api/v1/database/snapshots")
|
||||
async def list_db_snapshots(request):
|
||||
try:
|
||||
limit = int(request.query.get("limit", 100))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
snapshots = self.database.list_snapshots(self.storage_dir)
|
||||
return web.json_response(snapshots)
|
||||
total = len(snapshots)
|
||||
paginated_snapshots = snapshots[offset : offset + limit]
|
||||
return web.json_response(
|
||||
{
|
||||
"snapshots": paginated_snapshots,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
{"status": "error", "message": str(e)},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@routes.delete("/api/v1/database/snapshots/{filename}")
|
||||
async def delete_db_snapshot(request):
|
||||
try:
|
||||
filename = request.match_info.get("filename")
|
||||
if not filename.endswith(".zip"):
|
||||
filename += ".zip"
|
||||
self.database.delete_snapshot_or_backup(
|
||||
self.storage_dir,
|
||||
filename,
|
||||
is_backup=False,
|
||||
)
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
{"status": "error", "message": str(e)},
|
||||
@@ -2199,9 +2229,13 @@ class ReticulumMeshChat:
|
||||
@routes.get("/api/v1/database/backups")
|
||||
async def list_db_backups(request):
|
||||
try:
|
||||
limit = int(request.query.get("limit", 100))
|
||||
offset = int(request.query.get("offset", 0))
|
||||
backup_dir = os.path.join(self.storage_dir, "database-backups")
|
||||
if not os.path.exists(backup_dir):
|
||||
return web.json_response([])
|
||||
return web.json_response(
|
||||
{"backups": [], "total": 0, "limit": limit, "offset": offset},
|
||||
)
|
||||
|
||||
backups = []
|
||||
for file in os.listdir(backup_dir):
|
||||
@@ -2219,9 +2253,39 @@ class ReticulumMeshChat:
|
||||
).isoformat(),
|
||||
},
|
||||
)
|
||||
return web.json_response(
|
||||
sorted(backups, key=lambda x: x["created_at"], reverse=True),
|
||||
sorted_backups = sorted(
|
||||
backups,
|
||||
key=lambda x: x["created_at"],
|
||||
reverse=True,
|
||||
)
|
||||
total = len(sorted_backups)
|
||||
paginated_backups = sorted_backups[offset : offset + limit]
|
||||
return web.json_response(
|
||||
{
|
||||
"backups": paginated_backups,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
{"status": "error", "message": str(e)},
|
||||
status=500,
|
||||
)
|
||||
|
||||
@routes.delete("/api/v1/database/backups/{filename}")
|
||||
async def delete_db_backup(request):
|
||||
try:
|
||||
filename = request.match_info.get("filename")
|
||||
if not filename.endswith(".zip"):
|
||||
filename += ".zip"
|
||||
self.database.delete_snapshot_or_backup(
|
||||
self.storage_dir,
|
||||
filename,
|
||||
is_backup=True,
|
||||
)
|
||||
return web.json_response({"status": "success"})
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
{"status": "error", "message": str(e)},
|
||||
@@ -3360,6 +3424,7 @@ class ReticulumMeshChat:
|
||||
),
|
||||
"ply": self.get_package_version("ply"),
|
||||
"bcrypt": self.get_package_version("bcrypt"),
|
||||
"lxmfy": self.get_package_version("lxmfy"),
|
||||
},
|
||||
"storage_path": self.storage_path,
|
||||
"database_path": self.database_path,
|
||||
@@ -3939,6 +4004,62 @@ class ReticulumMeshChat:
|
||||
status=500,
|
||||
)
|
||||
|
||||
# maintenance - clear messages
|
||||
@routes.delete("/api/v1/maintenance/messages")
|
||||
async def maintenance_clear_messages(request):
|
||||
self.database.messages.delete_all_lxmf_messages()
|
||||
return web.json_response({"message": "All messages cleared"})
|
||||
|
||||
# maintenance - clear announces
|
||||
@routes.delete("/api/v1/maintenance/announces")
|
||||
async def maintenance_clear_announces(request):
|
||||
aspect = request.query.get("aspect")
|
||||
self.database.announces.delete_all_announces(aspect=aspect)
|
||||
return web.json_response(
|
||||
{
|
||||
"message": f"Announces cleared{' for aspect ' + aspect if aspect else ''}",
|
||||
},
|
||||
)
|
||||
|
||||
# maintenance - clear favorites
|
||||
@routes.delete("/api/v1/maintenance/favourites")
|
||||
async def maintenance_clear_favourites(request):
|
||||
aspect = request.query.get("aspect")
|
||||
self.database.announces.delete_all_favourites(aspect=aspect)
|
||||
return web.json_response(
|
||||
{
|
||||
"message": f"Favourites cleared{' for aspect ' + aspect if aspect else ''}",
|
||||
},
|
||||
)
|
||||
|
||||
# maintenance - clear archives
|
||||
@routes.delete("/api/v1/maintenance/archives")
|
||||
async def maintenance_clear_archives(request):
|
||||
self.database.misc.delete_archived_pages()
|
||||
return web.json_response({"message": "All archived pages cleared"})
|
||||
|
||||
# maintenance - export messages
|
||||
@routes.get("/api/v1/maintenance/messages/export")
|
||||
async def maintenance_export_messages(request):
|
||||
messages = self.database.messages.get_all_lxmf_messages()
|
||||
# Convert sqlite3.Row to dict if necessary
|
||||
messages_list = [dict(m) for m in messages]
|
||||
return web.json_response({"messages": messages_list})
|
||||
|
||||
# maintenance - import messages
|
||||
@routes.post("/api/v1/maintenance/messages/import")
|
||||
async def maintenance_import_messages(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
messages = data.get("messages", [])
|
||||
for msg in messages:
|
||||
self.database.messages.upsert_lxmf_message(msg)
|
||||
return web.json_response(
|
||||
{"message": f"Successfully imported {len(messages)} messages"},
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response({"error": str(e)}, status=400)
|
||||
|
||||
# get config
|
||||
@routes.get("/api/v1/config")
|
||||
async def config_get(request):
|
||||
@@ -4043,6 +4164,91 @@ class ReticulumMeshChat:
|
||||
|
||||
return web.json_response({"discovery": discovery_config})
|
||||
|
||||
@routes.get("/api/v1/reticulum/discovered-interfaces")
|
||||
async def reticulum_discovered_interfaces(request):
|
||||
try:
|
||||
discovery = InterfaceDiscovery(discover_interfaces=False)
|
||||
interfaces = discovery.list_discovered_interfaces()
|
||||
active = []
|
||||
try:
|
||||
if hasattr(self, "reticulum") and self.reticulum:
|
||||
stats = self.reticulum.get_interface_stats().get(
|
||||
"interfaces",
|
||||
[],
|
||||
)
|
||||
active = []
|
||||
for s in stats:
|
||||
name = s.get("name") or ""
|
||||
parsed_host = None
|
||||
parsed_port = None
|
||||
if "/" in name:
|
||||
try:
|
||||
host_port = name.split("/")[-1].strip("[]")
|
||||
if ":" in host_port:
|
||||
parsed_host, parsed_port = host_port.rsplit(
|
||||
":",
|
||||
1,
|
||||
)
|
||||
try:
|
||||
parsed_port = int(parsed_port)
|
||||
except Exception:
|
||||
parsed_port = None
|
||||
else:
|
||||
parsed_host = host_port
|
||||
except Exception:
|
||||
parsed_host = None
|
||||
parsed_port = None
|
||||
|
||||
host = (
|
||||
s.get("target_host") or s.get("remote") or parsed_host
|
||||
)
|
||||
port = (
|
||||
s.get("target_port")
|
||||
or s.get("listen_port")
|
||||
or parsed_port
|
||||
)
|
||||
transport_id = s.get("transport_id")
|
||||
if isinstance(transport_id, (bytes, bytearray)):
|
||||
transport_id = transport_id.hex()
|
||||
|
||||
active.append(
|
||||
{
|
||||
"name": name,
|
||||
"short_name": s.get("short_name"),
|
||||
"type": s.get("type"),
|
||||
"target_host": host,
|
||||
"target_port": port,
|
||||
"listen_ip": s.get("listen_ip"),
|
||||
"connected": s.get("connected"),
|
||||
"online": s.get("online"),
|
||||
"transport_id": transport_id,
|
||||
"network_id": s.get("network_id"),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get interface stats: {e}")
|
||||
|
||||
def to_jsonable(obj):
|
||||
if isinstance(obj, bytes):
|
||||
return obj.hex()
|
||||
if isinstance(obj, dict):
|
||||
return {k: to_jsonable(v) for k, v in obj.items()}
|
||||
if isinstance(obj, list):
|
||||
return [to_jsonable(v) for v in obj]
|
||||
return obj
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"interfaces": to_jsonable(interfaces),
|
||||
"active": to_jsonable(active),
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
return web.json_response(
|
||||
{"message": f"Failed to load discovered interfaces: {e!s}"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
# enable transport mode
|
||||
@routes.post("/api/v1/reticulum/enable-transport")
|
||||
async def reticulum_enable_transport(request):
|
||||
@@ -6920,6 +7126,12 @@ class ReticulumMeshChat:
|
||||
request.query.get("filter_has_attachments", "false"),
|
||||
),
|
||||
)
|
||||
folder_id = request.query.get("folder_id")
|
||||
if folder_id is not None:
|
||||
try:
|
||||
folder_id = int(folder_id)
|
||||
except ValueError:
|
||||
folder_id = None
|
||||
|
||||
# get pagination params
|
||||
try:
|
||||
@@ -6943,6 +7155,7 @@ class ReticulumMeshChat:
|
||||
filter_unread=filter_unread,
|
||||
filter_failed=filter_failed,
|
||||
filter_has_attachments=filter_has_attachments,
|
||||
folder_id=folder_id,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
@@ -7021,6 +7234,123 @@ class ReticulumMeshChat:
|
||||
},
|
||||
)
|
||||
|
||||
@routes.get("/api/v1/lxmf/folders")
|
||||
async def lxmf_folders_get(request):
|
||||
folders = self.database.messages.get_all_folders()
|
||||
return web.json_response([dict(f) for f in folders])
|
||||
|
||||
@routes.post("/api/v1/lxmf/folders")
|
||||
async def lxmf_folders_post(request):
|
||||
data = await request.json()
|
||||
name = data.get("name")
|
||||
if not name:
|
||||
return web.json_response({"message": "Name is required"}, status=400)
|
||||
try:
|
||||
self.database.messages.create_folder(name)
|
||||
return web.json_response({"message": "Folder created"})
|
||||
except Exception as e:
|
||||
return web.json_response({"message": str(e)}, status=500)
|
||||
|
||||
@routes.patch("/api/v1/lxmf/folders/{id}")
|
||||
async def lxmf_folders_patch(request):
|
||||
folder_id = int(request.match_info["id"])
|
||||
data = await request.json()
|
||||
name = data.get("name")
|
||||
if not name:
|
||||
return web.json_response({"message": "Name is required"}, status=400)
|
||||
self.database.messages.rename_folder(folder_id, name)
|
||||
return web.json_response({"message": "Folder renamed"})
|
||||
|
||||
@routes.delete("/api/v1/lxmf/folders/{id}")
|
||||
async def lxmf_folders_delete(request):
|
||||
folder_id = int(request.match_info["id"])
|
||||
self.database.messages.delete_folder(folder_id)
|
||||
return web.json_response({"message": "Folder deleted"})
|
||||
|
||||
@routes.post("/api/v1/lxmf/conversations/move-to-folder")
|
||||
async def lxmf_conversations_move_to_folder(request):
|
||||
data = await request.json()
|
||||
peer_hashes = data.get("peer_hashes", [])
|
||||
folder_id = data.get("folder_id") # Can be None to remove from folder
|
||||
if not peer_hashes:
|
||||
return web.json_response(
|
||||
{"message": "peer_hashes is required"},
|
||||
status=400,
|
||||
)
|
||||
self.database.messages.move_conversations_to_folder(peer_hashes, folder_id)
|
||||
return web.json_response({"message": "Conversations moved"})
|
||||
|
||||
@routes.post("/api/v1/lxmf/conversations/bulk-mark-as-read")
|
||||
async def lxmf_conversations_bulk_mark_read(request):
|
||||
data = await request.json()
|
||||
destination_hashes = data.get("destination_hashes", [])
|
||||
if not destination_hashes:
|
||||
return web.json_response(
|
||||
{"message": "destination_hashes is required"},
|
||||
status=400,
|
||||
)
|
||||
self.database.messages.mark_conversations_as_read(destination_hashes)
|
||||
return web.json_response({"message": "Conversations marked as read"})
|
||||
|
||||
@routes.post("/api/v1/lxmf/conversations/bulk-delete")
|
||||
async def lxmf_conversations_bulk_delete(request):
|
||||
data = await request.json()
|
||||
destination_hashes = data.get("destination_hashes", [])
|
||||
if not destination_hashes:
|
||||
return web.json_response(
|
||||
{"message": "destination_hashes is required"},
|
||||
status=400,
|
||||
)
|
||||
local_hash = self.local_lxmf_destination.hexhash
|
||||
for dest_hash in destination_hashes:
|
||||
self.message_handler.delete_conversation(local_hash, dest_hash)
|
||||
return web.json_response({"message": "Conversations deleted"})
|
||||
|
||||
@routes.get("/api/v1/lxmf/folders/export")
|
||||
async def lxmf_folders_export(request):
|
||||
folders = [dict(f) for f in self.database.messages.get_all_folders()]
|
||||
mappings = [
|
||||
dict(m) for m in self.database.messages.get_all_conversation_folders()
|
||||
]
|
||||
return web.json_response({"folders": folders, "mappings": mappings})
|
||||
|
||||
@routes.post("/api/v1/lxmf/folders/import")
|
||||
async def lxmf_folders_import(request):
|
||||
data = await request.json()
|
||||
folders = data.get("folders", [])
|
||||
mappings = data.get("mappings", [])
|
||||
|
||||
# We'll try to recreate folders by name to avoid ID conflicts
|
||||
folder_name_to_new_id = {}
|
||||
for f in folders:
|
||||
try:
|
||||
self.database.messages.create_folder(f["name"])
|
||||
except Exception as e:
|
||||
logger.debug(f"Folder '{f['name']}' likely already exists: {e}")
|
||||
|
||||
# Refresh folder list to get new IDs
|
||||
all_folders = self.database.messages.get_all_folders()
|
||||
for f in all_folders:
|
||||
folder_name_to_new_id[f["name"]] = f["id"]
|
||||
|
||||
# Map old IDs to new IDs if possible, or just use names if we had them
|
||||
# Since IDs might change, we should have exported names too
|
||||
# Let's assume the export had folder names in mappings or we match by old folder info
|
||||
old_id_to_name = {f["id"]: f["name"] for f in folders}
|
||||
|
||||
for m in mappings:
|
||||
peer_hash = m["peer_hash"]
|
||||
old_folder_id = m["folder_id"]
|
||||
folder_name = old_id_to_name.get(old_folder_id)
|
||||
if folder_name and folder_name in folder_name_to_new_id:
|
||||
new_folder_id = folder_name_to_new_id[folder_name]
|
||||
self.database.messages.move_conversation_to_folder(
|
||||
peer_hash,
|
||||
new_folder_id,
|
||||
)
|
||||
|
||||
return web.json_response({"message": "Folders and mappings imported"})
|
||||
|
||||
# mark lxmf conversation as read
|
||||
@routes.get("/api/v1/lxmf/conversations/{destination_hash}/mark-as-read")
|
||||
async def lxmf_conversations_mark_read(request):
|
||||
@@ -7806,7 +8136,7 @@ class ReticulumMeshChat:
|
||||
f"connect-src {' '.join(connect_sources)}; "
|
||||
"media-src 'self' blob:; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
"frame-src 'self' https://reticulum.network; "
|
||||
"object-src 'none'; "
|
||||
"base-uri 'self';"
|
||||
)
|
||||
@@ -7922,17 +8252,24 @@ class ReticulumMeshChat:
|
||||
# (e.g. when running from a read-only AppImage)
|
||||
if self.current_context and hasattr(self.current_context, "docs_manager"):
|
||||
dm = self.current_context.docs_manager
|
||||
if (
|
||||
dm.docs_dir
|
||||
and os.path.exists(dm.docs_dir)
|
||||
and not dm.docs_dir.startswith(public_dir)
|
||||
):
|
||||
app.router.add_static(
|
||||
"/reticulum-docs/",
|
||||
dm.docs_dir,
|
||||
name="reticulum_docs_storage",
|
||||
follow_symlinks=True,
|
||||
)
|
||||
|
||||
# Custom handler for reticulum docs to allow fallback to official website
|
||||
async def reticulum_docs_handler(request):
|
||||
path = request.match_info.get("filename", "index.html")
|
||||
if not path:
|
||||
path = "index.html"
|
||||
if path.endswith("/"):
|
||||
path += "index.html"
|
||||
|
||||
local_path = os.path.join(dm.docs_dir, path)
|
||||
if os.path.exists(local_path) and os.path.isfile(local_path):
|
||||
return web.FileResponse(local_path)
|
||||
|
||||
# Fallback to official website
|
||||
return web.HTTPFound(f"https://reticulum.network/manual/{path}")
|
||||
|
||||
app.router.add_get("/reticulum-docs/{filename:.*}", reticulum_docs_handler)
|
||||
|
||||
if (
|
||||
dm.meshchatx_docs_dir
|
||||
and os.path.exists(dm.meshchatx_docs_dir)
|
||||
@@ -7978,7 +8315,8 @@ class ReticulumMeshChat:
|
||||
print(
|
||||
f"Performing scheduled auto-backup for {ctx.identity_hash}...",
|
||||
)
|
||||
ctx.database.backup_database(self.storage_dir)
|
||||
max_count = ctx.config.backup_max_count.get()
|
||||
ctx.database.backup_database(self.storage_dir, max_count=max_count)
|
||||
except Exception as e:
|
||||
print(f"Auto-backup failed: {e}")
|
||||
|
||||
|
||||
@@ -240,7 +240,9 @@ class BotHandler:
|
||||
shutil.rmtree(storage_dir)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to delete storage dir for bot %s: %s", bot_id, exc
|
||||
"Failed to delete storage dir for bot %s: %s",
|
||||
bot_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
self._save_state()
|
||||
|
||||
@@ -103,6 +103,7 @@ class ConfigManager:
|
||||
"archives_max_storage_gb",
|
||||
1,
|
||||
)
|
||||
self.backup_max_count = self.IntConfig(self, "backup_max_count", 5)
|
||||
self.crawler_enabled = self.BoolConfig(self, "crawler_enabled", False)
|
||||
self.crawler_max_retries = self.IntConfig(self, "crawler_max_retries", 3)
|
||||
self.crawler_retry_delay_seconds = self.IntConfig(
|
||||
|
||||
@@ -211,14 +211,41 @@ class Database:
|
||||
"size": os.path.getsize(backup_path),
|
||||
}
|
||||
|
||||
def backup_database(self, storage_path, backup_path: str | None = None):
|
||||
def backup_database(
|
||||
self,
|
||||
storage_path,
|
||||
backup_path: str | None = None,
|
||||
max_count: int | None = None,
|
||||
):
|
||||
default_dir = os.path.join(storage_path, "database-backups")
|
||||
os.makedirs(default_dir, exist_ok=True)
|
||||
if backup_path is None:
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||
backup_path = os.path.join(default_dir, f"backup-{timestamp}.zip")
|
||||
|
||||
return self._backup_to_zip(backup_path)
|
||||
result = self._backup_to_zip(backup_path)
|
||||
|
||||
# Cleanup old backups if a limit is set
|
||||
if max_count is not None and max_count > 0:
|
||||
try:
|
||||
backups = []
|
||||
for file in os.listdir(default_dir):
|
||||
if file.endswith(".zip"):
|
||||
full_path = os.path.join(default_dir, file)
|
||||
stats = os.stat(full_path)
|
||||
backups.append((full_path, stats.st_mtime))
|
||||
|
||||
if len(backups) > max_count:
|
||||
# Sort by modification time (oldest first)
|
||||
backups.sort(key=lambda x: x[1])
|
||||
to_delete = backups[: len(backups) - max_count]
|
||||
for path, _ in to_delete:
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
except Exception as e:
|
||||
print(f"Failed to cleanup old backups: {e}")
|
||||
|
||||
return result
|
||||
|
||||
def create_snapshot(self, storage_path, name: str):
|
||||
"""Creates a named snapshot of the database."""
|
||||
@@ -258,6 +285,29 @@ class Database:
|
||||
)
|
||||
return sorted(snapshots, key=lambda x: x["created_at"], reverse=True)
|
||||
|
||||
def delete_snapshot_or_backup(
|
||||
self,
|
||||
storage_path,
|
||||
filename: str,
|
||||
is_backup: bool = False,
|
||||
):
|
||||
"""Deletes a database snapshot or auto-backup."""
|
||||
base_dir = "database-backups" if is_backup else "snapshots"
|
||||
file_path = os.path.join(storage_path, base_dir, filename)
|
||||
|
||||
# Basic security check to ensure we stay within the intended directory
|
||||
abs_path = os.path.abspath(file_path)
|
||||
abs_base = os.path.abspath(os.path.join(storage_path, base_dir))
|
||||
|
||||
if not abs_path.startswith(abs_base):
|
||||
msg = "Invalid path"
|
||||
raise ValueError(msg)
|
||||
|
||||
if os.path.exists(abs_path):
|
||||
os.remove(abs_path)
|
||||
return True
|
||||
return False
|
||||
|
||||
def restore_database(self, backup_path: str):
|
||||
if not os.path.exists(backup_path):
|
||||
msg = f"Backup not found at {backup_path}"
|
||||
|
||||
@@ -54,6 +54,15 @@ class AnnounceDAO:
|
||||
(destination_hash,),
|
||||
)
|
||||
|
||||
def delete_all_announces(self, aspect=None):
|
||||
if aspect:
|
||||
self.provider.execute(
|
||||
"DELETE FROM announces WHERE aspect = ?",
|
||||
(aspect,),
|
||||
)
|
||||
else:
|
||||
self.provider.execute("DELETE FROM announces")
|
||||
|
||||
def get_filtered_announces(
|
||||
self,
|
||||
aspect=None,
|
||||
@@ -137,3 +146,12 @@ class AnnounceDAO:
|
||||
"DELETE FROM favourite_destinations WHERE destination_hash = ?",
|
||||
(destination_hash,),
|
||||
)
|
||||
|
||||
def delete_all_favourites(self, aspect=None):
|
||||
if aspect:
|
||||
self.provider.execute(
|
||||
"DELETE FROM favourite_destinations WHERE aspect = ?",
|
||||
(aspect,),
|
||||
)
|
||||
else:
|
||||
self.provider.execute("DELETE FROM favourite_destinations")
|
||||
|
||||
@@ -63,12 +63,28 @@ class MessageDAO:
|
||||
(message_hash,),
|
||||
)
|
||||
|
||||
def delete_lxmf_messages_by_hashes(self, message_hashes):
|
||||
if not message_hashes:
|
||||
return
|
||||
placeholders = ", ".join(["?"] * len(message_hashes))
|
||||
self.provider.execute(
|
||||
f"DELETE FROM lxmf_messages WHERE hash IN ({placeholders})",
|
||||
tuple(message_hashes),
|
||||
)
|
||||
|
||||
def delete_lxmf_message_by_hash(self, message_hash):
|
||||
self.provider.execute(
|
||||
"DELETE FROM lxmf_messages WHERE hash = ?",
|
||||
(message_hash,),
|
||||
)
|
||||
|
||||
def delete_all_lxmf_messages(self):
|
||||
self.provider.execute("DELETE FROM lxmf_messages")
|
||||
self.provider.execute("DELETE FROM lxmf_conversation_read_state")
|
||||
|
||||
def get_all_lxmf_messages(self):
|
||||
return self.provider.fetchall("SELECT * FROM lxmf_messages")
|
||||
|
||||
def get_conversation_messages(self, destination_hash, limit=100, offset=0):
|
||||
return self.provider.fetchall(
|
||||
"SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||
@@ -103,6 +119,22 @@ class MessageDAO:
|
||||
(destination_hash, now, now, now),
|
||||
)
|
||||
|
||||
def mark_conversations_as_read(self, destination_hashes):
|
||||
if not destination_hashes:
|
||||
return
|
||||
now = datetime.now(UTC).isoformat()
|
||||
for destination_hash in destination_hashes:
|
||||
self.provider.execute(
|
||||
"""
|
||||
INSERT INTO lxmf_conversation_read_state (destination_hash, last_read_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(destination_hash) DO UPDATE SET
|
||||
last_read_at = EXCLUDED.last_read_at,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""",
|
||||
(destination_hash, now, now, now),
|
||||
)
|
||||
|
||||
def is_conversation_unread(self, destination_hash):
|
||||
row = self.provider.fetchone(
|
||||
"""
|
||||
@@ -290,3 +322,56 @@ class MessageDAO:
|
||||
last_viewed_at = last_viewed_at.replace(tzinfo=UTC)
|
||||
|
||||
return message_timestamp <= last_viewed_at.timestamp()
|
||||
|
||||
# Folders
|
||||
def get_all_folders(self):
|
||||
return self.provider.fetchall("SELECT * FROM lxmf_folders ORDER BY name ASC")
|
||||
|
||||
def create_folder(self, name):
|
||||
now = datetime.now(UTC).isoformat()
|
||||
return self.provider.execute(
|
||||
"INSERT INTO lxmf_folders (name, created_at, updated_at) VALUES (?, ?, ?)",
|
||||
(name, now, now),
|
||||
)
|
||||
|
||||
def rename_folder(self, folder_id, new_name):
|
||||
now = datetime.now(UTC).isoformat()
|
||||
self.provider.execute(
|
||||
"UPDATE lxmf_folders SET name = ?, updated_at = ? WHERE id = ?",
|
||||
(new_name, now, folder_id),
|
||||
)
|
||||
|
||||
def delete_folder(self, folder_id):
|
||||
self.provider.execute("DELETE FROM lxmf_folders WHERE id = ?", (folder_id,))
|
||||
|
||||
def get_conversation_folder(self, peer_hash):
|
||||
return self.provider.fetchone(
|
||||
"SELECT * FROM lxmf_conversation_folders WHERE peer_hash = ?",
|
||||
(peer_hash,),
|
||||
)
|
||||
|
||||
def move_conversation_to_folder(self, peer_hash, folder_id):
|
||||
now = datetime.now(UTC).isoformat()
|
||||
if folder_id is None:
|
||||
self.provider.execute(
|
||||
"DELETE FROM lxmf_conversation_folders WHERE peer_hash = ?",
|
||||
(peer_hash,),
|
||||
)
|
||||
else:
|
||||
self.provider.execute(
|
||||
"""
|
||||
INSERT INTO lxmf_conversation_folders (peer_hash, folder_id, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(peer_hash) DO UPDATE SET
|
||||
folder_id = EXCLUDED.folder_id,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""",
|
||||
(peer_hash, folder_id, now, now),
|
||||
)
|
||||
|
||||
def move_conversations_to_folder(self, peer_hashes, folder_id):
|
||||
for peer_hash in peer_hashes:
|
||||
self.move_conversation_to_folder(peer_hash, folder_id)
|
||||
|
||||
def get_all_conversation_folders(self):
|
||||
return self.provider.fetchall("SELECT * FROM lxmf_conversation_folders")
|
||||
|
||||
@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
|
||||
|
||||
|
||||
class DatabaseSchema:
|
||||
LATEST_VERSION = 35
|
||||
LATEST_VERSION = 36
|
||||
|
||||
def __init__(self, provider: DatabaseProvider):
|
||||
self.provider = provider
|
||||
@@ -423,6 +423,24 @@ class DatabaseSchema:
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"lxmf_folders": """
|
||||
CREATE TABLE IF NOT EXISTS lxmf_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""",
|
||||
"lxmf_conversation_folders": """
|
||||
CREATE TABLE IF NOT EXISTS lxmf_conversation_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
peer_hash TEXT UNIQUE,
|
||||
folder_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (folder_id) REFERENCES lxmf_folders(id) ON DELETE CASCADE
|
||||
)
|
||||
""",
|
||||
}
|
||||
|
||||
for table_name, create_sql in tables.items():
|
||||
@@ -933,6 +951,32 @@ class DatabaseSchema:
|
||||
"ALTER TABLE contacts ADD COLUMN lxst_address TEXT DEFAULT NULL",
|
||||
)
|
||||
|
||||
if current_version < 36:
|
||||
self._safe_execute("""
|
||||
CREATE TABLE IF NOT EXISTS lxmf_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
self._safe_execute("""
|
||||
CREATE TABLE IF NOT EXISTS lxmf_conversation_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
peer_hash TEXT UNIQUE,
|
||||
folder_id INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (folder_id) REFERENCES lxmf_folders(id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
self._safe_execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_lxmf_conversation_folders_peer_hash ON lxmf_conversation_folders(peer_hash)",
|
||||
)
|
||||
self._safe_execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_lxmf_conversation_folders_folder_id ON lxmf_conversation_folders(folder_id)",
|
||||
)
|
||||
|
||||
# Update version in config
|
||||
self._safe_execute(
|
||||
"""
|
||||
|
||||
@@ -38,7 +38,12 @@ class DocsManager:
|
||||
|
||||
# Ensure docs directories exist
|
||||
try:
|
||||
for d in [self.docs_base_dir, self.versions_dir, self.meshchatx_docs_dir]:
|
||||
for d in [
|
||||
self.docs_base_dir,
|
||||
self.versions_dir,
|
||||
self.docs_dir,
|
||||
self.meshchatx_docs_dir,
|
||||
]:
|
||||
if not os.path.exists(d):
|
||||
os.makedirs(d)
|
||||
|
||||
@@ -423,8 +428,6 @@ class DocsManager:
|
||||
|
||||
def has_docs(self):
|
||||
# Check if index.html exists in the docs folder or if we have any versions
|
||||
if self.config.docs_downloaded.get():
|
||||
return True
|
||||
return (
|
||||
os.path.exists(os.path.join(self.docs_dir, "index.html"))
|
||||
or len(self.get_available_versions()) > 0
|
||||
|
||||
@@ -35,6 +35,11 @@ class MessageHandler:
|
||||
def delete_conversation(self, local_hash, destination_hash):
|
||||
query = "DELETE FROM lxmf_messages WHERE peer_hash = ?"
|
||||
self.db.provider.execute(query, [destination_hash])
|
||||
# Also clean up folder mapping
|
||||
self.db.provider.execute(
|
||||
"DELETE FROM lxmf_conversation_folders WHERE peer_hash = ?",
|
||||
[destination_hash],
|
||||
)
|
||||
|
||||
def search_messages(self, local_hash, search_term):
|
||||
like_term = f"%{search_term}%"
|
||||
@@ -54,6 +59,7 @@ class MessageHandler:
|
||||
filter_unread=False,
|
||||
filter_failed=False,
|
||||
filter_has_attachments=False,
|
||||
folder_id=None,
|
||||
limit=None,
|
||||
offset=0,
|
||||
):
|
||||
@@ -66,6 +72,8 @@ class MessageHandler:
|
||||
con.custom_image as contact_image,
|
||||
i.icon_name, i.foreground_colour, i.background_colour,
|
||||
r.last_read_at,
|
||||
f.id as folder_id,
|
||||
fn.name as folder_name,
|
||||
(SELECT COUNT(*) FROM lxmf_messages m_failed
|
||||
WHERE m_failed.peer_hash = m1.peer_hash AND m_failed.state = 'failed') as failed_count
|
||||
FROM lxmf_messages m1
|
||||
@@ -84,10 +92,20 @@ class MessageHandler:
|
||||
)
|
||||
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
|
||||
LEFT JOIN lxmf_conversation_folders f ON f.peer_hash = m1.peer_hash
|
||||
LEFT JOIN lxmf_folders fn ON fn.id = f.folder_id
|
||||
"""
|
||||
params = []
|
||||
where_clauses = []
|
||||
|
||||
if folder_id is not None:
|
||||
if folder_id == 0 or folder_id == "0":
|
||||
# Special case: no folder (Uncategorized)
|
||||
where_clauses.append("f.folder_id IS NULL")
|
||||
else:
|
||||
where_clauses.append("f.folder_id = ?")
|
||||
params.append(folder_id)
|
||||
|
||||
if filter_unread:
|
||||
where_clauses.append(
|
||||
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))",
|
||||
|
||||
@@ -180,7 +180,161 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-if="discoveryOption === null" class="flex flex-col items-center gap-6 py-4">
|
||||
<div
|
||||
class="bg-blue-500/10 dark:bg-blue-500/20 p-6 rounded-[2rem] text-center space-y-4 border border-blue-500/20 max-w-md"
|
||||
>
|
||||
<v-icon icon="mdi-account-search" color="blue" size="48"></v-icon>
|
||||
<div class="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{{
|
||||
$t("tutorial.discovery_question") ||
|
||||
"Do you want to use community interface discovering and auto-connect?"
|
||||
}}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-zinc-400">
|
||||
{{
|
||||
$t("tutorial.discovery_desc") ||
|
||||
"This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet."
|
||||
}}
|
||||
</p>
|
||||
<div class="flex gap-3 justify-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-bold shadow-lg transition-all"
|
||||
:disabled="savingDiscovery"
|
||||
@click="useDiscovery"
|
||||
>
|
||||
<v-progress-circular
|
||||
v-if="savingDiscovery"
|
||||
indeterminate
|
||||
size="16"
|
||||
width="2"
|
||||
class="mr-2"
|
||||
></v-progress-circular>
|
||||
{{ $t("tutorial.yes") || "Yes, use discovery" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-6 py-2 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-bold shadow-sm transition-all"
|
||||
@click="discoveryOption = 'no'"
|
||||
>
|
||||
{{ $t("tutorial.no") || "No, manual setup" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- Discovered Interfaces (if any) -->
|
||||
<div
|
||||
v-if="sortedDiscoveredInterfaces.length > 0"
|
||||
class="bg-emerald-500/5 dark:bg-emerald-500/10 rounded-3xl p-4 border border-emerald-500/20"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-3 px-1 text-sm">
|
||||
<v-icon icon="mdi-radar" color="emerald"></v-icon>
|
||||
<span class="font-bold text-gray-900 dark:text-white">Discovered Interfaces</span>
|
||||
</div>
|
||||
<div class="space-y-3 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div
|
||||
v-for="iface in sortedDiscoveredInterfaces"
|
||||
:key="iface.discovery_hash || iface.name"
|
||||
class="interface-card group !p-3 transition-all duration-300"
|
||||
>
|
||||
<div class="flex gap-3 items-start relative">
|
||||
<div class="interface-card__icon !w-10 !h-10 !rounded-xl shrink-0">
|
||||
<MaterialDesignIcon
|
||||
:icon-name="getDiscoveryIcon(iface)"
|
||||
class="w-5 h-5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 space-y-1">
|
||||
<div class="flex items-center gap-2 flex-nowrap min-w-0">
|
||||
<div
|
||||
class="text-sm font-bold text-gray-900 dark:text-white truncate min-w-0"
|
||||
>
|
||||
{{ iface.name }}
|
||||
</div>
|
||||
<span class="type-chip !text-[9px] !px-1.5 shrink-0">{{
|
||||
iface.type
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
v-if="iface.value"
|
||||
class="text-[9px] font-bold text-blue-600 dark:text-blue-400 shrink-0"
|
||||
>
|
||||
Stamps: {{ iface.value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5 text-[10px] text-gray-500">
|
||||
<span>Hops: {{ iface.hops }}</span>
|
||||
<span class="capitalize shrink-0">{{ iface.status }}</span>
|
||||
<span v-if="iface.last_heard" class="shrink-0">
|
||||
{{ formatLastHeard(iface.last_heard) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-col gap-0.5 pt-1 text-[9px] text-gray-400 dark:text-zinc-500 min-w-0"
|
||||
>
|
||||
<div
|
||||
v-if="iface.reachable_on"
|
||||
class="flex items-center gap-1.5 hover:text-blue-500 cursor-pointer min-w-0"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
`${iface.reachable_on}:${iface.port}`,
|
||||
'Address'
|
||||
)
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="link-variant"
|
||||
class="w-3 h-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate"
|
||||
>Address: {{ iface.reachable_on }}:{{ iface.port }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="iface.transport_id"
|
||||
class="flex items-center gap-1.5 hover:text-blue-500 cursor-pointer min-w-0"
|
||||
@click="copyToClipboard(iface.transport_id, 'Transport ID')"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="identifier"
|
||||
class="w-3 h-3 shrink-0"
|
||||
/>
|
||||
<span class="truncate font-mono"
|
||||
>Transport ID: {{ iface.transport_id }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="iface.network_id"
|
||||
class="flex items-center gap-1.5 hover:text-blue-500 cursor-pointer min-w-0"
|
||||
@click="copyToClipboard(iface.network_id, 'Network ID')"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="lan" class="w-3 h-3 shrink-0" />
|
||||
<span class="truncate font-mono"
|
||||
>Network ID: {{ iface.network_id }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<span
|
||||
class="text-[8px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 rounded-full font-bold uppercase tracking-wider"
|
||||
>Heard</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Community Interfaces -->
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-zinc-900 rounded-3xl p-3 border border-gray-100 dark:border-zinc-800"
|
||||
@@ -232,7 +386,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center gap-3 text-sm text-gray-900 dark:text-white">
|
||||
<div
|
||||
v-if="discoveryOption !== null"
|
||||
class="flex flex-col items-center gap-3 text-sm text-gray-900 dark:text-white"
|
||||
>
|
||||
<p class="max-w-sm text-center">
|
||||
{{ $t("tutorial.custom_interfaces_desc") }}
|
||||
</p>
|
||||
@@ -479,7 +636,7 @@
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 md:px-12 py-10">
|
||||
<div class="max-w-4xl mx-auto h-full flex flex-col justify-between">
|
||||
<div class="w-full h-full flex flex-col justify-between">
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<!-- Step 1: Welcome -->
|
||||
<div
|
||||
@@ -626,69 +783,284 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Community Interfaces -->
|
||||
<div v-if="discoveryOption === null" class="flex flex-col items-center gap-8 py-12">
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800"
|
||||
class="bg-blue-500/10 dark:bg-blue-500/20 p-12 rounded-[3rem] text-center space-y-6 border border-blue-500/20 max-w-2xl shadow-2xl"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<v-icon icon="mdi-web" color="blue" size="26"></v-icon>
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-white">{{
|
||||
$t("tutorial.suggested_relays")
|
||||
}}</span>
|
||||
<v-icon icon="mdi-account-search" color="blue" size="80"></v-icon>
|
||||
<div class="text-3xl font-black text-gray-900 dark:text-white">
|
||||
{{
|
||||
$t("tutorial.discovery_question") ||
|
||||
"Do you want to use community interface discovering and auto-connect?"
|
||||
}}
|
||||
</div>
|
||||
<div class="space-y-3 max-h-[320px] overflow-y-auto pr-3 custom-scrollbar">
|
||||
<div
|
||||
v-for="iface in communityInterfaces"
|
||||
:key="iface.name"
|
||||
class="flex items-center justify-between p-3 bg-white dark:bg-zinc-800 rounded-xl border border-gray-100 dark:border-zinc-700 hover:border-blue-400 transition-all cursor-pointer"
|
||||
@click="selectCommunityInterface(iface)"
|
||||
<p class="text-xl text-gray-600 dark:text-zinc-400">
|
||||
{{
|
||||
$t("tutorial.discovery_desc") ||
|
||||
"This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet."
|
||||
}}
|
||||
</p>
|
||||
<div class="flex gap-6 justify-center pt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="px-10 py-4 text-xl rounded-2xl bg-blue-600 hover:bg-blue-500 text-white font-black shadow-xl transition-all transform hover:scale-105"
|
||||
:disabled="savingDiscovery"
|
||||
@click="useDiscovery"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold text-gray-900 dark:text-white text-base">
|
||||
{{ iface.name }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 font-mono"
|
||||
>{{ iface.target_host }}:{{ iface.target_port }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
v-if="iface.online"
|
||||
class="flex items-center gap-1.5 text-[9px] font-bold text-green-500 uppercase tracking-[0.2em]"
|
||||
>
|
||||
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
{{ $t("tutorial.online") }}
|
||||
</span>
|
||||
<v-progress-circular
|
||||
v-if="savingDiscovery"
|
||||
indeterminate
|
||||
size="24"
|
||||
width="3"
|
||||
class="mr-3"
|
||||
></v-progress-circular>
|
||||
{{ $t("tutorial.yes") || "Yes, use discovery" }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="px-10 py-4 text-xl rounded-2xl border-2 border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-black shadow-lg transition-all transform hover:scale-105"
|
||||
@click="discoveryOption = 'no'"
|
||||
>
|
||||
{{ $t("tutorial.no") || "No, manual setup" }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<!-- Left/Middle Columns: Discovered & Community -->
|
||||
<div class="lg:col-span-1 xl:col-span-2 space-y-6">
|
||||
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
<!-- Discovered Interfaces -->
|
||||
<div
|
||||
v-if="sortedDiscoveredInterfaces.length > 0"
|
||||
class="bg-emerald-500/5 dark:bg-emerald-500/10 rounded-[1.5rem] p-5 border border-emerald-500/20 h-fit"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div class="flex items-center gap-2">
|
||||
<v-icon icon="mdi-radar" color="emerald" size="26"></v-icon>
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-white"
|
||||
>Discovered</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
v-if="interfacesWithLocation.length > 0"
|
||||
type="button"
|
||||
class="px-4 py-1 text-[11px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all"
|
||||
@click.stop="selectCommunityInterface(iface)"
|
||||
class="px-2 py-1 text-[9px] rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-bold border border-emerald-500/20 hover:bg-emerald-500/20 transition-all"
|
||||
@click="mapAllDiscovered"
|
||||
>
|
||||
{{ $t("tutorial.use") }}
|
||||
Map All ({{ interfacesWithLocation.length }})
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3 max-h-[600px] overflow-y-auto pr-3 custom-scrollbar">
|
||||
<div
|
||||
v-for="iface in sortedDiscoveredInterfaces"
|
||||
:key="iface.discovery_hash || iface.name"
|
||||
class="interface-card group !p-4 transition-all duration-300"
|
||||
>
|
||||
<div class="flex gap-4 items-start relative">
|
||||
<div class="interface-card__icon !w-12 !h-12 !rounded-2xl shrink-0">
|
||||
<MaterialDesignIcon
|
||||
:icon-name="getDiscoveryIcon(iface)"
|
||||
class="w-6 h-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<div class="flex items-center gap-2 flex-nowrap min-w-0">
|
||||
<div
|
||||
class="text-lg font-bold text-gray-900 dark:text-white truncate min-w-0"
|
||||
>
|
||||
{{ iface.name }}
|
||||
</div>
|
||||
<span class="type-chip shrink-0">{{ iface.type }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
v-if="iface.value"
|
||||
class="text-xs font-bold text-blue-600 dark:text-blue-400 shrink-0"
|
||||
>
|
||||
Stamps: {{ iface.value }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
<span class="stat-chip !px-2 !py-0.5"
|
||||
>Hops: {{ iface.hops }}</span
|
||||
>
|
||||
<span class="stat-chip !px-2 !py-0.5 capitalize shrink-0">{{
|
||||
iface.status
|
||||
}}</span>
|
||||
<span
|
||||
v-if="iface.last_heard"
|
||||
class="stat-chip !px-2 !py-0.5 shrink-0"
|
||||
>
|
||||
{{ formatLastHeard(iface.last_heard) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="grid gap-1.5 pt-1 text-[11px] text-gray-500 dark:text-zinc-400 min-w-0"
|
||||
>
|
||||
<div
|
||||
v-if="iface.reachable_on"
|
||||
class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
`${iface.reachable_on}:${iface.port}`,
|
||||
'Address'
|
||||
)
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="link-variant"
|
||||
class="w-4 h-4 shrink-0"
|
||||
/>
|
||||
<span class="truncate"
|
||||
>Address: {{ iface.reachable_on }}:{{
|
||||
iface.port
|
||||
}}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="iface.transport_id"
|
||||
class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
|
||||
@click="
|
||||
copyToClipboard(iface.transport_id, 'Transport ID')
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="identifier"
|
||||
class="w-4 h-4 shrink-0"
|
||||
/>
|
||||
<span class="truncate font-mono"
|
||||
>Transport ID: {{ iface.transport_id }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="iface.network_id"
|
||||
class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
|
||||
@click="copyToClipboard(iface.network_id, 'Network ID')"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="lan"
|
||||
class="w-4 h-4 shrink-0"
|
||||
/>
|
||||
<span class="truncate font-mono"
|
||||
>Network ID: {{ iface.network_id }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="iface.latitude != null && iface.longitude != null"
|
||||
class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
`${iface.latitude}, ${iface.longitude}`,
|
||||
'Location'
|
||||
)
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="map-marker"
|
||||
class="w-4 h-4 shrink-0"
|
||||
/>
|
||||
<span class="truncate"
|
||||
>Loc: {{ iface.latitude }},
|
||||
{{ iface.longitude }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
class="text-[10px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider"
|
||||
>Heard</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadingInterfaces" class="flex justify-center py-4">
|
||||
<v-progress-circular indeterminate color="blue" size="32"></v-progress-circular>
|
||||
|
||||
<!-- Community Interfaces -->
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800 h-fit"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-5">
|
||||
<v-icon icon="mdi-web" color="blue" size="26"></v-icon>
|
||||
<span class="text-lg font-bold text-gray-900 dark:text-white">{{
|
||||
$t("tutorial.suggested_relays")
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="space-y-3 max-h-[600px] overflow-y-auto pr-3 custom-scrollbar">
|
||||
<div
|
||||
v-for="iface in communityInterfaces"
|
||||
:key="iface.name"
|
||||
class="flex items-center justify-between p-3 bg-white dark:bg-zinc-800 rounded-xl border border-gray-100 dark:border-zinc-700 hover:border-blue-400 transition-all cursor-pointer"
|
||||
@click="selectCommunityInterface(iface)"
|
||||
>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span
|
||||
class="font-bold text-gray-900 dark:text-white text-base truncate"
|
||||
>
|
||||
{{ iface.name }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 font-mono truncate"
|
||||
>{{ iface.target_host }}:{{ iface.target_port }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span
|
||||
v-if="iface.online"
|
||||
class="flex items-center gap-1.5 text-[9px] font-bold text-green-500 uppercase tracking-[0.2em]"
|
||||
>
|
||||
<span
|
||||
class="w-2 h-2 rounded-full bg-green-500 animate-pulse"
|
||||
></span>
|
||||
{{ $t("tutorial.online") }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-1 text-[11px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all"
|
||||
@click.stop="selectCommunityInterface(iface)"
|
||||
>
|
||||
{{ $t("tutorial.use") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loadingInterfaces" class="flex justify-center py-4">
|
||||
<v-progress-circular
|
||||
indeterminate
|
||||
color="blue"
|
||||
size="32"
|
||||
></v-progress-circular>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Manual Setup -->
|
||||
<div
|
||||
class="flex flex-col justify-center gap-4 text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800"
|
||||
class="flex flex-col justify-center gap-4 text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-8 border border-gray-100 dark:border-zinc-800 h-fit my-auto"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="text-base font-bold text-gray-900 dark:text-white">
|
||||
<div class="text-center space-y-4">
|
||||
<v-icon icon="mdi-plus-circle-outline" size="48" color="blue"></v-icon>
|
||||
<p class="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{{ $t("tutorial.custom_interfaces") }}
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<p class="text-gray-600 dark:text-zinc-400">
|
||||
{{ $t("tutorial.custom_interfaces_desc_page") }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 text-[11px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
|
||||
class="mt-4 px-6 py-3 text-sm rounded-xl border-2 border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-bold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
|
||||
@click="gotoAddInterface"
|
||||
>
|
||||
{{ $t("tutorial.open_interfaces") }}
|
||||
@@ -925,6 +1297,12 @@ export default {
|
||||
communityInterfaces: [],
|
||||
loadingInterfaces: false,
|
||||
interfaceAddedViaTutorial: false,
|
||||
discoveryOption: null,
|
||||
discoveredInterfaces: [],
|
||||
discoveredActive: [],
|
||||
loadingDiscovered: false,
|
||||
savingDiscovery: false,
|
||||
discoveryInterval: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -937,10 +1315,25 @@ export default {
|
||||
config() {
|
||||
return GlobalState.config;
|
||||
},
|
||||
sortedDiscoveredInterfaces() {
|
||||
return [...this.discoveredInterfaces].sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
|
||||
},
|
||||
interfacesWithLocation() {
|
||||
return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.discoveryInterval) {
|
||||
clearInterval(this.discoveryInterval);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.isPage) {
|
||||
this.loadCommunityInterfaces();
|
||||
this.loadDiscoveredInterfaces();
|
||||
this.discoveryInterval = setInterval(() => {
|
||||
this.loadDiscoveredInterfaces();
|
||||
}, 5000);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -970,7 +1363,16 @@ export default {
|
||||
this.visible = true;
|
||||
this.currentStep = 1;
|
||||
this.interfaceAddedViaTutorial = false;
|
||||
this.discoveryOption = null;
|
||||
await this.loadCommunityInterfaces();
|
||||
await this.loadDiscoveredInterfaces();
|
||||
|
||||
if (this.discoveryInterval) {
|
||||
clearInterval(this.discoveryInterval);
|
||||
}
|
||||
this.discoveryInterval = setInterval(() => {
|
||||
this.loadDiscoveredInterfaces();
|
||||
}, 5000);
|
||||
},
|
||||
async loadCommunityInterfaces() {
|
||||
this.loadingInterfaces = true;
|
||||
@@ -983,6 +1385,85 @@ export default {
|
||||
this.loadingInterfaces = false;
|
||||
}
|
||||
},
|
||||
async loadDiscoveredInterfaces() {
|
||||
this.loadingDiscovered = true;
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/reticulum/discovered-interfaces`);
|
||||
this.discoveredInterfaces = response.data?.interfaces ?? [];
|
||||
this.discoveredActive = response.data?.active ?? [];
|
||||
} catch (e) {
|
||||
console.error("Failed to load discovered interfaces:", e);
|
||||
} finally {
|
||||
this.loadingDiscovered = false;
|
||||
}
|
||||
},
|
||||
async useDiscovery() {
|
||||
this.savingDiscovery = true;
|
||||
try {
|
||||
const payload = {
|
||||
discover_interfaces: true,
|
||||
autoconnect_discovered_interfaces: 3, // default to 3 slots
|
||||
};
|
||||
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
|
||||
ToastUtils.success("Community discovery enabled");
|
||||
this.discoveryOption = "yes";
|
||||
this.nextStep();
|
||||
} catch (e) {
|
||||
console.error("Failed to enable discovery:", e);
|
||||
ToastUtils.error("Failed to enable discovery");
|
||||
} finally {
|
||||
this.savingDiscovery = false;
|
||||
}
|
||||
},
|
||||
getDiscoveryIcon(iface) {
|
||||
switch (iface.type) {
|
||||
case "AutoInterface":
|
||||
return "home-automation";
|
||||
case "RNodeInterface":
|
||||
return iface.port && iface.port.toString().startsWith("tcp://") ? "lan-connect" : "radio-tower";
|
||||
case "RNodeMultiInterface":
|
||||
return "access-point-network";
|
||||
case "TCPClientInterface":
|
||||
case "BackboneInterface":
|
||||
return "lan-connect";
|
||||
case "TCPServerInterface":
|
||||
return "lan";
|
||||
case "UDPInterface":
|
||||
return "wan";
|
||||
case "SerialInterface":
|
||||
return "usb-port";
|
||||
case "KISSInterface":
|
||||
case "AX25KISSInterface":
|
||||
return "antenna";
|
||||
case "I2PInterface":
|
||||
return "eye";
|
||||
case "PipeInterface":
|
||||
return "pipe";
|
||||
default:
|
||||
return "server-network";
|
||||
}
|
||||
},
|
||||
formatLastHeard(ts) {
|
||||
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
},
|
||||
copyToClipboard(text, label) {
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text);
|
||||
ToastUtils.success(`${label} copied to clipboard`);
|
||||
},
|
||||
mapAllDiscovered() {
|
||||
if (!this.isPage) {
|
||||
this.visible = false;
|
||||
}
|
||||
this.$router.push({
|
||||
name: "map",
|
||||
query: { view: "discovered" },
|
||||
});
|
||||
},
|
||||
async selectCommunityInterface(iface) {
|
||||
try {
|
||||
await window.axios.post("/api/v1/reticulum/interfaces/add", {
|
||||
|
||||
@@ -264,7 +264,27 @@
|
||||
class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
|
||||
>
|
||||
<div
|
||||
class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-blue-500 to-purple-500"
|
||||
class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-blue-500 to-emerald-500"
|
||||
></div>
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-emerald-500/10 flex items-center justify-center border border-emerald-500/20 text-emerald-600 font-black text-[10px] tracking-tighter shadow-sm"
|
||||
>
|
||||
LXMFy
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
|
||||
LXMF Bot framework
|
||||
</div>
|
||||
<div class="text-xs font-mono font-bold text-gray-400 mt-1">
|
||||
v{{ (appInfo.dependencies && appInfo.dependencies.lxmfy) || "unknown" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
|
||||
>
|
||||
<div
|
||||
class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-emerald-500 to-purple-500"
|
||||
></div>
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center border border-purple-500/20 text-purple-600 font-black text-[10px] tracking-tighter shadow-sm"
|
||||
@@ -295,8 +315,24 @@
|
||||
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
|
||||
Reticulum Network Stack
|
||||
</div>
|
||||
<div class="text-xs font-mono font-bold text-gray-400 mt-1">
|
||||
v{{ appInfo.rns_version }}
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<div class="text-xs font-mono font-bold text-gray-400">
|
||||
v{{ appInfo.rns_version }}
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
appInfo.is_connected_to_shared_instance
|
||||
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
|
||||
: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
||||
]"
|
||||
class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded border"
|
||||
>
|
||||
{{
|
||||
appInfo.is_connected_to_shared_instance
|
||||
? "Shared Instance"
|
||||
: "Main Instance"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -530,34 +566,75 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="snapshots.length > 0" class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="snapshot in snapshots"
|
||||
:key="snapshot.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-purple-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]"
|
||||
>{{ snapshot.name }}</span
|
||||
>
|
||||
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
|
||||
>{{ formatBytes(snapshot.size) }} • {{ snapshot.created_at }}</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
|
||||
@click="restoreFromSnapshot(snapshot.path)"
|
||||
<div v-if="snapshots && snapshots.length > 0" class="space-y-4">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div
|
||||
v-for="snapshot in snapshots"
|
||||
:key="snapshot.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-purple-500/20 transition-all group"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span
|
||||
class="font-black text-gray-900 dark:text-white text-xs truncate"
|
||||
>{{ snapshot.name }}</span
|
||||
>
|
||||
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
|
||||
>{{ formatBytes(snapshot.size) }} •
|
||||
{{ Utils.formatTimeAgo(snapshot.created_at) }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip !px-3 !py-1 !text-[10px]"
|
||||
@click="restoreFromSnapshot(snapshot.path)"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger-chip !px-3 !py-1 !text-[10px]"
|
||||
@click="deleteSnapshot(snapshot.name)"
|
||||
>
|
||||
<v-icon icon="mdi-delete" size="12"></v-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Snapshots Pagination -->
|
||||
<div
|
||||
v-if="snapshotsTotal > snapshotsLimit"
|
||||
class="flex items-center justify-between px-2"
|
||||
>
|
||||
<div class="text-[10px] font-black text-gray-400 uppercase tracking-widest">
|
||||
Page {{ Math.floor(snapshotsOffset / snapshotsLimit) + 1 }} of
|
||||
{{ Math.ceil(snapshotsTotal / snapshotsLimit) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="secondary-chip !p-1 disabled:opacity-30"
|
||||
:disabled="snapshotsOffset === 0"
|
||||
@click="prevSnapshots"
|
||||
>
|
||||
<v-icon icon="mdi-chevron-left"></v-icon>
|
||||
</button>
|
||||
<button
|
||||
class="secondary-chip !p-1 disabled:opacity-30"
|
||||
:disabled="snapshotsOffset + snapshotsLimit >= snapshotsTotal"
|
||||
@click="nextSnapshots"
|
||||
>
|
||||
<v-icon icon="mdi-chevron-right"></v-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Backups -->
|
||||
<div v-if="autoBackups.length > 0" class="space-y-6">
|
||||
<div v-if="autoBackups && 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
|
||||
@@ -572,28 +649,69 @@
|
||||
</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)"
|
||||
<div v-if="autoBackups && autoBackups.length > 0" class="space-y-4">
|
||||
<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"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<div class="flex flex-col min-w-0">
|
||||
<span
|
||||
class="font-black text-gray-900 dark:text-white text-xs truncate"
|
||||
>{{ backup.name }}</span
|
||||
>
|
||||
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
|
||||
>{{ formatBytes(backup.size) }} •
|
||||
{{ Utils.formatTimeAgo(backup.created_at) }}</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip !px-3 !py-1 !text-[10px]"
|
||||
@click="restoreFromSnapshot(backup.path)"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="danger-chip !px-3 !py-1 !text-[10px]"
|
||||
@click="deleteBackup(backup.name)"
|
||||
>
|
||||
<v-icon icon="mdi-delete" size="12"></v-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backups Pagination -->
|
||||
<div
|
||||
v-if="autoBackupsTotal > autoBackupsLimit"
|
||||
class="flex items-center justify-between px-2"
|
||||
>
|
||||
<div class="text-[10px] font-black text-gray-400 uppercase tracking-widest">
|
||||
Page {{ Math.floor(autoBackupsOffset / autoBackupsLimit) + 1 }} of
|
||||
{{ Math.ceil(autoBackupsTotal / autoBackupsLimit) }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="secondary-chip !p-1 disabled:opacity-30"
|
||||
:disabled="autoBackupsOffset === 0"
|
||||
@click="prevBackups"
|
||||
>
|
||||
<v-icon icon="mdi-chevron-left"></v-icon>
|
||||
</button>
|
||||
<button
|
||||
class="secondary-chip !p-1 disabled:opacity-30"
|
||||
:disabled="autoBackupsOffset + autoBackupsLimit >= autoBackupsTotal"
|
||||
@click="nextBackups"
|
||||
>
|
||||
<v-icon icon="mdi-chevron-right"></v-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -704,6 +822,7 @@ export default {
|
||||
components: {},
|
||||
data() {
|
||||
return {
|
||||
Utils,
|
||||
appInfo: null,
|
||||
config: null,
|
||||
updateInterval: null,
|
||||
@@ -725,10 +844,16 @@ export default {
|
||||
restoreFile: null,
|
||||
snapshotName: "",
|
||||
snapshots: [],
|
||||
snapshotsTotal: 0,
|
||||
snapshotsOffset: 0,
|
||||
snapshotsLimit: 3,
|
||||
snapshotInProgress: false,
|
||||
snapshotMessage: "",
|
||||
snapshotError: "",
|
||||
autoBackups: [],
|
||||
autoBackupsTotal: 0,
|
||||
autoBackupsOffset: 0,
|
||||
autoBackupsLimit: 3,
|
||||
identityBackupMessage: "",
|
||||
identityBackupError: "",
|
||||
identityBase32: "",
|
||||
@@ -776,18 +901,74 @@ export default {
|
||||
methods: {
|
||||
async listSnapshots() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/database/snapshots");
|
||||
this.snapshots = response.data;
|
||||
const response = await window.axios.get("/api/v1/database/snapshots", {
|
||||
params: {
|
||||
limit: this.snapshotsLimit,
|
||||
offset: this.snapshotsOffset,
|
||||
},
|
||||
});
|
||||
this.snapshots = response.data.snapshots;
|
||||
this.snapshotsTotal = response.data.total;
|
||||
} catch (e) {
|
||||
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);
|
||||
const response = await window.axios.get("/api/v1/database/backups", {
|
||||
params: {
|
||||
limit: this.autoBackupsLimit,
|
||||
offset: this.autoBackupsOffset,
|
||||
},
|
||||
});
|
||||
this.autoBackups = response.data.backups;
|
||||
this.autoBackupsTotal = response.data.total;
|
||||
} catch {
|
||||
console.log("Failed to list auto-backups");
|
||||
}
|
||||
},
|
||||
async deleteSnapshot(filename) {
|
||||
if (!(await DialogUtils.confirm("Are you sure you want to delete this snapshot?"))) return;
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/database/snapshots/${filename}`);
|
||||
ToastUtils.success("Snapshot deleted");
|
||||
await this.listSnapshots();
|
||||
} catch {
|
||||
ToastUtils.error("Failed to delete snapshot");
|
||||
}
|
||||
},
|
||||
async deleteBackup(filename) {
|
||||
if (!(await DialogUtils.confirm("Are you sure you want to delete this backup?"))) return;
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/database/backups/${filename}`);
|
||||
ToastUtils.success("Backup deleted");
|
||||
await this.listAutoBackups();
|
||||
} catch {
|
||||
ToastUtils.error("Failed to delete backup");
|
||||
}
|
||||
},
|
||||
async nextSnapshots() {
|
||||
if (this.snapshotsOffset + this.snapshotsLimit < this.snapshotsTotal) {
|
||||
this.snapshotsOffset += this.snapshotsLimit;
|
||||
await this.listSnapshots();
|
||||
}
|
||||
},
|
||||
async prevSnapshots() {
|
||||
if (this.snapshotsOffset > 0) {
|
||||
this.snapshotsOffset = Math.max(0, this.snapshotsOffset - this.snapshotsLimit);
|
||||
await this.listSnapshots();
|
||||
}
|
||||
},
|
||||
async nextBackups() {
|
||||
if (this.autoBackupsOffset + this.autoBackupsLimit < this.autoBackupsTotal) {
|
||||
this.autoBackupsOffset += this.autoBackupsLimit;
|
||||
await this.listAutoBackups();
|
||||
}
|
||||
},
|
||||
async prevBackups() {
|
||||
if (this.autoBackupsOffset > 0) {
|
||||
this.autoBackupsOffset = Math.max(0, this.autoBackupsOffset - this.autoBackupsLimit);
|
||||
await this.listAutoBackups();
|
||||
}
|
||||
},
|
||||
async createSnapshot() {
|
||||
@@ -802,9 +983,8 @@ export default {
|
||||
this.snapshotMessage = "Snapshot created successfully";
|
||||
this.snapshotName = "";
|
||||
await this.listSnapshots();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
this.snapshotError = "Failed to create snapshot";
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.snapshotInProgress = false;
|
||||
}
|
||||
@@ -825,9 +1005,8 @@ export default {
|
||||
setTimeout(() => ElectronUtils.relaunch(), 2000);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
ToastUtils.error("Failed to restore snapshot");
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async getAppInfo() {
|
||||
@@ -856,9 +1035,8 @@ export default {
|
||||
await window.axios.post("/api/v1/app/integrity/acknowledge");
|
||||
ToastUtils.success("Integrity issues acknowledged");
|
||||
await this.getAppInfo();
|
||||
} catch (e) {
|
||||
} catch {
|
||||
ToastUtils.error("Failed to acknowledge integrity issues");
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1075,9 +1253,9 @@ export default {
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
this.identityBackupMessage = "Identity downloaded. Keep it secret.";
|
||||
} catch (e) {
|
||||
ToastUtils.success("Identity key file exported");
|
||||
} catch {
|
||||
this.identityBackupError = "Failed to download identity";
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async copyIdentityBase32() {
|
||||
@@ -1092,9 +1270,9 @@ export default {
|
||||
}
|
||||
await navigator.clipboard.writeText(this.identityBase32);
|
||||
this.identityBase32Message = "Identity copied. Clear your clipboard after use.";
|
||||
} catch (e) {
|
||||
ToastUtils.success("Identity Base32 key copied to clipboard");
|
||||
} catch {
|
||||
this.identityBase32Error = "Failed to copy identity";
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
onIdentityRestoreFileChange(event) {
|
||||
@@ -1124,9 +1302,8 @@ export default {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
this.identityRestoreMessage = response.data.message || "Identity imported.";
|
||||
} catch (e) {
|
||||
} catch {
|
||||
this.identityRestoreError = "Identity restore failed";
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.identityRestoreInProgress = false;
|
||||
}
|
||||
@@ -1147,9 +1324,8 @@ export default {
|
||||
base32: this.identityRestoreBase32.trim(),
|
||||
});
|
||||
this.identityRestoreMessage = response.data.message || "Identity imported.";
|
||||
} catch (e) {
|
||||
} catch {
|
||||
this.identityRestoreError = "Identity restore failed";
|
||||
console.log(e);
|
||||
} finally {
|
||||
this.identityRestoreInProgress = false;
|
||||
}
|
||||
|
||||
@@ -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="p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full flex-1">
|
||||
<div class="p-3 md:p-6 space-y-4 w-full flex-1">
|
||||
<div
|
||||
v-if="showRestartReminder"
|
||||
class="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center"
|
||||
@@ -28,11 +28,11 @@
|
||||
|
||||
<div class="glass-card space-y-4">
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("interfaces.manage") }}
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white truncate">
|
||||
{{ $t("interfaces.title") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
@@ -111,136 +111,370 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="filteredInterfaces.length === 0"
|
||||
class="glass-card text-center py-10 text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3" />
|
||||
<div class="text-lg font-semibold">{{ $t("interfaces.no_interfaces_found") }}</div>
|
||||
<div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
|
||||
</div>
|
||||
|
||||
<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 class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="tab in ['overview', 'discovery']"
|
||||
:key="tab"
|
||||
type="button"
|
||||
:class="tabChipClass(activeTab === tab)"
|
||||
@click="activeTab = tab"
|
||||
>
|
||||
<span v-if="tab === 'overview'">Overview</span>
|
||||
<span v-else>Discovery Settings</span>
|
||||
</button>
|
||||
</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 v-if="activeTab === 'overview'" class="space-y-4">
|
||||
<div class="glass-card space-y-3">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Configured
|
||||
</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 class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
|
||||
<div
|
||||
v-if="filteredInterfaces.length !== 0"
|
||||
class="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3 3xl:grid-cols-4 4xl:grid-cols-5"
|
||||
>
|
||||
<Interface
|
||||
v-for="iface of filteredInterfaces"
|
||||
:key="iface._name"
|
||||
:iface="iface"
|
||||
:is-reticulum-running="isReticulumRunning"
|
||||
@enable="enableInterface(iface._name)"
|
||||
@disable="disableInterface(iface._name)"
|
||||
@edit="editInterface(iface._name)"
|
||||
@export="exportInterface(iface._name)"
|
||||
@delete="deleteInterface(iface._name)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="glass-card text-center py-10 text-gray-500 dark:text-gray-300">
|
||||
<MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3" />
|
||||
<div class="text-lg font-semibold">{{ $t("interfaces.no_interfaces_found") }}</div>
|
||||
<div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
|
||||
</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 class="glass-card space-y-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Discovered Interfaces
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Recently Heard Announces
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Cards appear/disappear as announces are heard. Connected entries show a green
|
||||
pill; disconnected entries are dimmed with a red label.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="interfacesWithLocation.length > 0"
|
||||
type="button"
|
||||
class="secondary-chip text-xs bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30"
|
||||
@click="mapAllDiscovered"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="map-marker-multiple" class="w-4 h-4" />
|
||||
Map All ({{ interfacesWithLocation.length }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip text-xs"
|
||||
@click="loadDiscoveredInterfaces"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="sortedDiscoveredInterfaces.length === 0"
|
||||
class="text-sm text-gray-500 dark:text-gray-300"
|
||||
>
|
||||
No discovered interfaces yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 4xl:grid-cols-6"
|
||||
>
|
||||
<div
|
||||
v-for="iface in sortedDiscoveredInterfaces"
|
||||
:key="iface.discovery_hash || iface.name"
|
||||
class="interface-card group transition-all duration-300"
|
||||
:class="{ 'opacity-70 grayscale-[0.3]': !isDiscoveredConnected(iface) }"
|
||||
>
|
||||
<div class="flex gap-4 items-start relative">
|
||||
<!-- Disconnected Overlay -->
|
||||
<div
|
||||
v-if="!isDiscoveredConnected(iface)"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-white/20 dark:bg-zinc-900/20 backdrop-blur-[0.5px] rounded-3xl pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="bg-red-500/90 text-white px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider animate-pulse"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="lan-disconnect" class="w-3.5 h-3.5" />
|
||||
<span>{{ $t("app.disabled") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="interface-card__icon shrink-0">
|
||||
<MaterialDesignIcon :icon-name="getDiscoveryIcon(iface)" class="w-6 h-6" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 space-y-2">
|
||||
<div class="flex items-center gap-2 flex-nowrap min-w-0">
|
||||
<div
|
||||
class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate min-w-0"
|
||||
>
|
||||
{{ iface.name }}
|
||||
</div>
|
||||
<span class="type-chip shrink-0">{{ iface.type }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
v-if="iface.value"
|
||||
class="text-[10px] font-bold text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
Stamps: {{ iface.value }}
|
||||
</span>
|
||||
<span
|
||||
v-if="isDiscoveredConnected(iface)"
|
||||
class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-700 px-2 py-0.5 text-[10px] font-semibold dark:bg-emerald-900/40 dark:text-emerald-200 shrink-0"
|
||||
>
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1.5 text-[10px] sm:text-xs">
|
||||
<span class="stat-chip bg-gray-50 dark:bg-zinc-800/50"
|
||||
>Hops: {{ iface.hops }}</span
|
||||
>
|
||||
<span class="stat-chip capitalize bg-gray-50 dark:bg-zinc-800/50">{{
|
||||
iface.status
|
||||
}}</span>
|
||||
<span
|
||||
v-if="iface.last_heard"
|
||||
class="stat-chip bg-gray-50 dark:bg-zinc-800/50"
|
||||
>
|
||||
Heard: {{ formatLastHeard(iface.last_heard) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-1.5 text-[10px] sm:text-[11px] pt-1 min-w-0">
|
||||
<div
|
||||
v-if="iface.reachable_on"
|
||||
class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
`${iface.reachable_on}:${iface.port}`,
|
||||
'Address'
|
||||
)
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="link-variant"
|
||||
class="w-3.5 h-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate"
|
||||
>Address: {{ iface.reachable_on }}:{{ iface.port }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="iface.transport_id"
|
||||
class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
|
||||
@click="copyToClipboard(iface.transport_id, 'Transport ID')"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="identifier"
|
||||
class="w-3.5 h-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate font-mono"
|
||||
>Transport ID: {{ iface.transport_id }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="iface.network_id"
|
||||
class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
|
||||
@click="copyToClipboard(iface.network_id, 'Network ID')"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="lan" class="w-3.5 h-3.5 shrink-0" />
|
||||
<span class="truncate font-mono"
|
||||
>Network ID: {{ iface.network_id }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="iface.latitude != null && iface.longitude != null"
|
||||
class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
|
||||
@click="
|
||||
copyToClipboard(
|
||||
`${iface.latitude}, ${iface.longitude}`,
|
||||
'Location'
|
||||
)
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="map-marker"
|
||||
class="w-3.5 h-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate"
|
||||
>Loc: {{ iface.latitude }}, {{ iface.longitude }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="discoveredBytes(iface)"
|
||||
class="flex items-center gap-2 text-gray-500 dark:text-gray-500 min-w-0"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="swap-vertical"
|
||||
class="w-3.5 h-3.5 shrink-0"
|
||||
/>
|
||||
<span class="truncate"
|
||||
>TX {{ discoveredBytes(iface).tx }} · RX
|
||||
{{ discoveredBytes(iface).rx }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 shrink-0">
|
||||
<button
|
||||
v-if="iface.latitude != null && iface.longitude != null"
|
||||
type="button"
|
||||
class="secondary-chip !p-2 !rounded-xl"
|
||||
:title="$t('map.title')"
|
||||
@click="goToMap(iface)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="map" class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<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">
|
||||
Listen for discovery announces and optionally auto-connect to available
|
||||
interfaces.
|
||||
Requires LXMF in the Python environment. Transport is optional for publishing,
|
||||
but usually recommended so peers can connect back.
|
||||
</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 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>
|
||||
<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 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>
|
||||
<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 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>
|
||||
<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"
|
||||
:iface="iface"
|
||||
:is-reticulum-running="isReticulumRunning"
|
||||
@enable="enableInterface(iface._name)"
|
||||
@disable="disableInterface(iface._name)"
|
||||
@edit="editInterface(iface._name)"
|
||||
@export="exportInterface(iface._name)"
|
||||
@delete="deleteInterface(iface._name)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -286,6 +520,10 @@ export default {
|
||||
network_identity: "",
|
||||
},
|
||||
savingDiscovery: false,
|
||||
discoveredInterfaces: [],
|
||||
discoveredActive: [],
|
||||
discoveryInterval: null,
|
||||
activeTab: "overview",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -357,19 +595,57 @@ export default {
|
||||
this.interfacesWithStats.forEach((iface) => types.add(iface.type));
|
||||
return Array.from(types).sort();
|
||||
},
|
||||
sortedDiscoveredInterfaces() {
|
||||
return [...this.discoveredInterfaces].sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
|
||||
},
|
||||
interfacesWithLocation() {
|
||||
return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
|
||||
},
|
||||
activeInterfaceStats() {
|
||||
return Object.values(this.interfaceStats || {});
|
||||
},
|
||||
tabChipClass() {
|
||||
return (isActive) => (isActive ? "primary-chip text-xs" : "secondary-chip text-xs");
|
||||
},
|
||||
discoveredActiveSet() {
|
||||
const set = new Set();
|
||||
this.discoveredActive.forEach((a) => {
|
||||
const host = a.target_host || a.remote || a.listen_ip;
|
||||
const port = a.target_port || a.listen_port;
|
||||
if (host && port) {
|
||||
set.add(`${host}:${port}`);
|
||||
}
|
||||
});
|
||||
return set;
|
||||
},
|
||||
discoveredActiveTransportIds() {
|
||||
const set = new Set();
|
||||
this.discoveredActive.forEach((a) => {
|
||||
if (a.transport_id) {
|
||||
set.add(a.transport_id);
|
||||
}
|
||||
});
|
||||
return set;
|
||||
},
|
||||
},
|
||||
beforeUnmount() {
|
||||
clearInterval(this.reloadInterval);
|
||||
clearInterval(this.discoveryInterval);
|
||||
},
|
||||
mounted() {
|
||||
this.loadInterfaces();
|
||||
this.updateInterfaceStats();
|
||||
this.loadDiscoveryConfig();
|
||||
this.loadDiscoveredInterfaces();
|
||||
|
||||
// update info every few seconds
|
||||
this.reloadInterval = setInterval(() => {
|
||||
this.updateInterfaceStats();
|
||||
}, 1000);
|
||||
|
||||
this.discoveryInterval = setInterval(() => {
|
||||
this.loadDiscoveredInterfaces();
|
||||
}, 5000);
|
||||
},
|
||||
methods: {
|
||||
relaunch() {
|
||||
@@ -506,6 +782,77 @@ export default {
|
||||
this.trackInterfaceChange();
|
||||
}
|
||||
},
|
||||
async loadDiscoveredInterfaces() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/reticulum/discovered-interfaces`);
|
||||
this.discoveredInterfaces = response.data?.interfaces ?? [];
|
||||
this.discoveredActive = response.data?.active ?? [];
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
formatLastHeard(ts) {
|
||||
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
|
||||
return `${Math.floor(seconds / 86400)}d ago`;
|
||||
},
|
||||
isDiscoveredConnected(iface) {
|
||||
const reach = iface.reachable_on;
|
||||
const port = iface.port;
|
||||
if (iface.transport_id && this.discoveredActiveTransportIds.has(iface.transport_id)) {
|
||||
return true;
|
||||
}
|
||||
if (reach && port && this.discoveredActiveSet && this.discoveredActiveSet.has(`${reach}:${port}`)) {
|
||||
return true;
|
||||
}
|
||||
return this.activeInterfaceStats.some((s) => {
|
||||
const hostMatch =
|
||||
(s.target_host && reach && s.target_host === reach) || (s.remote && reach && s.remote === reach);
|
||||
const portMatch =
|
||||
(s.target_port && port && Number(s.target_port) === Number(port)) ||
|
||||
(s.listen_port && port && Number(s.listen_port) === Number(port));
|
||||
return hostMatch && portMatch && (s.connected || s.online);
|
||||
});
|
||||
},
|
||||
goToMap(iface) {
|
||||
if (iface.latitude == null || iface.longitude == null) return;
|
||||
this.$router.push({
|
||||
name: "map",
|
||||
query: {
|
||||
lat: iface.latitude,
|
||||
lon: iface.longitude,
|
||||
label: iface.name,
|
||||
},
|
||||
});
|
||||
},
|
||||
mapAllDiscovered() {
|
||||
this.$router.push({
|
||||
name: "map",
|
||||
query: { view: "discovered" },
|
||||
});
|
||||
},
|
||||
discoveredBytes(iface) {
|
||||
const reach = iface.reachable_on;
|
||||
const port = iface.port;
|
||||
const stats = this.activeInterfaceStats || [];
|
||||
const match = stats.find((s) => {
|
||||
const host = s.target_host || s.remote || s.interface_name;
|
||||
const p = s.target_port || s.listen_port;
|
||||
const hostMatch = host && reach && host === reach;
|
||||
const portMatch = p && port && Number(p) === Number(port);
|
||||
return hostMatch && portMatch;
|
||||
});
|
||||
if (!match) return null;
|
||||
return {
|
||||
tx: this.formatBytes(match.txb || 0),
|
||||
rx: this.formatBytes(match.rxb || 0),
|
||||
};
|
||||
},
|
||||
formatBytes(bytes) {
|
||||
return Utils.formatBytes(bytes || 0);
|
||||
},
|
||||
parseBool(value) {
|
||||
if (typeof value === "string") {
|
||||
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
|
||||
@@ -565,6 +912,39 @@ export default {
|
||||
this.savingDiscovery = false;
|
||||
}
|
||||
},
|
||||
getDiscoveryIcon(iface) {
|
||||
switch (iface.type) {
|
||||
case "AutoInterface":
|
||||
return "home-automation";
|
||||
case "RNodeInterface":
|
||||
return iface.port && iface.port.toString().startsWith("tcp://") ? "lan-connect" : "radio-tower";
|
||||
case "RNodeMultiInterface":
|
||||
return "access-point-network";
|
||||
case "TCPClientInterface":
|
||||
case "BackboneInterface":
|
||||
return "lan-connect";
|
||||
case "TCPServerInterface":
|
||||
return "lan";
|
||||
case "UDPInterface":
|
||||
return "wan";
|
||||
case "SerialInterface":
|
||||
return "usb-port";
|
||||
case "KISSInterface":
|
||||
case "AX25KISSInterface":
|
||||
return "antenna";
|
||||
case "I2PInterface":
|
||||
return "eye";
|
||||
case "PipeInterface":
|
||||
return "pipe";
|
||||
default:
|
||||
return "server-network";
|
||||
}
|
||||
},
|
||||
copyToClipboard(text, label) {
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text);
|
||||
ToastUtils.success(`${label} copied to clipboard`);
|
||||
},
|
||||
setStatusFilter(value) {
|
||||
this.statusFilter = value;
|
||||
},
|
||||
|
||||
@@ -28,6 +28,13 @@
|
||||
|
||||
<!-- offline/online toggle -->
|
||||
<div class="flex items-center bg-gray-100 dark:bg-zinc-800 rounded-lg p-1">
|
||||
<button
|
||||
class="p-2 rounded-lg text-gray-500 hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors mr-1"
|
||||
title="Map Discovered Interfaces"
|
||||
@click="mapDiscoveredNodes"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="map-marker-radius" class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
:class="
|
||||
!offlineEnabled
|
||||
@@ -1059,6 +1066,7 @@ import { fromCircle } from "ol/geom/Polygon";
|
||||
import { unByKey } from "ol/Observable";
|
||||
import Overlay from "ol/Overlay";
|
||||
import GeoJSON from "ol/format/GeoJSON";
|
||||
import { extend as extendExtent, createEmpty as createEmptyExtent, isEmpty as isExtentEmpty } from "ol/extent";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import TileCache from "../../js/TileCache";
|
||||
@@ -1090,6 +1098,8 @@ export default {
|
||||
markerSource: null,
|
||||
markerLayer: null,
|
||||
selectedMarker: null,
|
||||
queryMarker: null,
|
||||
discoveredMarkers: [],
|
||||
|
||||
// caching
|
||||
cachingEnabled: true,
|
||||
@@ -1256,6 +1266,11 @@ export default {
|
||||
await this.fetchPeers();
|
||||
await this.fetchTelemetryMarkers();
|
||||
|
||||
// Handle view modes
|
||||
if (this.$route.query.view === "discovered") {
|
||||
await this.mapDiscoveredNodes();
|
||||
}
|
||||
|
||||
// Listen for websocket messages
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
@@ -1264,10 +1279,29 @@ export default {
|
||||
const lat = parseFloat(this.$route.query.lat);
|
||||
const lon = parseFloat(this.$route.query.lon);
|
||||
const zoom = parseInt(this.$route.query.zoom || 15);
|
||||
const label = this.$route.query.label || "Target";
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
this.map.getView().setCenter(fromLonLat([lon, lat]));
|
||||
this.map.getView().setZoom(zoom);
|
||||
|
||||
// add a temporary marker for the query target
|
||||
const feature = new Feature({
|
||||
geometry: new Point(fromLonLat([lon, lat])),
|
||||
});
|
||||
feature.setStyle(
|
||||
this.createMarkerStyle({
|
||||
iconColor: "#2563eb",
|
||||
bgColor: "#bfdbfe",
|
||||
label,
|
||||
isStale: false,
|
||||
iconPath: null,
|
||||
})
|
||||
);
|
||||
this.queryMarker = feature;
|
||||
if (this.markerSource) {
|
||||
this.markerSource.addFeature(feature);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2962,18 +2996,83 @@ export default {
|
||||
if (!this.markerSource) return;
|
||||
this.markerSource.clear();
|
||||
|
||||
const featuresByCoord = {};
|
||||
|
||||
// Helper to collect features
|
||||
const addFeatureToGroup = (coord, feature) => {
|
||||
// Round coordinates to handle floating point jitter
|
||||
const key = coord.map((c) => c.toFixed(6)).join(",");
|
||||
if (!featuresByCoord[key]) featuresByCoord[key] = [];
|
||||
featuresByCoord[key].push(feature);
|
||||
};
|
||||
|
||||
// Process telemetry
|
||||
for (const t of this.telemetryList) {
|
||||
const loc = t.telemetry?.location;
|
||||
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
|
||||
|
||||
const coord = fromLonLat([loc.longitude, loc.latitude]);
|
||||
const feature = new Feature({
|
||||
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
|
||||
geometry: new Point(coord),
|
||||
telemetry: t,
|
||||
peer: this.peers[t.destination_hash],
|
||||
});
|
||||
|
||||
this.markerSource.addFeature(feature);
|
||||
addFeatureToGroup(coord, feature);
|
||||
}
|
||||
|
||||
// Process query marker
|
||||
if (this.queryMarker) {
|
||||
const coord = this.queryMarker.getGeometry().getCoordinates();
|
||||
addFeatureToGroup(coord, this.queryMarker);
|
||||
}
|
||||
|
||||
// Process discovered markers
|
||||
if (this.discoveredMarkers && this.discoveredMarkers.length > 0) {
|
||||
for (const feature of this.discoveredMarkers) {
|
||||
const coord = feature.getGeometry().getCoordinates();
|
||||
addFeatureToGroup(coord, feature);
|
||||
}
|
||||
}
|
||||
|
||||
// Now handle groups (Marker Explosion)
|
||||
const view = this.map.getView();
|
||||
const resolution = view.getResolution();
|
||||
const offsetDist = resolution * 40; // 40 pixels offset
|
||||
|
||||
Object.entries(featuresByCoord).forEach(([coordStr, features]) => {
|
||||
const trueCoord = coordStr.split(",").map(Number);
|
||||
|
||||
if (features.length === 1) {
|
||||
this.markerSource.addFeature(features[0]);
|
||||
} else {
|
||||
features.forEach((feature, index) => {
|
||||
const angle = (index / features.length) * 2 * Math.PI;
|
||||
const offsetCoord = [
|
||||
trueCoord[0] + Math.cos(angle) * offsetDist,
|
||||
trueCoord[1] + Math.sin(angle) * offsetDist,
|
||||
];
|
||||
|
||||
// Move the marker to offset position
|
||||
feature.setGeometry(new Point(offsetCoord));
|
||||
this.markerSource.addFeature(feature);
|
||||
|
||||
// Draw dashed line to true position
|
||||
const lineFeature = new Feature({
|
||||
geometry: new LineString([offsetCoord, trueCoord]),
|
||||
});
|
||||
lineFeature.setStyle(
|
||||
new Style({
|
||||
stroke: new Stroke({
|
||||
color: "rgba(59, 130, 246, 0.6)",
|
||||
width: 1.5,
|
||||
lineDash: [4, 4],
|
||||
}),
|
||||
})
|
||||
);
|
||||
this.markerSource.addFeature(lineFeature);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
|
||||
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
|
||||
@@ -3042,6 +3141,59 @@ export default {
|
||||
params: { destinationHash: hash },
|
||||
});
|
||||
},
|
||||
async mapDiscoveredNodes() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/reticulum/discovered-interfaces");
|
||||
const discovered = response.data?.interfaces ?? [];
|
||||
const nodesWithLoc = discovered.filter((n) => n.latitude != null && n.longitude != null);
|
||||
|
||||
if (nodesWithLoc.length === 0) {
|
||||
ToastUtils.info("No discovered nodes with location found");
|
||||
return;
|
||||
}
|
||||
|
||||
const extent = createEmptyExtent();
|
||||
this.discoveredMarkers = [];
|
||||
|
||||
for (const node of nodesWithLoc) {
|
||||
const coord = fromLonLat([node.longitude, node.latitude]);
|
||||
extendExtent(extent, coord);
|
||||
|
||||
// Add markers
|
||||
const feature = new Feature({
|
||||
geometry: new Point(coord),
|
||||
discovered: node,
|
||||
});
|
||||
feature.setStyle(
|
||||
this.createMarkerStyle({
|
||||
iconColor: "#10b981", // emerald-500
|
||||
bgColor: "#d1fae5", // emerald-100
|
||||
label: node.name,
|
||||
isStale: false,
|
||||
iconPath: null,
|
||||
})
|
||||
);
|
||||
this.discoveredMarkers.push(feature);
|
||||
}
|
||||
|
||||
// refresh all markers
|
||||
this.updateMarkers();
|
||||
|
||||
// Fit view to all discovered nodes if extent is valid
|
||||
if (!isExtentEmpty(extent)) {
|
||||
this.map.getView().fit(extent, {
|
||||
padding: [100, 100, 100, 100],
|
||||
maxZoom: 12,
|
||||
duration: 1000,
|
||||
});
|
||||
}
|
||||
|
||||
ToastUtils.success(`Mapped ${nodesWithLoc.length} discovered nodes`);
|
||||
} catch (e) {
|
||||
console.error("Failed to map discovered nodes", e);
|
||||
ToastUtils.error("Failed to fetch discovered nodes for mapping");
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
:class="{ 'hidden sm:flex': destinationHash }"
|
||||
:conversations="conversations"
|
||||
:peers="peers"
|
||||
:folders="folders"
|
||||
:selected-folder-id="selectedFolderId"
|
||||
:selected-destination-hash="selectedPeer?.destination_hash"
|
||||
:conversation-search-term="conversationSearchTerm"
|
||||
:filter-unread-only="filterUnreadOnly"
|
||||
@@ -25,6 +27,15 @@
|
||||
@ingest-paper-message="openIngestPaperMessageModal"
|
||||
@load-more="loadMoreConversations"
|
||||
@load-more-announces="loadMoreAnnounces"
|
||||
@folder-click="onFolderClick"
|
||||
@create-folder="onCreateFolder"
|
||||
@rename-folder="onRenameFolder"
|
||||
@delete-folder="onDeleteFolder"
|
||||
@move-to-folder="onMoveToFolder"
|
||||
@bulk-mark-as-read="onBulkMarkAsRead"
|
||||
@bulk-delete="onBulkDelete"
|
||||
@export-folders="onExportFolders"
|
||||
@import-folders="onImportFolders"
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -141,6 +152,8 @@ export default {
|
||||
selectedPeer: null,
|
||||
|
||||
conversations: [],
|
||||
folders: [],
|
||||
selectedFolderId: null,
|
||||
pageSize: 50,
|
||||
hasMoreConversations: true,
|
||||
isLoadingMore: false,
|
||||
@@ -201,11 +214,13 @@ export default {
|
||||
|
||||
this.getConfig();
|
||||
this.getConversations();
|
||||
this.getFolders();
|
||||
this.getLxmfDeliveryAnnounces();
|
||||
|
||||
// update info every few seconds
|
||||
this.reloadInterval = setInterval(() => {
|
||||
this.getConversations();
|
||||
this.getFolders();
|
||||
}, 5000);
|
||||
|
||||
// compose message if a destination hash was provided on page load
|
||||
@@ -395,6 +410,129 @@ export default {
|
||||
this.isLoadingMore = false;
|
||||
}
|
||||
},
|
||||
async getFolders() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/lxmf/folders");
|
||||
this.folders = response.data;
|
||||
} catch (e) {
|
||||
console.error("Failed to load folders", e);
|
||||
}
|
||||
},
|
||||
async onCreateFolder(name) {
|
||||
try {
|
||||
await window.axios.post("/api/v1/lxmf/folders", { name });
|
||||
await this.getFolders();
|
||||
ToastUtils.success("Folder created");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to create folder");
|
||||
}
|
||||
},
|
||||
async onRenameFolder({ id, name }) {
|
||||
try {
|
||||
await window.axios.patch(`/api/v1/lxmf/folders/${id}`, { name });
|
||||
await this.getFolders();
|
||||
ToastUtils.success("Folder renamed");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to rename folder");
|
||||
}
|
||||
},
|
||||
async onDeleteFolder(id) {
|
||||
try {
|
||||
await window.axios.delete(`/api/v1/lxmf/folders/${id}`);
|
||||
if (this.selectedFolderId === id) {
|
||||
this.selectedFolderId = null;
|
||||
}
|
||||
await this.getFolders();
|
||||
await this.getConversations();
|
||||
ToastUtils.success("Folder deleted");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to delete folder");
|
||||
}
|
||||
},
|
||||
async onMoveToFolder({ peer_hashes, folder_id }) {
|
||||
try {
|
||||
// Treat 0 as null (Uncategorized) for the backend
|
||||
const targetFolderId = folder_id === 0 ? null : folder_id;
|
||||
await window.axios.post("/api/v1/lxmf/conversations/move-to-folder", {
|
||||
peer_hashes,
|
||||
folder_id: targetFolderId,
|
||||
});
|
||||
await this.getConversations();
|
||||
ToastUtils.success("Moved to folder");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to move to folder");
|
||||
}
|
||||
},
|
||||
async onBulkMarkAsRead(destination_hashes) {
|
||||
try {
|
||||
await window.axios.post("/api/v1/lxmf/conversations/bulk-mark-as-read", {
|
||||
destination_hashes,
|
||||
});
|
||||
await this.getConversations();
|
||||
ToastUtils.success("Marked as read");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to mark as read");
|
||||
}
|
||||
},
|
||||
async onBulkDelete(destination_hashes) {
|
||||
try {
|
||||
const confirmed = await DialogUtils.confirm(
|
||||
"Are you sure you want to delete these conversations? All messages will be lost.",
|
||||
"Delete Conversations"
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
await window.axios.post("/api/v1/lxmf/conversations/bulk-delete", {
|
||||
destination_hashes,
|
||||
});
|
||||
await this.getConversations();
|
||||
ToastUtils.success("Conversations deleted");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to delete conversations");
|
||||
}
|
||||
},
|
||||
async onExportFolders() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/lxmf/folders/export");
|
||||
const data = JSON.stringify(response.data, null, 2);
|
||||
const blob = new Blob([data], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `meshchatx-folders-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
ToastUtils.error("Failed to export folders");
|
||||
}
|
||||
},
|
||||
async onImportFolders() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = ".json";
|
||||
input.onchange = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (re) => {
|
||||
try {
|
||||
const data = JSON.parse(re.target.result);
|
||||
await window.axios.post("/api/v1/lxmf/folders/import", data);
|
||||
await this.getFolders();
|
||||
await this.getConversations();
|
||||
ToastUtils.success("Folders imported");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to import folders");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
},
|
||||
onFolderClick(folderId) {
|
||||
this.selectedFolderId = folderId;
|
||||
this.requestConversationsRefresh();
|
||||
},
|
||||
async loadMoreConversations() {
|
||||
if (this.isLoadingMore || !this.hasMoreConversations) return;
|
||||
this.isLoadingMore = true;
|
||||
@@ -414,6 +552,9 @@ export default {
|
||||
if (this.filterHasAttachmentsOnly) {
|
||||
params.filter_has_attachments = true;
|
||||
}
|
||||
if (this.selectedFolderId !== null) {
|
||||
params.folder_id = this.selectedFolderId;
|
||||
}
|
||||
return params;
|
||||
},
|
||||
updatePeerFromAnnounce: function (announce) {
|
||||
|
||||
@@ -33,6 +33,145 @@
|
||||
v-if="tab === 'conversations'"
|
||||
class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
|
||||
>
|
||||
<!-- Folders Section -->
|
||||
<div class="border-b border-gray-200 dark:border-zinc-700 bg-gray-50/50 dark:bg-zinc-900/50">
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
|
||||
@click="foldersExpanded = !foldersExpanded"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<MaterialDesignIcon
|
||||
:icon-name="foldersExpanded ? 'chevron-down' : 'chevron-right'"
|
||||
class="size-4 text-gray-400"
|
||||
/>
|
||||
<span class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-zinc-500">
|
||||
Folders
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-1" @click.stop>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-gray-400 hover:text-blue-500 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
title="Create Folder"
|
||||
@click="createFolder"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="folder-plus-outline" class="size-4" />
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
@click="folderMenu.show = !folderMenu.show"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
|
||||
</button>
|
||||
<div
|
||||
v-if="folderMenu.show"
|
||||
v-click-outside="{ handler: () => (folderMenu.show = false), capture: true }"
|
||||
class="absolute right-0 top-full mt-1 z-[60] min-w-[160px] bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1 overflow-hidden animate-in fade-in zoom-in duration-100"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
|
||||
@click="
|
||||
$emit('export-folders');
|
||||
folderMenu.show = false;
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="export" class="size-4" />
|
||||
<span>Export Folders</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
|
||||
@click="
|
||||
$emit('import-folders');
|
||||
folderMenu.show = false;
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="import" class="size-4" />
|
||||
<span>Import Folders</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="foldersExpanded" class="flex flex-col max-h-48 overflow-y-auto pb-1">
|
||||
<div
|
||||
class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
|
||||
:class="[
|
||||
selectedFolderId === null
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
|
||||
: 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
|
||||
dragOverFolderId === 'all'
|
||||
? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
|
||||
: '',
|
||||
]"
|
||||
@click="$emit('folder-click', null)"
|
||||
@dragover="onDragOver($event, 'all')"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDropOnFolder($event, null)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="inbox-outline" class="size-4" />
|
||||
<span class="truncate flex-1">All Messages</span>
|
||||
</div>
|
||||
<div
|
||||
class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
|
||||
:class="[
|
||||
selectedFolderId === 0
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
|
||||
: 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
|
||||
dragOverFolderId === 0
|
||||
? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
|
||||
: '',
|
||||
]"
|
||||
@click="$emit('folder-click', 0)"
|
||||
@dragover="onDragOver($event, 0)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDropOnFolder($event, 0)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="folder-outline" class="size-4" />
|
||||
<span class="truncate flex-1">Uncategorized</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
class="group px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
|
||||
:class="[
|
||||
selectedFolderId === folder.id
|
||||
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
|
||||
: 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
|
||||
dragOverFolderId === folder.id
|
||||
? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
|
||||
: '',
|
||||
]"
|
||||
@click="$emit('folder-click', folder.id)"
|
||||
@dragover="onDragOver($event, folder.id)"
|
||||
@dragleave="onDragLeave"
|
||||
@drop="onDropOnFolder($event, folder.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="folder" class="size-4" />
|
||||
<span class="truncate flex-1">{{ folder.name }}</span>
|
||||
<div class="hidden group-hover:flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 hover:text-blue-500 hover:bg-white dark:hover:bg-zinc-700 rounded-lg transition-colors"
|
||||
@click.stop="renameFolder(folder)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="pencil-outline" class="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 hover:text-red-500 hover:bg-white dark:hover:bg-zinc-700 rounded-lg transition-colors"
|
||||
@click.stop="deleteFolder(folder)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="trash-can-outline" class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- search + filters -->
|
||||
<div
|
||||
v-if="conversations.length > 0 || isFilterActive"
|
||||
@@ -46,6 +185,15 @@
|
||||
class="input-field flex-1"
|
||||
@input="onConversationSearchInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
title="Selection Mode"
|
||||
:class="{ 'text-blue-500 bg-blue-50 dark:bg-blue-900/20': selectionMode }"
|
||||
@click="toggleSelectionMode"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" class="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="p-2 bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
|
||||
@@ -55,6 +203,69 @@
|
||||
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="selectionMode"
|
||||
class="flex items-center justify-between px-2 py-1 bg-blue-50 dark:bg-blue-900/10 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="allSelected"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
@change="toggleSelectAll"
|
||||
/>
|
||||
<span class="text-xs font-semibold text-blue-700 dark:text-blue-400">
|
||||
{{ selectedHashes.size }} selected
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
|
||||
@click="bulkMarkAsRead"
|
||||
>
|
||||
Mark as read
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-bold text-red-600 dark:text-red-400 hover:underline"
|
||||
@click="bulkDelete"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
|
||||
@click="moveMenu.show = !moveMenu.show"
|
||||
>
|
||||
Move to
|
||||
</button>
|
||||
<div
|
||||
v-if="moveMenu.show"
|
||||
v-click-outside="{ handler: () => (moveMenu.show = false), capture: true }"
|
||||
class="absolute right-0 top-full mt-1 z-[60] min-w-[160px] bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1 overflow-hidden animate-in fade-in zoom-in duration-100"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
|
||||
@click="moveSelectedToFolder(null)"
|
||||
>
|
||||
Uncategorized
|
||||
</button>
|
||||
<button
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
|
||||
@click="moveSelectedToFolder(folder.id)"
|
||||
>
|
||||
{{ folder.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button type="button" :class="filterChipClasses(filterUnreadOnly)" @click="toggleFilter('unread')">
|
||||
{{ $t("messages.unread") }}
|
||||
@@ -96,15 +307,38 @@
|
||||
conversation.failed_messages_count,
|
||||
selectedDestinationHash === conversation.destination_hash,
|
||||
GlobalState.config.banished_effect_enabled && isBlocked(conversation.destination_hash),
|
||||
selectionMode,
|
||||
selectedHashes.has(conversation.destination_hash),
|
||||
]"
|
||||
class="flex cursor-pointer p-2 border-l-2 relative"
|
||||
class="flex cursor-pointer p-2 border-l-2 relative group conversation-item"
|
||||
:class="[
|
||||
conversation.destination_hash === selectedDestinationHash
|
||||
? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400'
|
||||
: 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600',
|
||||
selectedHashes.has(conversation.destination_hash)
|
||||
? 'bg-blue-50/50 dark:bg-blue-900/10'
|
||||
: '',
|
||||
]"
|
||||
@click="onConversationClick(conversation)"
|
||||
draggable="true"
|
||||
@click="
|
||||
selectionMode
|
||||
? toggleSelectConversation(conversation.destination_hash)
|
||||
: onConversationClick(conversation)
|
||||
"
|
||||
@contextmenu="onRightClick($event, conversation.destination_hash)"
|
||||
@dragstart="onDragStart($event, conversation.destination_hash)"
|
||||
>
|
||||
<!-- Selection Checkbox -->
|
||||
<div v-if="selectionMode" class="my-auto mr-3 px-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="selectedHashes.has(conversation.destination_hash)"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
@click.stop
|
||||
@change="toggleSelectConversation(conversation.destination_hash)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- banished overlay -->
|
||||
<div
|
||||
v-if="
|
||||
@@ -177,6 +411,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Context Menu -->
|
||||
<div
|
||||
v-if="contextMenu.show"
|
||||
v-click-outside="{ handler: () => (contextMenu.show = false), capture: true }"
|
||||
class="fixed z-[100] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
|
||||
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
|
||||
@click="bulkMarkAsRead"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="email-open-outline" class="size-4 text-gray-400" />
|
||||
<span class="font-medium">Mark as Read</span>
|
||||
</button>
|
||||
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
|
||||
<div
|
||||
class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
|
||||
>
|
||||
Move to Folder
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
|
||||
@click="moveSelectedToFolder(null)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="inbox-arrow-down" class="size-4 opacity-70" />
|
||||
<span>Uncategorized</span>
|
||||
</button>
|
||||
<div class="max-h-[200px] overflow-y-auto custom-scrollbar">
|
||||
<button
|
||||
v-for="folder in folders"
|
||||
:key="folder.id"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
|
||||
@click="moveSelectedToFolder(folder.id)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
|
||||
<span class="truncate">{{ folder.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
|
||||
@click="bulkDelete"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
|
||||
<span class="font-bold">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- loading more spinner -->
|
||||
<div v-if="isLoadingMore" class="p-4 text-center">
|
||||
<MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
|
||||
@@ -340,6 +626,7 @@
|
||||
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import LxmfUserIcon from "../LxmfUserIcon.vue";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
@@ -356,6 +643,14 @@ export default {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
folders: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
selectedFolderId: {
|
||||
type: [Number, String],
|
||||
default: null,
|
||||
},
|
||||
selectedDestinationHash: {
|
||||
type: String,
|
||||
required: true,
|
||||
@@ -414,11 +709,45 @@ export default {
|
||||
"ingest-paper-message",
|
||||
"load-more",
|
||||
"load-more-announces",
|
||||
"folder-click",
|
||||
"create-folder",
|
||||
"rename-folder",
|
||||
"delete-folder",
|
||||
"move-to-folder",
|
||||
"bulk-mark-as-read",
|
||||
"bulk-delete",
|
||||
"export-folders",
|
||||
"import-folders",
|
||||
],
|
||||
data() {
|
||||
let foldersExpanded = true;
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
foldersExpanded = localStorage.getItem("meshchatx_folders_expanded") !== "false";
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return {
|
||||
GlobalState,
|
||||
tab: "conversations",
|
||||
foldersExpanded,
|
||||
selectionMode: false,
|
||||
selectedHashes: new Set(),
|
||||
folderMenu: {
|
||||
show: false,
|
||||
},
|
||||
moveMenu: {
|
||||
show: false,
|
||||
},
|
||||
contextMenu: {
|
||||
show: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
targetHash: null,
|
||||
},
|
||||
draggedHash: null,
|
||||
dragOverFolderId: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -461,8 +790,126 @@ export default {
|
||||
hasUnreadConversations() {
|
||||
return this.conversations.some((c) => c.is_unread);
|
||||
},
|
||||
allSelected() {
|
||||
return this.conversations.length > 0 && this.selectedHashes.size === this.conversations.length;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
foldersExpanded(newVal) {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem("meshchatx_folders_expanded", newVal);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleSelectionMode() {
|
||||
this.selectionMode = !this.selectionMode;
|
||||
if (!this.selectionMode) {
|
||||
this.selectedHashes.clear();
|
||||
}
|
||||
},
|
||||
toggleSelectAll() {
|
||||
if (this.allSelected) {
|
||||
this.selectedHashes.clear();
|
||||
} else {
|
||||
this.conversations.forEach((c) => this.selectedHashes.add(c.destination_hash));
|
||||
}
|
||||
},
|
||||
toggleSelectConversation(hash) {
|
||||
if (this.selectedHashes.has(hash)) {
|
||||
this.selectedHashes.delete(hash);
|
||||
} else {
|
||||
this.selectedHashes.add(hash);
|
||||
}
|
||||
},
|
||||
onRightClick(event, hash) {
|
||||
event.preventDefault();
|
||||
if (this.selectionMode && !this.selectedHashes.has(hash)) {
|
||||
this.selectedHashes.add(hash);
|
||||
}
|
||||
this.contextMenu.x = event.clientX;
|
||||
this.contextMenu.y = event.clientY;
|
||||
this.contextMenu.targetHash = hash;
|
||||
this.contextMenu.show = true;
|
||||
},
|
||||
onFolderContextMenu(event) {
|
||||
event.preventDefault();
|
||||
// Show folder management menu
|
||||
},
|
||||
onDragStart(event, hash) {
|
||||
this.draggedHash = hash;
|
||||
event.dataTransfer.setData("text/plain", hash);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
},
|
||||
onDragOver(event, folderId) {
|
||||
event.preventDefault();
|
||||
this.dragOverFolderId = folderId;
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
},
|
||||
onDragLeave() {
|
||||
this.dragOverFolderId = null;
|
||||
},
|
||||
onDropOnFolder(event, folderId) {
|
||||
event.preventDefault();
|
||||
this.dragOverFolderId = null;
|
||||
const hash = event.dataTransfer.getData("text/plain");
|
||||
if (hash) {
|
||||
this.$emit("move-to-folder", {
|
||||
peer_hashes: [hash],
|
||||
folder_id: folderId,
|
||||
});
|
||||
}
|
||||
this.draggedHash = null;
|
||||
},
|
||||
async createFolder() {
|
||||
const name = await DialogUtils.prompt("Enter folder name", "New Folder");
|
||||
if (name) {
|
||||
this.$emit("create-folder", name);
|
||||
}
|
||||
},
|
||||
async renameFolder(folder) {
|
||||
const name = await DialogUtils.prompt("Rename folder", folder.name);
|
||||
if (name && name !== folder.name) {
|
||||
this.$emit("rename-folder", { id: folder.id, name });
|
||||
}
|
||||
},
|
||||
async deleteFolder(folder) {
|
||||
const confirmed = await DialogUtils.confirm(
|
||||
`Are you sure you want to delete the folder "${folder.name}"? Conversations will be moved to Uncategorized.`,
|
||||
"Delete Folder"
|
||||
);
|
||||
if (confirmed) {
|
||||
this.$emit("delete-folder", folder.id);
|
||||
}
|
||||
},
|
||||
bulkMarkAsRead() {
|
||||
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
|
||||
this.$emit("bulk-mark-as-read", hashes);
|
||||
this.contextMenu.show = false;
|
||||
this.moveMenu.show = false;
|
||||
this.folderMenu.show = false;
|
||||
if (this.selectionMode) this.toggleSelectionMode();
|
||||
},
|
||||
bulkDelete() {
|
||||
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
|
||||
this.$emit("bulk-delete", hashes);
|
||||
this.contextMenu.show = false;
|
||||
this.moveMenu.show = false;
|
||||
this.folderMenu.show = false;
|
||||
if (this.selectionMode) this.toggleSelectionMode();
|
||||
},
|
||||
moveSelectedToFolder(folderId) {
|
||||
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
|
||||
this.$emit("move-to-folder", { peer_hashes: hashes, folder_id: folderId });
|
||||
this.contextMenu.show = false;
|
||||
this.moveMenu.show = false;
|
||||
this.folderMenu.show = false;
|
||||
if (this.selectionMode) this.toggleSelectionMode();
|
||||
},
|
||||
isBlocked(destinationHash) {
|
||||
return this.blockedDestinations.some((b) => b.destination_hash === destinationHash);
|
||||
},
|
||||
|
||||
@@ -173,6 +173,167 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Maintenance & Data -->
|
||||
<section class="glass-card break-inside-avoid">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Maintenance</div>
|
||||
<h2>{{ $t("maintenance.title") }}</h2>
|
||||
<p>{{ $t("maintenance.description") }}</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-4">
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-maintenance border-red-200 dark:border-red-900/30 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/10 hover:bg-red-100 dark:hover:bg-red-900/20"
|
||||
@click="clearMessages"
|
||||
>
|
||||
<div class="flex flex-col items-start text-left">
|
||||
<div class="font-bold flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="message-remove" class="size-4" />
|
||||
{{ $t("maintenance.clear_messages") }}
|
||||
</div>
|
||||
<div class="text-xs opacity-80">
|
||||
{{ $t("maintenance.clear_messages_desc") }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-maintenance border-orange-200 dark:border-orange-900/30 text-orange-700 dark:text-orange-300 bg-orange-50 dark:bg-orange-900/10 hover:bg-orange-100 dark:hover:bg-orange-900/20"
|
||||
@click="clearAnnounces"
|
||||
>
|
||||
<div class="flex flex-col items-start text-left">
|
||||
<div class="font-bold flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="broadcast-off" class="size-4" />
|
||||
{{ $t("maintenance.clear_announces") }}
|
||||
</div>
|
||||
<div class="text-xs opacity-80">
|
||||
{{ $t("maintenance.clear_announces_desc") }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-maintenance border-indigo-200 dark:border-indigo-900/30 text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/10 hover:bg-indigo-100 dark:hover:bg-indigo-900/20"
|
||||
@click="clearNomadnetFavorites"
|
||||
>
|
||||
<div class="flex flex-col items-start text-left">
|
||||
<div class="font-bold flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="bookmark-remove" class="size-4" />
|
||||
{{ $t("maintenance.clear_nomadnet_favs") }}
|
||||
</div>
|
||||
<div class="text-xs opacity-80">
|
||||
{{ $t("maintenance.clear_nomadnet_favs_desc") }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
|
||||
@click="clearArchives"
|
||||
>
|
||||
<div class="flex flex-col items-start text-left">
|
||||
<div class="font-bold flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
|
||||
{{ $t("maintenance.clear_archives") }}
|
||||
</div>
|
||||
<div class="text-xs opacity-80">
|
||||
{{ $t("maintenance.clear_archives_desc") }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 pt-2 border-t border-gray-100 dark:border-zinc-800">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Automatic Backup Limit
|
||||
</div>
|
||||
<input
|
||||
v-model.number="config.backup_max_count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
class="input-field"
|
||||
@input="onBackupConfigChange"
|
||||
/>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
Number of automatic backups to keep.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-blue-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-blue-500 transition group"
|
||||
@click="exportMessages"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="export"
|
||||
class="size-6 text-blue-500 group-hover:scale-110 transition"
|
||||
/>
|
||||
<div class="text-sm font-bold">{{ $t("maintenance.export_messages") }}</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-emerald-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-emerald-500 transition group"
|
||||
@click="triggerImport"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="import"
|
||||
class="size-6 text-emerald-500 group-hover:scale-110 transition"
|
||||
/>
|
||||
<div class="text-sm font-bold">{{ $t("maintenance.import_messages") }}</div>
|
||||
</button>
|
||||
<input
|
||||
ref="importFile"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="importMessages"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 mt-2 pt-4 border-t border-gray-100 dark:border-zinc-800">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-purple-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-purple-500 transition group"
|
||||
@click="exportFolders"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="folder-export-outline"
|
||||
class="size-6 text-purple-500 group-hover:scale-110 transition"
|
||||
/>
|
||||
<div class="text-sm font-bold">Export Folders</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-indigo-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-indigo-500 transition group"
|
||||
@click="triggerFolderImport"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="folder-import-outline"
|
||||
class="size-6 text-indigo-500 group-hover:scale-110 transition"
|
||||
/>
|
||||
<div class="text-sm font-bold">Import Folders</div>
|
||||
</button>
|
||||
<input
|
||||
ref="importFolderFile"
|
||||
type="file"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
@change="importFolders"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Desktop / Electron Settings -->
|
||||
<section v-if="ElectronUtils.isElectron()" class="glass-card break-inside-avoid">
|
||||
<header class="glass-card__header">
|
||||
@@ -1313,6 +1474,17 @@ export default {
|
||||
);
|
||||
}, 1000);
|
||||
},
|
||||
async onBackupConfigChange() {
|
||||
if (this.saveTimeouts.backup) clearTimeout(this.saveTimeouts.backup);
|
||||
this.saveTimeouts.backup = setTimeout(async () => {
|
||||
await this.updateConfig(
|
||||
{
|
||||
backup_max_count: this.config.backup_max_count,
|
||||
},
|
||||
"backup_max_count"
|
||||
);
|
||||
}, 1000);
|
||||
},
|
||||
async flushArchivedPages() {
|
||||
if (
|
||||
!(await DialogUtils.confirm(
|
||||
@@ -1335,19 +1507,15 @@ export default {
|
||||
async onIsTransportEnabledChange() {
|
||||
if (this.config.is_transport_enabled) {
|
||||
try {
|
||||
const response = await window.axios.post("/api/v1/reticulum/enable-transport");
|
||||
ToastUtils.success(response.data.message);
|
||||
} catch (e) {
|
||||
await window.axios.post("/api/v1/reticulum/enable-transport");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to enable transport mode!");
|
||||
console.log(e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const response = await window.axios.post("/api/v1/reticulum/disable-transport");
|
||||
ToastUtils.success(response.data.message);
|
||||
} catch (e) {
|
||||
await window.axios.post("/api/v1/reticulum/disable-transport");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to disable transport mode!");
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -1358,13 +1526,130 @@ export default {
|
||||
this.reloadingRns = true;
|
||||
const response = await window.axios.post("/api/v1/reticulum/reload");
|
||||
ToastUtils.success(response.data.message);
|
||||
} catch (e) {
|
||||
ToastUtils.error(e.response?.data?.error || "Failed to reload Reticulum!");
|
||||
console.error(e);
|
||||
} catch {
|
||||
ToastUtils.error("Failed to reload Reticulum!");
|
||||
} finally {
|
||||
this.reloadingRns = false;
|
||||
}
|
||||
},
|
||||
async clearMessages() {
|
||||
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
|
||||
try {
|
||||
await window.axios.delete("/api/v1/maintenance/messages");
|
||||
ToastUtils.success(this.$t("maintenance.messages_cleared"));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("common.error"));
|
||||
}
|
||||
},
|
||||
async clearAnnounces() {
|
||||
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
|
||||
try {
|
||||
await window.axios.delete("/api/v1/maintenance/announces");
|
||||
ToastUtils.success(this.$t("maintenance.announces_cleared"));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("common.error"));
|
||||
}
|
||||
},
|
||||
async clearNomadnetFavorites() {
|
||||
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
|
||||
try {
|
||||
await window.axios.delete("/api/v1/maintenance/favourites", {
|
||||
params: { aspect: "nomadnetwork.node" },
|
||||
});
|
||||
ToastUtils.success(this.$t("maintenance.favourites_cleared"));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("common.error"));
|
||||
}
|
||||
},
|
||||
async clearArchives() {
|
||||
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
|
||||
try {
|
||||
await window.axios.delete("/api/v1/maintenance/archives");
|
||||
ToastUtils.success(this.$t("maintenance.archives_cleared"));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("common.error"));
|
||||
}
|
||||
},
|
||||
async exportMessages() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/maintenance/messages/export");
|
||||
const messages = response.data.messages;
|
||||
const dataStr = JSON.stringify({ messages }, null, 2);
|
||||
const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
|
||||
|
||||
const exportFileDefaultName = `meshchat_messages_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
const linkElement = document.createElement("a");
|
||||
linkElement.setAttribute("href", dataUri);
|
||||
linkElement.setAttribute("download", exportFileDefaultName);
|
||||
linkElement.click();
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("common.error"));
|
||||
}
|
||||
},
|
||||
triggerImport() {
|
||||
this.$refs.importFile.click();
|
||||
},
|
||||
async importMessages(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target.result);
|
||||
if (!data.messages) throw new Error("Invalid file format");
|
||||
|
||||
await window.axios.post("/api/v1/maintenance/messages/import", {
|
||||
messages: data.messages,
|
||||
});
|
||||
ToastUtils.success(this.$t("maintenance.import_success", { count: data.messages.length }));
|
||||
} catch {
|
||||
ToastUtils.error(this.$t("maintenance.import_failed"));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset input
|
||||
event.target.value = "";
|
||||
},
|
||||
async exportFolders() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/lxmf/folders/export");
|
||||
const dataStr = JSON.stringify(response.data, null, 2);
|
||||
const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
|
||||
const exportFileDefaultName = `meshchat_folders_${new Date().toISOString().slice(0, 10)}.json`;
|
||||
const linkElement = document.createElement("a");
|
||||
linkElement.setAttribute("href", dataUri);
|
||||
linkElement.setAttribute("download", exportFileDefaultName);
|
||||
linkElement.click();
|
||||
ToastUtils.success("Folders exported");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to export folders");
|
||||
}
|
||||
},
|
||||
triggerFolderImport() {
|
||||
this.$refs.importFolderFile.click();
|
||||
},
|
||||
async importFolders(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.target.result);
|
||||
if (!data.folders || !data.mappings) throw new Error("Invalid file format");
|
||||
|
||||
await window.axios.post("/api/v1/lxmf/folders/import", data);
|
||||
ToastUtils.success("Folders and mappings imported successfully");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to import folders");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset input
|
||||
event.target.value = "";
|
||||
},
|
||||
formatSecondsAgo: function (seconds) {
|
||||
return Utils.formatSecondsAgo(seconds);
|
||||
},
|
||||
@@ -1394,6 +1679,9 @@ export default {
|
||||
.input-field {
|
||||
@apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-2xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
|
||||
}
|
||||
.btn-maintenance {
|
||||
@apply w-full px-4 py-3 rounded-2xl border transition flex items-center justify-between;
|
||||
}
|
||||
.setting-toggle {
|
||||
@apply flex items-start gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 px-3 py-3;
|
||||
}
|
||||
|
||||
@@ -35,8 +35,16 @@
|
||||
<RouterLink
|
||||
v-for="tool in filteredTools"
|
||||
:key="tool.name"
|
||||
:to="tool.route"
|
||||
:class="['tool-card', 'glass-card', tool.customClass].filter(Boolean)"
|
||||
:to="tool.comingSoon ? '' : tool.route"
|
||||
:class="
|
||||
[
|
||||
'tool-card',
|
||||
'glass-card',
|
||||
tool.customClass,
|
||||
tool.comingSoon ? 'opacity-60 grayscale-[0.5] cursor-default' : '',
|
||||
].filter(Boolean)
|
||||
"
|
||||
@click="tool.comingSoon ? $event.preventDefault() : null"
|
||||
>
|
||||
<div :class="tool.iconBg">
|
||||
<MaterialDesignIcon v-if="tool.icon" :icon-name="tool.icon" class="w-6 h-6" />
|
||||
@@ -48,23 +56,33 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">{{ tool.title }}</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="tool-card__title">{{ tool.title }}</div>
|
||||
<span
|
||||
v-if="tool.comingSoon"
|
||||
class="px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-md border border-gray-200 dark:border-zinc-700"
|
||||
>
|
||||
Soon
|
||||
</span>
|
||||
</div>
|
||||
<div class="tool-card__description">
|
||||
{{ tool.description }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tool.extraAction" class="flex items-center gap-2">
|
||||
<a
|
||||
: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="tool.extraAction.icon" class="size-5" />
|
||||
</a>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||
<div v-if="!tool.comingSoon">
|
||||
<div v-if="tool.extraAction" class="flex items-center gap-2">
|
||||
<a
|
||||
: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="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" />
|
||||
</div>
|
||||
<MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -193,6 +211,30 @@ export default {
|
||||
icon: "open-in-new",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "rns-page-node",
|
||||
comingSoon: true,
|
||||
icon: "server-network",
|
||||
iconBg: "tool-card__icon bg-amber-50 text-amber-500 dark:bg-amber-900/30 dark:text-amber-200",
|
||||
titleKey: "tools.rns_page_node.title",
|
||||
descriptionKey: "tools.rns_page_node.description",
|
||||
},
|
||||
{
|
||||
name: "rns-tunnel",
|
||||
comingSoon: true,
|
||||
icon: "tunnel",
|
||||
iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
|
||||
titleKey: "tools.rns_tunnel.title",
|
||||
descriptionKey: "tools.rns_tunnel.description",
|
||||
},
|
||||
{
|
||||
name: "rns-filesync",
|
||||
comingSoon: true,
|
||||
icon: "folder-sync",
|
||||
iconBg: "tool-card__icon bg-emerald-50 text-emerald-500 dark:bg-emerald-900/30 dark:text-emerald-200",
|
||||
titleKey: "tools.rns_filesync.title",
|
||||
descriptionKey: "tools.rns_filesync.description",
|
||||
},
|
||||
{
|
||||
name: "debug-logs",
|
||||
route: { name: "debug-logs" },
|
||||
|
||||
@@ -212,7 +212,31 @@
|
||||
"confirm": "Bestätigen",
|
||||
"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"
|
||||
"no_results": "Keine Werkzeuge gefunden",
|
||||
"error": "Fehler"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Wartung & Daten",
|
||||
"description": "Verwalten Sie Ihre lokalen Daten, löschen Sie Caches und sichern Sie Konversationen.",
|
||||
"clear_messages": "Alle Nachrichten löschen",
|
||||
"clear_messages_desc": "Alle gesendeten und empfangenen Nachrichten dauerhaft löschen.",
|
||||
"clear_announces": "Alle Ankündigungen löschen",
|
||||
"clear_announces_desc": "Alle entdeckten Peers und Knoten aus dem Cache entfernen.",
|
||||
"clear_nomadnet_favs": "NomadNetwork-Favoriten löschen",
|
||||
"clear_nomadnet_favs_desc": "Alle gespeicherten NomadNetwork-Knoten aus den Favoriten entfernen.",
|
||||
"clear_archives": "Seitenarchive löschen",
|
||||
"clear_archives_desc": "Alle zwischengespeicherten NomadNetwork-Seiten löschen.",
|
||||
"export_messages": "Nachrichten exportieren",
|
||||
"export_messages_desc": "Alle Konversationen als JSON-Datei herunterladen.",
|
||||
"import_messages": "Nachrichten importieren",
|
||||
"import_messages_desc": "Konversationen aus einer JSON-Datei wiederherstellen.",
|
||||
"clear_confirm": "Sind Sie sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"messages_cleared": "Nachrichten erfolgreich gelöscht",
|
||||
"announces_cleared": "Ankündigungen erfolgreich gelöscht",
|
||||
"favourites_cleared": "Favoriten erfolgreich gelöscht",
|
||||
"archives_cleared": "Archive erfolgreich gelöscht",
|
||||
"import_success": "Erfolgreich {count} Nachrichten importiert",
|
||||
"import_failed": "Nachrichten konnten nicht importiert werden"
|
||||
},
|
||||
"identities": {
|
||||
"title": "Identitäten",
|
||||
@@ -695,6 +719,18 @@
|
||||
"title": "Papiernachricht",
|
||||
"description": "Erstellen und lesen Sie LXMF-signierte Papiernachrichten über QR-Codes."
|
||||
},
|
||||
"rns_page_node": {
|
||||
"title": "RNS Page-Knoten",
|
||||
"description": "Nomadnet-Micron-Seiten hosten und Dateien mühelos teilen."
|
||||
},
|
||||
"rns_tunnel": {
|
||||
"title": "RNS-Tunnel",
|
||||
"description": "Regulären IP-Verkehr über Reticulum-Netzwerkverbindungen tunneln."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNS-Dateisynchronisierung",
|
||||
"description": "Dateien effizient mit anderen Mesh-Peers teilen und synchronisieren."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy-Bots",
|
||||
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy."
|
||||
@@ -1051,7 +1087,11 @@
|
||||
"restart_start": "MeshChatX starten",
|
||||
"skip_setup": "Setup überspringen",
|
||||
"continue": "Weiter",
|
||||
"skip_confirm": "Sind Sie sicher, dass Sie das Setup überspringen möchten? Sie müssen Schnittstellen später manuell hinzufügen."
|
||||
"skip_confirm": "Sind Sie sicher, dass Sie das Setup überspringen möchten? Sie müssen Schnittstellen später manuell hinzufügen.",
|
||||
"discovery_question": "Möchten Sie die Community-Schnittstellenerkennung und Auto-Connect verwenden?",
|
||||
"discovery_desc": "Dies ermöglicht es MeshChatX, automatisch öffentliche Community-Knoten in Ihrer Nähe oder im Internet zu finden und sich mit ihnen zu verbinden.",
|
||||
"yes": "Ja, Erkennung verwenden",
|
||||
"no": "Nein, manuelle Einrichtung"
|
||||
},
|
||||
"command_palette": {
|
||||
"search_placeholder": "Befehle suchen, navigieren oder Peers finden...",
|
||||
|
||||
@@ -212,7 +212,31 @@
|
||||
"confirm": "Confirm",
|
||||
"delete_confirm": "Are you sure you want to delete this? This cannot be undone.",
|
||||
"search": "Search tools...",
|
||||
"no_results": "No tools found"
|
||||
"no_results": "No tools found",
|
||||
"error": "Error"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Maintenance & Data",
|
||||
"description": "Manage your local data, clear caches, and backup conversations.",
|
||||
"clear_messages": "Clear All Messages",
|
||||
"clear_messages_desc": "Permanently delete all sent and received messages.",
|
||||
"clear_announces": "Clear All Announces",
|
||||
"clear_announces_desc": "Remove all discovered peers and nodes from your cache.",
|
||||
"clear_nomadnet_favs": "Clear NomadNetwork Favorites",
|
||||
"clear_nomadnet_favs_desc": "Remove all saved NomadNetwork nodes from your favorites.",
|
||||
"clear_archives": "Clear Page Archives",
|
||||
"clear_archives_desc": "Delete all cached NomadNetwork pages.",
|
||||
"export_messages": "Export Messages",
|
||||
"export_messages_desc": "Download all conversations as a JSON file.",
|
||||
"import_messages": "Import Messages",
|
||||
"import_messages_desc": "Restore conversations from a JSON file.",
|
||||
"clear_confirm": "Are you sure? This action cannot be undone.",
|
||||
"messages_cleared": "Messages cleared successfully",
|
||||
"announces_cleared": "Announces cleared successfully",
|
||||
"favourites_cleared": "Favorites cleared successfully",
|
||||
"archives_cleared": "Archives cleared successfully",
|
||||
"import_success": "Successfully imported {count} messages",
|
||||
"import_failed": "Failed to import messages"
|
||||
},
|
||||
"identities": {
|
||||
"title": "Identities",
|
||||
@@ -695,6 +719,18 @@
|
||||
"title": "Paper Message",
|
||||
"description": "Generate and read LXMF signed paper messages via QR codes."
|
||||
},
|
||||
"rns_page_node": {
|
||||
"title": "RNS Page Node",
|
||||
"description": "Host Nomadnet micron pages and share files with ease."
|
||||
},
|
||||
"rns_tunnel": {
|
||||
"title": "RNS Tunnel",
|
||||
"description": "Tunnel regular IP traffic over Reticulum network links."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNS Filesync",
|
||||
"description": "Efficiently share and sync files with other mesh peers."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy Bots",
|
||||
"description": "Manage automated bots for echo, notes, and reminders using LXMFy."
|
||||
@@ -1051,7 +1087,11 @@
|
||||
"restart_start": "Start MeshChatX",
|
||||
"skip_setup": "Skip Setup",
|
||||
"continue": "Continue",
|
||||
"skip_confirm": "Are you sure you want to skip the setup? You'll need to manually add interfaces later."
|
||||
"skip_confirm": "Are you sure you want to skip the setup? You'll need to manually add interfaces later.",
|
||||
"discovery_question": "Do you want to use community interface discovering and auto-connect?",
|
||||
"discovery_desc": "This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet.",
|
||||
"yes": "Yes, use discovery",
|
||||
"no": "No, manual setup"
|
||||
},
|
||||
"command_palette": {
|
||||
"search_placeholder": "Search commands, navigate, or find peers...",
|
||||
|
||||
@@ -212,7 +212,31 @@
|
||||
"confirm": "Подтвердить",
|
||||
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
|
||||
"search": "Поиск инструментов...",
|
||||
"no_results": "Инструменты не найдены"
|
||||
"no_results": "Инструменты не найдены",
|
||||
"error": "Ошибка"
|
||||
},
|
||||
"maintenance": {
|
||||
"title": "Обслуживание и данные",
|
||||
"description": "Управляйте локальными данными, очищайте кэши и создавайте резервные копии разговоров.",
|
||||
"clear_messages": "Очистить все сообщения",
|
||||
"clear_messages_desc": "Навсегда удалить все отправленные и полученные сообщения.",
|
||||
"clear_announces": "Очистить все объявления",
|
||||
"clear_announces_desc": "Удалить всех обнаруженных участников и узлы из кэша.",
|
||||
"clear_nomadnet_favs": "Очистить избранное NomadNetwork",
|
||||
"clear_nomadnet_favs_desc": "Удалить все сохраненные узлы NomadNetwork из избранного.",
|
||||
"clear_archives": "Очистить архивы страниц",
|
||||
"clear_archives_desc": "Удалить все кэшированные страницы NomadNetwork.",
|
||||
"export_messages": "Экспортировать сообщения",
|
||||
"export_messages_desc": "Загрузить все разговоры в виде JSON-файла.",
|
||||
"import_messages": "Импортировать сообщения",
|
||||
"import_messages_desc": "Восстановить разговоры из JSON-файла.",
|
||||
"clear_confirm": "Вы уверены? Это действие нельзя отменить.",
|
||||
"messages_cleared": "Сообщения успешно очищены",
|
||||
"announces_cleared": "Объявления успешно очищены",
|
||||
"favourites_cleared": "Избранное успешно очищено",
|
||||
"archives_cleared": "Архивы успешно очищены",
|
||||
"import_success": "Успешно импортировано {count} сообщений",
|
||||
"import_failed": "Не удалось импортировать сообщения"
|
||||
},
|
||||
"identities": {
|
||||
"title": "Личности",
|
||||
@@ -695,6 +719,18 @@
|
||||
"title": "Бумажное сообщение",
|
||||
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
|
||||
},
|
||||
"rns_page_node": {
|
||||
"title": "RNS Page-узел",
|
||||
"description": "Хостинг микрон-страниц Nomadnet и удобный обмен файлами."
|
||||
},
|
||||
"rns_tunnel": {
|
||||
"title": "RNS-туннель",
|
||||
"description": "Туннелирование обычного IP-трафика через сеть Reticulum."
|
||||
},
|
||||
"rns_filesync": {
|
||||
"title": "RNS-файлообмен",
|
||||
"description": "Эффективный обмен и синхронизация файлов с другими узлами mesh."
|
||||
},
|
||||
"bots": {
|
||||
"title": "LXMFy Боты",
|
||||
"description": "Управление автоматизированными ботами для эха, заметок и напоминаний с помощью LXMFy."
|
||||
@@ -1051,7 +1087,11 @@
|
||||
"restart_start": "Запустить MeshChatX",
|
||||
"skip_setup": "Пропустить настройку",
|
||||
"continue": "Продолжить",
|
||||
"skip_confirm": "Вы уверены, что хотите пропустить настройку? Вам придется добавить интерфейсы позже вручную."
|
||||
"skip_confirm": "Вы уверены, что хотите пропустить настройку? Вам придется добавить интерфейсы позже вручную.",
|
||||
"discovery_question": "Вы хотите использовать обнаружение интерфейсов сообщества и автоподключение?",
|
||||
"discovery_desc": "Это позволяет MeshChatX автоматически находить и подключаться к публичным узлам сообщества рядом с вами или в интернете.",
|
||||
"yes": "Да, использовать обнаружение",
|
||||
"no": "Нет, ручная настройка"
|
||||
},
|
||||
"command_palette": {
|
||||
"search_placeholder": "Поиск команд, навигация или поиск узлов...",
|
||||
|
||||
@@ -168,6 +168,33 @@ select.input-field option {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
@keyframes spin-reverse {
|
||||
from {
|
||||
transform: rotate(360deg);
|
||||
|
||||
85
tests/backend/test_identity_restore.py
Normal file
85
tests/backend/test_identity_restore.py
Normal file
@@ -0,0 +1,85 @@
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import base64
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from meshchatx.src.backend.identity_manager import IdentityManager
|
||||
|
||||
|
||||
class TestIdentityRestore(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.identity_manager = IdentityManager(self.temp_dir)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
@patch("RNS.Identity")
|
||||
@patch("meshchatx.src.backend.identity_manager.DatabaseProvider")
|
||||
@patch("meshchatx.src.backend.identity_manager.DatabaseSchema")
|
||||
def test_restore_identity_from_bytes(
|
||||
self, mock_schema, mock_provider, mock_rns_identity
|
||||
):
|
||||
# Setup mock identity
|
||||
mock_id_instance = MagicMock()
|
||||
mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
|
||||
mock_id_instance.get_private_key.return_value = b"test_private_key"
|
||||
mock_rns_identity.from_bytes.return_value = mock_id_instance
|
||||
|
||||
identity_bytes = b"some_identity_bytes"
|
||||
result = self.identity_manager.restore_identity_from_bytes(identity_bytes)
|
||||
|
||||
identity_hash = mock_id_instance.hash.hex()
|
||||
self.assertEqual(result["hash"], identity_hash)
|
||||
self.assertEqual(result["display_name"], "Restored Identity")
|
||||
|
||||
# Verify files were created
|
||||
identity_dir = os.path.join(self.temp_dir, "identities", identity_hash)
|
||||
self.assertTrue(os.path.exists(identity_dir))
|
||||
self.assertTrue(os.path.exists(os.path.join(identity_dir, "identity")))
|
||||
self.assertTrue(os.path.exists(os.path.join(identity_dir, "metadata.json")))
|
||||
|
||||
# Verify private key was written
|
||||
with open(os.path.join(identity_dir, "identity"), "rb") as f:
|
||||
self.assertEqual(f.read(), b"test_private_key")
|
||||
|
||||
@patch("RNS.Identity")
|
||||
@patch("meshchatx.src.backend.identity_manager.DatabaseProvider")
|
||||
@patch("meshchatx.src.backend.identity_manager.DatabaseSchema")
|
||||
def test_restore_identity_from_base32(
|
||||
self, mock_schema, mock_provider, mock_rns_identity
|
||||
):
|
||||
# Setup mock identity
|
||||
mock_id_instance = MagicMock()
|
||||
mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
|
||||
mock_id_instance.get_private_key.return_value = b"test_private_key"
|
||||
mock_rns_identity.from_bytes.return_value = mock_id_instance
|
||||
|
||||
identity_bytes = b"some_identity_bytes"
|
||||
base32_value = base64.b32encode(identity_bytes).decode("utf-8")
|
||||
|
||||
result = self.identity_manager.restore_identity_from_base32(base32_value)
|
||||
|
||||
identity_hash = mock_id_instance.hash.hex()
|
||||
self.assertEqual(result["hash"], identity_hash)
|
||||
|
||||
# Verify from_bytes was called with the decoded bytes
|
||||
mock_rns_identity.from_bytes.assert_called_with(identity_bytes)
|
||||
|
||||
@patch("RNS.Identity")
|
||||
def test_restore_identity_invalid_bytes(self, mock_rns_identity):
|
||||
mock_rns_identity.from_bytes.return_value = None
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
self.identity_manager.restore_identity_from_bytes(b"invalid")
|
||||
self.assertIn("Could not load identity from bytes", str(cm.exception))
|
||||
|
||||
@patch("RNS.Identity")
|
||||
def test_restore_identity_invalid_base32(self, mock_rns_identity):
|
||||
with self.assertRaises(ValueError) as cm:
|
||||
self.identity_manager.restore_identity_from_base32("invalid-base32-!!!")
|
||||
self.assertIn("Invalid base32 identity", str(cm.exception))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
64
tests/backend/test_maintenance.py
Normal file
64
tests/backend/test_maintenance.py
Normal file
@@ -0,0 +1,64 @@
|
||||
import unittest
|
||||
from unittest.mock import MagicMock
|
||||
from meshchatx.src.backend.database.messages import MessageDAO
|
||||
from meshchatx.src.backend.database.announces import AnnounceDAO
|
||||
from meshchatx.src.backend.database.misc import MiscDAO
|
||||
|
||||
|
||||
class TestMaintenance(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.provider = MagicMock()
|
||||
self.messages_dao = MessageDAO(self.provider)
|
||||
self.announces_dao = AnnounceDAO(self.provider)
|
||||
self.misc_dao = MiscDAO(self.provider)
|
||||
|
||||
def test_delete_all_lxmf_messages(self):
|
||||
self.messages_dao.delete_all_lxmf_messages()
|
||||
self.assertEqual(self.provider.execute.call_count, 2)
|
||||
calls = self.provider.execute.call_args_list
|
||||
self.assertIn("DELETE FROM lxmf_messages", calls[0][0][0])
|
||||
self.assertIn("DELETE FROM lxmf_conversation_read_state", calls[1][0][0])
|
||||
|
||||
def test_delete_all_announces(self):
|
||||
# Test without aspect
|
||||
self.announces_dao.delete_all_announces()
|
||||
self.provider.execute.assert_called_with("DELETE FROM announces")
|
||||
|
||||
# Test with aspect
|
||||
self.announces_dao.delete_all_announces(aspect="test_aspect")
|
||||
self.provider.execute.assert_called_with(
|
||||
"DELETE FROM announces WHERE aspect = ?", ("test_aspect",)
|
||||
)
|
||||
|
||||
def test_delete_all_favourites(self):
|
||||
# Test without aspect
|
||||
self.announces_dao.delete_all_favourites()
|
||||
self.provider.execute.assert_called_with("DELETE FROM favourite_destinations")
|
||||
|
||||
# Test with aspect
|
||||
self.announces_dao.delete_all_favourites(aspect="test_aspect")
|
||||
self.provider.execute.assert_called_with(
|
||||
"DELETE FROM favourite_destinations WHERE aspect = ?", ("test_aspect",)
|
||||
)
|
||||
|
||||
def test_delete_archived_pages(self):
|
||||
self.misc_dao.delete_archived_pages()
|
||||
self.provider.execute.assert_called_with("DELETE FROM archived_pages")
|
||||
|
||||
def test_upsert_lxmf_message(self):
|
||||
msg_data = {
|
||||
"hash": "test_hash",
|
||||
"source_hash": "source",
|
||||
"destination_hash": "dest",
|
||||
"peer_hash": "peer",
|
||||
"content": "hello",
|
||||
}
|
||||
self.messages_dao.upsert_lxmf_message(msg_data)
|
||||
self.provider.execute.assert_called()
|
||||
args, _ = self.provider.execute.call_args
|
||||
self.assertIn("INSERT INTO lxmf_messages", args[0])
|
||||
self.assertIn("ON CONFLICT(hash) DO UPDATE SET", args[0])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -22,10 +22,14 @@ class TestMessageHandler(unittest.TestCase):
|
||||
|
||||
def test_delete_conversation(self):
|
||||
self.handler.delete_conversation("local", "dest")
|
||||
self.db.provider.execute.assert_called()
|
||||
args, kwargs = self.db.provider.execute.call_args
|
||||
self.assertIn("DELETE FROM lxmf_messages", args[0])
|
||||
self.assertIn("dest", args[1])
|
||||
self.assertEqual(self.db.provider.execute.call_count, 2)
|
||||
call_args_list = self.db.provider.execute.call_args_list
|
||||
first_call_args, _ = call_args_list[0]
|
||||
second_call_args, _ = call_args_list[1]
|
||||
self.assertIn("DELETE FROM lxmf_messages", first_call_args[0])
|
||||
self.assertIn("dest", first_call_args[1])
|
||||
self.assertIn("DELETE FROM lxmf_conversation_folders", second_call_args[0])
|
||||
self.assertIn("dest", second_call_args[1])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import { mount } from "@vue/test-utils";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import MessagesSidebar from "@/components/messages/MessagesSidebar.vue";
|
||||
|
||||
describe("MessagesSidebar.vue", () => {
|
||||
beforeEach(() => {
|
||||
// Mock localStorage
|
||||
global.localStorage = {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
clear: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
peers: {},
|
||||
conversations: [],
|
||||
@@ -38,11 +48,13 @@ describe("MessagesSidebar.vue", () => {
|
||||
|
||||
const wrapper = mountMessagesSidebar({ conversations });
|
||||
|
||||
const nameElement = wrapper.find(".truncate");
|
||||
const nameElement = wrapper.find(".conversation-item .truncate");
|
||||
expect(nameElement.exists()).toBe(true);
|
||||
expect(nameElement.text()).toContain("Long Name");
|
||||
|
||||
const previewElement = wrapper.findAll(".truncate").find((el) => el.text().includes("Message"));
|
||||
const previewElement = wrapper
|
||||
.findAll(".conversation-item .truncate")
|
||||
.find((el) => el.text().includes("Message"));
|
||||
expect(previewElement.exists()).toBe(true);
|
||||
});
|
||||
|
||||
@@ -60,7 +72,7 @@ describe("MessagesSidebar.vue", () => {
|
||||
expect(scrollContainer.exists()).toBe(true);
|
||||
expect(scrollContainer.classes()).toContain("overflow-y-auto");
|
||||
|
||||
const conversationItems = wrapper.findAll("div.overflow-y-auto .cursor-pointer");
|
||||
const conversationItems = wrapper.findAll(".conversation-item");
|
||||
expect(conversationItems.length).toBe(100);
|
||||
});
|
||||
|
||||
|
||||
@@ -106,7 +106,7 @@ describe("UI Performance and Memory Tests", () => {
|
||||
`Rendered ${numConvs} conversations in ${renderTime.toFixed(2)}ms, Memory growth: ${memGrowth.toFixed(2)}MB`
|
||||
);
|
||||
|
||||
expect(wrapper.findAll(".flex.cursor-pointer").length).toBe(numConvs);
|
||||
expect(wrapper.findAll(".conversation-item").length).toBe(numConvs);
|
||||
expect(renderTime).toBeLessThan(5000);
|
||||
expect(memGrowth).toBeLessThan(200); // Adjusted for JSDOM/Node.js overhead with 2000 items
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user