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 import hashes, serialization
|
||||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
from cryptography.x509.oid import NameOID
|
from cryptography.x509.oid import NameOID
|
||||||
|
from RNS.Discovery import InterfaceDiscovery
|
||||||
from serial.tools import list_ports
|
from serial.tools import list_ports
|
||||||
|
|
||||||
from meshchatx.src.backend.async_utils import AsyncUtils
|
from meshchatx.src.backend.async_utils import AsyncUtils
|
||||||
@@ -2152,8 +2153,37 @@ class ReticulumMeshChat:
|
|||||||
@routes.get("/api/v1/database/snapshots")
|
@routes.get("/api/v1/database/snapshots")
|
||||||
async def list_db_snapshots(request):
|
async def list_db_snapshots(request):
|
||||||
try:
|
try:
|
||||||
|
limit = int(request.query.get("limit", 100))
|
||||||
|
offset = int(request.query.get("offset", 0))
|
||||||
snapshots = self.database.list_snapshots(self.storage_dir)
|
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:
|
except Exception as e:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"status": "error", "message": str(e)},
|
{"status": "error", "message": str(e)},
|
||||||
@@ -2199,9 +2229,13 @@ class ReticulumMeshChat:
|
|||||||
@routes.get("/api/v1/database/backups")
|
@routes.get("/api/v1/database/backups")
|
||||||
async def list_db_backups(request):
|
async def list_db_backups(request):
|
||||||
try:
|
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")
|
backup_dir = os.path.join(self.storage_dir, "database-backups")
|
||||||
if not os.path.exists(backup_dir):
|
if not os.path.exists(backup_dir):
|
||||||
return web.json_response([])
|
return web.json_response(
|
||||||
|
{"backups": [], "total": 0, "limit": limit, "offset": offset},
|
||||||
|
)
|
||||||
|
|
||||||
backups = []
|
backups = []
|
||||||
for file in os.listdir(backup_dir):
|
for file in os.listdir(backup_dir):
|
||||||
@@ -2219,9 +2253,39 @@ class ReticulumMeshChat:
|
|||||||
).isoformat(),
|
).isoformat(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return web.json_response(
|
sorted_backups = sorted(
|
||||||
sorted(backups, key=lambda x: x["created_at"], reverse=True),
|
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:
|
except Exception as e:
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"status": "error", "message": str(e)},
|
{"status": "error", "message": str(e)},
|
||||||
@@ -3360,6 +3424,7 @@ class ReticulumMeshChat:
|
|||||||
),
|
),
|
||||||
"ply": self.get_package_version("ply"),
|
"ply": self.get_package_version("ply"),
|
||||||
"bcrypt": self.get_package_version("bcrypt"),
|
"bcrypt": self.get_package_version("bcrypt"),
|
||||||
|
"lxmfy": self.get_package_version("lxmfy"),
|
||||||
},
|
},
|
||||||
"storage_path": self.storage_path,
|
"storage_path": self.storage_path,
|
||||||
"database_path": self.database_path,
|
"database_path": self.database_path,
|
||||||
@@ -3939,6 +4004,62 @@ class ReticulumMeshChat:
|
|||||||
status=500,
|
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
|
# get config
|
||||||
@routes.get("/api/v1/config")
|
@routes.get("/api/v1/config")
|
||||||
async def config_get(request):
|
async def config_get(request):
|
||||||
@@ -4043,6 +4164,91 @@ class ReticulumMeshChat:
|
|||||||
|
|
||||||
return web.json_response({"discovery": discovery_config})
|
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
|
# enable transport mode
|
||||||
@routes.post("/api/v1/reticulum/enable-transport")
|
@routes.post("/api/v1/reticulum/enable-transport")
|
||||||
async def reticulum_enable_transport(request):
|
async def reticulum_enable_transport(request):
|
||||||
@@ -6920,6 +7126,12 @@ class ReticulumMeshChat:
|
|||||||
request.query.get("filter_has_attachments", "false"),
|
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
|
# get pagination params
|
||||||
try:
|
try:
|
||||||
@@ -6943,6 +7155,7 @@ class ReticulumMeshChat:
|
|||||||
filter_unread=filter_unread,
|
filter_unread=filter_unread,
|
||||||
filter_failed=filter_failed,
|
filter_failed=filter_failed,
|
||||||
filter_has_attachments=filter_has_attachments,
|
filter_has_attachments=filter_has_attachments,
|
||||||
|
folder_id=folder_id,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
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
|
# mark lxmf conversation as read
|
||||||
@routes.get("/api/v1/lxmf/conversations/{destination_hash}/mark-as-read")
|
@routes.get("/api/v1/lxmf/conversations/{destination_hash}/mark-as-read")
|
||||||
async def lxmf_conversations_mark_read(request):
|
async def lxmf_conversations_mark_read(request):
|
||||||
@@ -7806,7 +8136,7 @@ class ReticulumMeshChat:
|
|||||||
f"connect-src {' '.join(connect_sources)}; "
|
f"connect-src {' '.join(connect_sources)}; "
|
||||||
"media-src 'self' blob:; "
|
"media-src 'self' blob:; "
|
||||||
"worker-src 'self' blob:; "
|
"worker-src 'self' blob:; "
|
||||||
"frame-src 'self'; "
|
"frame-src 'self' https://reticulum.network; "
|
||||||
"object-src 'none'; "
|
"object-src 'none'; "
|
||||||
"base-uri 'self';"
|
"base-uri 'self';"
|
||||||
)
|
)
|
||||||
@@ -7922,17 +8252,24 @@ class ReticulumMeshChat:
|
|||||||
# (e.g. when running from a read-only AppImage)
|
# (e.g. when running from a read-only AppImage)
|
||||||
if self.current_context and hasattr(self.current_context, "docs_manager"):
|
if self.current_context and hasattr(self.current_context, "docs_manager"):
|
||||||
dm = self.current_context.docs_manager
|
dm = self.current_context.docs_manager
|
||||||
if (
|
|
||||||
dm.docs_dir
|
# Custom handler for reticulum docs to allow fallback to official website
|
||||||
and os.path.exists(dm.docs_dir)
|
async def reticulum_docs_handler(request):
|
||||||
and not dm.docs_dir.startswith(public_dir)
|
path = request.match_info.get("filename", "index.html")
|
||||||
):
|
if not path:
|
||||||
app.router.add_static(
|
path = "index.html"
|
||||||
"/reticulum-docs/",
|
if path.endswith("/"):
|
||||||
dm.docs_dir,
|
path += "index.html"
|
||||||
name="reticulum_docs_storage",
|
|
||||||
follow_symlinks=True,
|
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 (
|
if (
|
||||||
dm.meshchatx_docs_dir
|
dm.meshchatx_docs_dir
|
||||||
and os.path.exists(dm.meshchatx_docs_dir)
|
and os.path.exists(dm.meshchatx_docs_dir)
|
||||||
@@ -7978,7 +8315,8 @@ class ReticulumMeshChat:
|
|||||||
print(
|
print(
|
||||||
f"Performing scheduled auto-backup for {ctx.identity_hash}...",
|
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:
|
except Exception as e:
|
||||||
print(f"Auto-backup failed: {e}")
|
print(f"Auto-backup failed: {e}")
|
||||||
|
|
||||||
|
|||||||
@@ -240,7 +240,9 @@ class BotHandler:
|
|||||||
shutil.rmtree(storage_dir)
|
shutil.rmtree(storage_dir)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
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()
|
self._save_state()
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ class ConfigManager:
|
|||||||
"archives_max_storage_gb",
|
"archives_max_storage_gb",
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
self.backup_max_count = self.IntConfig(self, "backup_max_count", 5)
|
||||||
self.crawler_enabled = self.BoolConfig(self, "crawler_enabled", False)
|
self.crawler_enabled = self.BoolConfig(self, "crawler_enabled", False)
|
||||||
self.crawler_max_retries = self.IntConfig(self, "crawler_max_retries", 3)
|
self.crawler_max_retries = self.IntConfig(self, "crawler_max_retries", 3)
|
||||||
self.crawler_retry_delay_seconds = self.IntConfig(
|
self.crawler_retry_delay_seconds = self.IntConfig(
|
||||||
|
|||||||
@@ -211,14 +211,41 @@ class Database:
|
|||||||
"size": os.path.getsize(backup_path),
|
"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")
|
default_dir = os.path.join(storage_path, "database-backups")
|
||||||
os.makedirs(default_dir, exist_ok=True)
|
os.makedirs(default_dir, exist_ok=True)
|
||||||
if backup_path is None:
|
if backup_path is None:
|
||||||
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
|
||||||
backup_path = os.path.join(default_dir, f"backup-{timestamp}.zip")
|
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):
|
def create_snapshot(self, storage_path, name: str):
|
||||||
"""Creates a named snapshot of the database."""
|
"""Creates a named snapshot of the database."""
|
||||||
@@ -258,6 +285,29 @@ class Database:
|
|||||||
)
|
)
|
||||||
return sorted(snapshots, key=lambda x: x["created_at"], reverse=True)
|
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):
|
def restore_database(self, backup_path: str):
|
||||||
if not os.path.exists(backup_path):
|
if not os.path.exists(backup_path):
|
||||||
msg = f"Backup not found at {backup_path}"
|
msg = f"Backup not found at {backup_path}"
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ class AnnounceDAO:
|
|||||||
(destination_hash,),
|
(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(
|
def get_filtered_announces(
|
||||||
self,
|
self,
|
||||||
aspect=None,
|
aspect=None,
|
||||||
@@ -137,3 +146,12 @@ class AnnounceDAO:
|
|||||||
"DELETE FROM favourite_destinations WHERE destination_hash = ?",
|
"DELETE FROM favourite_destinations WHERE destination_hash = ?",
|
||||||
(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,),
|
(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):
|
def delete_lxmf_message_by_hash(self, message_hash):
|
||||||
self.provider.execute(
|
self.provider.execute(
|
||||||
"DELETE FROM lxmf_messages WHERE hash = ?",
|
"DELETE FROM lxmf_messages WHERE hash = ?",
|
||||||
(message_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):
|
def get_conversation_messages(self, destination_hash, limit=100, offset=0):
|
||||||
return self.provider.fetchall(
|
return self.provider.fetchall(
|
||||||
"SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
"SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
|
||||||
@@ -103,6 +119,22 @@ class MessageDAO:
|
|||||||
(destination_hash, now, now, now),
|
(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):
|
def is_conversation_unread(self, destination_hash):
|
||||||
row = self.provider.fetchone(
|
row = self.provider.fetchone(
|
||||||
"""
|
"""
|
||||||
@@ -290,3 +322,56 @@ class MessageDAO:
|
|||||||
last_viewed_at = last_viewed_at.replace(tzinfo=UTC)
|
last_viewed_at = last_viewed_at.replace(tzinfo=UTC)
|
||||||
|
|
||||||
return message_timestamp <= last_viewed_at.timestamp()
|
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:
|
class DatabaseSchema:
|
||||||
LATEST_VERSION = 35
|
LATEST_VERSION = 36
|
||||||
|
|
||||||
def __init__(self, provider: DatabaseProvider):
|
def __init__(self, provider: DatabaseProvider):
|
||||||
self.provider = provider
|
self.provider = provider
|
||||||
@@ -423,6 +423,24 @@ class DatabaseSchema:
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
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():
|
for table_name, create_sql in tables.items():
|
||||||
@@ -933,6 +951,32 @@ class DatabaseSchema:
|
|||||||
"ALTER TABLE contacts ADD COLUMN lxst_address TEXT DEFAULT NULL",
|
"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
|
# Update version in config
|
||||||
self._safe_execute(
|
self._safe_execute(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ class DocsManager:
|
|||||||
|
|
||||||
# Ensure docs directories exist
|
# Ensure docs directories exist
|
||||||
try:
|
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):
|
if not os.path.exists(d):
|
||||||
os.makedirs(d)
|
os.makedirs(d)
|
||||||
|
|
||||||
@@ -423,8 +428,6 @@ class DocsManager:
|
|||||||
|
|
||||||
def has_docs(self):
|
def has_docs(self):
|
||||||
# Check if index.html exists in the docs folder or if we have any versions
|
# Check if index.html exists in the docs folder or if we have any versions
|
||||||
if self.config.docs_downloaded.get():
|
|
||||||
return True
|
|
||||||
return (
|
return (
|
||||||
os.path.exists(os.path.join(self.docs_dir, "index.html"))
|
os.path.exists(os.path.join(self.docs_dir, "index.html"))
|
||||||
or len(self.get_available_versions()) > 0
|
or len(self.get_available_versions()) > 0
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ class MessageHandler:
|
|||||||
def delete_conversation(self, local_hash, destination_hash):
|
def delete_conversation(self, local_hash, destination_hash):
|
||||||
query = "DELETE FROM lxmf_messages WHERE peer_hash = ?"
|
query = "DELETE FROM lxmf_messages WHERE peer_hash = ?"
|
||||||
self.db.provider.execute(query, [destination_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):
|
def search_messages(self, local_hash, search_term):
|
||||||
like_term = f"%{search_term}%"
|
like_term = f"%{search_term}%"
|
||||||
@@ -54,6 +59,7 @@ class MessageHandler:
|
|||||||
filter_unread=False,
|
filter_unread=False,
|
||||||
filter_failed=False,
|
filter_failed=False,
|
||||||
filter_has_attachments=False,
|
filter_has_attachments=False,
|
||||||
|
folder_id=None,
|
||||||
limit=None,
|
limit=None,
|
||||||
offset=0,
|
offset=0,
|
||||||
):
|
):
|
||||||
@@ -66,6 +72,8 @@ class MessageHandler:
|
|||||||
con.custom_image as contact_image,
|
con.custom_image as contact_image,
|
||||||
i.icon_name, i.foreground_colour, i.background_colour,
|
i.icon_name, i.foreground_colour, i.background_colour,
|
||||||
r.last_read_at,
|
r.last_read_at,
|
||||||
|
f.id as folder_id,
|
||||||
|
fn.name as folder_name,
|
||||||
(SELECT COUNT(*) FROM lxmf_messages m_failed
|
(SELECT COUNT(*) FROM lxmf_messages m_failed
|
||||||
WHERE m_failed.peer_hash = m1.peer_hash AND m_failed.state = 'failed') as failed_count
|
WHERE m_failed.peer_hash = m1.peer_hash AND m_failed.state = 'failed') as failed_count
|
||||||
FROM lxmf_messages m1
|
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_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_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 = []
|
params = []
|
||||||
where_clauses = []
|
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:
|
if filter_unread:
|
||||||
where_clauses.append(
|
where_clauses.append(
|
||||||
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))",
|
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))",
|
||||||
|
|||||||
@@ -180,7 +180,161 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 -->
|
<!-- Community Interfaces -->
|
||||||
<div
|
<div
|
||||||
class="bg-gray-50 dark:bg-zinc-900 rounded-3xl p-3 border border-gray-100 dark:border-zinc-800"
|
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>
|
</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">
|
<p class="max-w-sm text-center">
|
||||||
{{ $t("tutorial.custom_interfaces_desc") }}
|
{{ $t("tutorial.custom_interfaces_desc") }}
|
||||||
</p>
|
</p>
|
||||||
@@ -479,7 +636,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto px-6 md:px-12 py-10">
|
<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">
|
<transition name="fade-slide" mode="out-in">
|
||||||
<!-- Step 1: Welcome -->
|
<!-- Step 1: Welcome -->
|
||||||
<div
|
<div
|
||||||
@@ -626,69 +783,284 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div v-if="discoveryOption === null" class="flex flex-col items-center gap-8 py-12">
|
||||||
<!-- Community Interfaces -->
|
|
||||||
<div
|
<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-account-search" color="blue" size="80"></v-icon>
|
||||||
<v-icon icon="mdi-web" color="blue" size="26"></v-icon>
|
<div class="text-3xl font-black text-gray-900 dark:text-white">
|
||||||
<span class="text-lg font-bold text-gray-900 dark:text-white">{{
|
{{
|
||||||
$t("tutorial.suggested_relays")
|
$t("tutorial.discovery_question") ||
|
||||||
}}</span>
|
"Do you want to use community interface discovering and auto-connect?"
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3 max-h-[320px] overflow-y-auto pr-3 custom-scrollbar">
|
<p class="text-xl text-gray-600 dark:text-zinc-400">
|
||||||
<div
|
{{
|
||||||
v-for="iface in communityInterfaces"
|
$t("tutorial.discovery_desc") ||
|
||||||
:key="iface.name"
|
"This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet."
|
||||||
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>
|
||||||
|
<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">
|
<v-progress-circular
|
||||||
<span class="font-bold text-gray-900 dark:text-white text-base">
|
v-if="savingDiscovery"
|
||||||
{{ iface.name }}
|
indeterminate
|
||||||
</span>
|
size="24"
|
||||||
<span class="text-xs text-gray-500 font-mono"
|
width="3"
|
||||||
>{{ iface.target_host }}:{{ iface.target_port }}</span
|
class="mr-3"
|
||||||
>
|
></v-progress-circular>
|
||||||
</div>
|
{{ $t("tutorial.yes") || "Yes, use discovery" }}
|
||||||
<div class="flex items-center gap-2">
|
</button>
|
||||||
<span
|
<button
|
||||||
v-if="iface.online"
|
type="button"
|
||||||
class="flex items-center gap-1.5 text-[9px] font-bold text-green-500 uppercase tracking-[0.2em]"
|
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'"
|
||||||
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
>
|
||||||
{{ $t("tutorial.online") }}
|
{{ $t("tutorial.no") || "No, manual setup" }}
|
||||||
</span>
|
</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
|
<button
|
||||||
|
v-if="interfacesWithLocation.length > 0"
|
||||||
type="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"
|
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.stop="selectCommunityInterface(iface)"
|
@click="mapAllDiscovered"
|
||||||
>
|
>
|
||||||
{{ $t("tutorial.use") }}
|
Map All ({{ interfacesWithLocation.length }})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Manual Setup -->
|
||||||
<div
|
<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">
|
<div class="text-center space-y-4">
|
||||||
<p class="text-base font-bold text-gray-900 dark:text-white">
|
<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") }}
|
{{ $t("tutorial.custom_interfaces") }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-2">
|
<p class="text-gray-600 dark:text-zinc-400">
|
||||||
{{ $t("tutorial.custom_interfaces_desc_page") }}
|
{{ $t("tutorial.custom_interfaces_desc_page") }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="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"
|
@click="gotoAddInterface"
|
||||||
>
|
>
|
||||||
{{ $t("tutorial.open_interfaces") }}
|
{{ $t("tutorial.open_interfaces") }}
|
||||||
@@ -925,6 +1297,12 @@ export default {
|
|||||||
communityInterfaces: [],
|
communityInterfaces: [],
|
||||||
loadingInterfaces: false,
|
loadingInterfaces: false,
|
||||||
interfaceAddedViaTutorial: false,
|
interfaceAddedViaTutorial: false,
|
||||||
|
discoveryOption: null,
|
||||||
|
discoveredInterfaces: [],
|
||||||
|
discoveredActive: [],
|
||||||
|
loadingDiscovered: false,
|
||||||
|
savingDiscovery: false,
|
||||||
|
discoveryInterval: null,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -937,10 +1315,25 @@ export default {
|
|||||||
config() {
|
config() {
|
||||||
return GlobalState.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() {
|
mounted() {
|
||||||
if (this.isPage) {
|
if (this.isPage) {
|
||||||
this.loadCommunityInterfaces();
|
this.loadCommunityInterfaces();
|
||||||
|
this.loadDiscoveredInterfaces();
|
||||||
|
this.discoveryInterval = setInterval(() => {
|
||||||
|
this.loadDiscoveredInterfaces();
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -970,7 +1363,16 @@ export default {
|
|||||||
this.visible = true;
|
this.visible = true;
|
||||||
this.currentStep = 1;
|
this.currentStep = 1;
|
||||||
this.interfaceAddedViaTutorial = false;
|
this.interfaceAddedViaTutorial = false;
|
||||||
|
this.discoveryOption = null;
|
||||||
await this.loadCommunityInterfaces();
|
await this.loadCommunityInterfaces();
|
||||||
|
await this.loadDiscoveredInterfaces();
|
||||||
|
|
||||||
|
if (this.discoveryInterval) {
|
||||||
|
clearInterval(this.discoveryInterval);
|
||||||
|
}
|
||||||
|
this.discoveryInterval = setInterval(() => {
|
||||||
|
this.loadDiscoveredInterfaces();
|
||||||
|
}, 5000);
|
||||||
},
|
},
|
||||||
async loadCommunityInterfaces() {
|
async loadCommunityInterfaces() {
|
||||||
this.loadingInterfaces = true;
|
this.loadingInterfaces = true;
|
||||||
@@ -983,6 +1385,85 @@ export default {
|
|||||||
this.loadingInterfaces = false;
|
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) {
|
async selectCommunityInterface(iface) {
|
||||||
try {
|
try {
|
||||||
await window.axios.post("/api/v1/reticulum/interfaces/add", {
|
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"
|
class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
|
||||||
>
|
>
|
||||||
<div
|
<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>
|
||||||
<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"
|
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">
|
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
|
||||||
Reticulum Network Stack
|
Reticulum Network Stack
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs font-mono font-bold text-gray-400 mt-1">
|
<div class="flex items-center gap-2 mt-1">
|
||||||
v{{ appInfo.rns_version }}
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -530,34 +566,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="snapshots.length > 0" class="grid gap-3 sm:grid-cols-2">
|
<div v-if="snapshots && snapshots.length > 0" class="space-y-4">
|
||||||
<div
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
v-for="snapshot in snapshots"
|
<div
|
||||||
:key="snapshot.path"
|
v-for="snapshot in snapshots"
|
||||||
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"
|
: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)"
|
|
||||||
>
|
>
|
||||||
Restore
|
<div class="flex flex-col min-w-0">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Auto Backups -->
|
<!-- 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="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div
|
<div
|
||||||
@@ -572,28 +649,69 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div v-if="autoBackups && autoBackups.length > 0" class="space-y-4">
|
||||||
<div
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
v-for="backup in autoBackups"
|
<div
|
||||||
:key="backup.path"
|
v-for="backup in autoBackups"
|
||||||
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"
|
:key="backup.path"
|
||||||
>
|
class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-blue-500/20 transition-all group"
|
||||||
<div class="flex flex-col">
|
|
||||||
<span
|
|
||||||
class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
|
|
||||||
>{{ backup.name }}</span
|
|
||||||
>
|
|
||||||
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
|
|
||||||
>{{ formatBytes(backup.size) }} • {{ backup.created_at }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
|
|
||||||
@click="restoreFromSnapshot(backup.path)"
|
|
||||||
>
|
>
|
||||||
Restore
|
<div class="flex flex-col min-w-0">
|
||||||
</button>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -704,6 +822,7 @@ export default {
|
|||||||
components: {},
|
components: {},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
Utils,
|
||||||
appInfo: null,
|
appInfo: null,
|
||||||
config: null,
|
config: null,
|
||||||
updateInterval: null,
|
updateInterval: null,
|
||||||
@@ -725,10 +844,16 @@ export default {
|
|||||||
restoreFile: null,
|
restoreFile: null,
|
||||||
snapshotName: "",
|
snapshotName: "",
|
||||||
snapshots: [],
|
snapshots: [],
|
||||||
|
snapshotsTotal: 0,
|
||||||
|
snapshotsOffset: 0,
|
||||||
|
snapshotsLimit: 3,
|
||||||
snapshotInProgress: false,
|
snapshotInProgress: false,
|
||||||
snapshotMessage: "",
|
snapshotMessage: "",
|
||||||
snapshotError: "",
|
snapshotError: "",
|
||||||
autoBackups: [],
|
autoBackups: [],
|
||||||
|
autoBackupsTotal: 0,
|
||||||
|
autoBackupsOffset: 0,
|
||||||
|
autoBackupsLimit: 3,
|
||||||
identityBackupMessage: "",
|
identityBackupMessage: "",
|
||||||
identityBackupError: "",
|
identityBackupError: "",
|
||||||
identityBase32: "",
|
identityBase32: "",
|
||||||
@@ -776,18 +901,74 @@ export default {
|
|||||||
methods: {
|
methods: {
|
||||||
async listSnapshots() {
|
async listSnapshots() {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get("/api/v1/database/snapshots");
|
const response = await window.axios.get("/api/v1/database/snapshots", {
|
||||||
this.snapshots = response.data;
|
params: {
|
||||||
|
limit: this.snapshotsLimit,
|
||||||
|
offset: this.snapshotsOffset,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.snapshots = response.data.snapshots;
|
||||||
|
this.snapshotsTotal = response.data.total;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log("Failed to list snapshots", e);
|
console.log("Failed to list snapshots", e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async listAutoBackups() {
|
async listAutoBackups() {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.get("/api/v1/database/backups");
|
const response = await window.axios.get("/api/v1/database/backups", {
|
||||||
this.autoBackups = response.data;
|
params: {
|
||||||
} catch (e) {
|
limit: this.autoBackupsLimit,
|
||||||
console.log("Failed to list auto-backups", e);
|
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() {
|
async createSnapshot() {
|
||||||
@@ -802,9 +983,8 @@ export default {
|
|||||||
this.snapshotMessage = "Snapshot created successfully";
|
this.snapshotMessage = "Snapshot created successfully";
|
||||||
this.snapshotName = "";
|
this.snapshotName = "";
|
||||||
await this.listSnapshots();
|
await this.listSnapshots();
|
||||||
} catch (e) {
|
} catch {
|
||||||
this.snapshotError = "Failed to create snapshot";
|
this.snapshotError = "Failed to create snapshot";
|
||||||
console.log(e);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.snapshotInProgress = false;
|
this.snapshotInProgress = false;
|
||||||
}
|
}
|
||||||
@@ -825,9 +1005,8 @@ export default {
|
|||||||
setTimeout(() => ElectronUtils.relaunch(), 2000);
|
setTimeout(() => ElectronUtils.relaunch(), 2000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch {
|
||||||
ToastUtils.error("Failed to restore snapshot");
|
ToastUtils.error("Failed to restore snapshot");
|
||||||
console.log(e);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async getAppInfo() {
|
async getAppInfo() {
|
||||||
@@ -856,9 +1035,8 @@ export default {
|
|||||||
await window.axios.post("/api/v1/app/integrity/acknowledge");
|
await window.axios.post("/api/v1/app/integrity/acknowledge");
|
||||||
ToastUtils.success("Integrity issues acknowledged");
|
ToastUtils.success("Integrity issues acknowledged");
|
||||||
await this.getAppInfo();
|
await this.getAppInfo();
|
||||||
} catch (e) {
|
} catch {
|
||||||
ToastUtils.error("Failed to acknowledge integrity issues");
|
ToastUtils.error("Failed to acknowledge integrity issues");
|
||||||
console.log(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1075,9 +1253,9 @@ export default {
|
|||||||
link.remove();
|
link.remove();
|
||||||
window.URL.revokeObjectURL(url);
|
window.URL.revokeObjectURL(url);
|
||||||
this.identityBackupMessage = "Identity downloaded. Keep it secret.";
|
this.identityBackupMessage = "Identity downloaded. Keep it secret.";
|
||||||
} catch (e) {
|
ToastUtils.success("Identity key file exported");
|
||||||
|
} catch {
|
||||||
this.identityBackupError = "Failed to download identity";
|
this.identityBackupError = "Failed to download identity";
|
||||||
console.log(e);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
async copyIdentityBase32() {
|
async copyIdentityBase32() {
|
||||||
@@ -1092,9 +1270,9 @@ export default {
|
|||||||
}
|
}
|
||||||
await navigator.clipboard.writeText(this.identityBase32);
|
await navigator.clipboard.writeText(this.identityBase32);
|
||||||
this.identityBase32Message = "Identity copied. Clear your clipboard after use.";
|
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";
|
this.identityBase32Error = "Failed to copy identity";
|
||||||
console.log(e);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onIdentityRestoreFileChange(event) {
|
onIdentityRestoreFileChange(event) {
|
||||||
@@ -1124,9 +1302,8 @@ export default {
|
|||||||
headers: { "Content-Type": "multipart/form-data" },
|
headers: { "Content-Type": "multipart/form-data" },
|
||||||
});
|
});
|
||||||
this.identityRestoreMessage = response.data.message || "Identity imported.";
|
this.identityRestoreMessage = response.data.message || "Identity imported.";
|
||||||
} catch (e) {
|
} catch {
|
||||||
this.identityRestoreError = "Identity restore failed";
|
this.identityRestoreError = "Identity restore failed";
|
||||||
console.log(e);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.identityRestoreInProgress = false;
|
this.identityRestoreInProgress = false;
|
||||||
}
|
}
|
||||||
@@ -1147,9 +1324,8 @@ export default {
|
|||||||
base32: this.identityRestoreBase32.trim(),
|
base32: this.identityRestoreBase32.trim(),
|
||||||
});
|
});
|
||||||
this.identityRestoreMessage = response.data.message || "Identity imported.";
|
this.identityRestoreMessage = response.data.message || "Identity imported.";
|
||||||
} catch (e) {
|
} catch {
|
||||||
this.identityRestoreError = "Identity restore failed";
|
this.identityRestoreError = "Identity restore failed";
|
||||||
console.log(e);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.identityRestoreInProgress = false;
|
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"
|
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="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
|
<div
|
||||||
v-if="showRestartReminder"
|
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"
|
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="glass-card space-y-4">
|
||||||
<div class="flex flex-wrap gap-3 items-center">
|
<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">
|
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
{{ $t("interfaces.manage") }}
|
{{ $t("interfaces.manage") }}
|
||||||
</div>
|
</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") }}
|
{{ $t("interfaces.title") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||||
@@ -111,136 +111,370 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="glass-card space-y-4">
|
||||||
<div class="flex flex-wrap gap-3 items-center">
|
<div class="flex flex-wrap gap-2">
|
||||||
<div class="flex-1">
|
<button
|
||||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
v-for="tab in ['overview', 'discovery']"
|
||||||
Discovery
|
:key="tab"
|
||||||
</div>
|
type="button"
|
||||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interface Discovery</div>
|
:class="tabChipClass(activeTab === tab)"
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
@click="activeTab = tab"
|
||||||
Publish your interfaces for others to find, or listen for announced entrypoints and
|
>
|
||||||
auto-connect to them.
|
<span v-if="tab === 'overview'">Overview</span>
|
||||||
</div>
|
<span v-else>Discovery Settings</span>
|
||||||
</div>
|
</button>
|
||||||
<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>
|
||||||
<div class="grid gap-4 md:grid-cols-2">
|
|
||||||
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
<div v-if="activeTab === 'overview'" class="space-y-4">
|
||||||
<div class="font-semibold text-gray-900 dark:text-white">Publish (Server)</div>
|
<div class="glass-card space-y-3">
|
||||||
<div>
|
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
Enable discovery while adding or editing an interface to broadcast reachable details.
|
Configured
|
||||||
Reticulum will sign and stamp announces automatically.
|
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
|
||||||
Requires LXMF in the Python environment. Transport is optional for publishing, but
|
<div
|
||||||
usually recommended so peers can connect back.
|
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>
|
</div>
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center">
|
<div class="glass-card space-y-3">
|
||||||
<div class="flex flex-col mr-auto">
|
<div class="flex items-center gap-3">
|
||||||
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
<div class="flex-1">
|
||||||
Discover Interfaces (Peer)
|
<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>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Listen for discovery announces and optionally auto-connect to available
|
Requires LXMF in the Python environment. Transport is optional for publishing,
|
||||||
interfaces.
|
but usually recommended so peers can connect back.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Toggle v-model="discoveryConfig.discover_interfaces" class="my-auto mx-2" />
|
<div class="space-y-3">
|
||||||
</div>
|
<div class="flex items-center">
|
||||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
<div class="flex flex-col mr-auto">
|
||||||
<div>
|
<div class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
Discover Interfaces (Peer)
|
||||||
Allowed Sources
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Listen for discovery announces and optionally auto-connect to available
|
||||||
|
interfaces.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Toggle v-model="discoveryConfig.discover_interfaces" class="my-auto mx-2" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
v-model="discoveryConfig.interface_discovery_sources"
|
<div>
|
||||||
type="text"
|
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
placeholder="Comma separated identity hashes"
|
Allowed Sources
|
||||||
class="input-field"
|
</div>
|
||||||
/>
|
<input
|
||||||
</div>
|
v-model="discoveryConfig.interface_discovery_sources"
|
||||||
<div>
|
type="text"
|
||||||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
placeholder="Comma separated identity hashes"
|
||||||
Required Stamp Value
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
Required Stamp Value
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="discoveryConfig.required_discovery_value"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
Auto-connect Slots
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model.number="discoveryConfig.autoconnect_discovered_interfaces"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
0 disables auto-connect.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
||||||
|
Network Identity Path
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
v-model="discoveryConfig.network_identity"
|
||||||
|
type="text"
|
||||||
|
placeholder="~/.reticulum/storage/identities/..."
|
||||||
|
class="input-field"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<div class="flex justify-end">
|
||||||
v-model.number="discoveryConfig.required_discovery_value"
|
<button
|
||||||
type="number"
|
type="button"
|
||||||
min="0"
|
class="primary-chip text-xs"
|
||||||
class="input-field"
|
:disabled="savingDiscovery"
|
||||||
/>
|
@click="saveDiscoveryConfig"
|
||||||
</div>
|
>
|
||||||
<div>
|
<MaterialDesignIcon
|
||||||
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
|
:icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
|
||||||
Auto-connect Slots
|
class="w-4 h-4"
|
||||||
|
:class="{ 'animate-spin-reverse': savingDiscovery }"
|
||||||
|
/>
|
||||||
|
<span class="ml-1">Save Discovery Settings</span>
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
|
||||||
<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>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,6 +520,10 @@ export default {
|
|||||||
network_identity: "",
|
network_identity: "",
|
||||||
},
|
},
|
||||||
savingDiscovery: false,
|
savingDiscovery: false,
|
||||||
|
discoveredInterfaces: [],
|
||||||
|
discoveredActive: [],
|
||||||
|
discoveryInterval: null,
|
||||||
|
activeTab: "overview",
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -357,19 +595,57 @@ export default {
|
|||||||
this.interfacesWithStats.forEach((iface) => types.add(iface.type));
|
this.interfacesWithStats.forEach((iface) => types.add(iface.type));
|
||||||
return Array.from(types).sort();
|
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() {
|
beforeUnmount() {
|
||||||
clearInterval(this.reloadInterval);
|
clearInterval(this.reloadInterval);
|
||||||
|
clearInterval(this.discoveryInterval);
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.loadInterfaces();
|
this.loadInterfaces();
|
||||||
this.updateInterfaceStats();
|
this.updateInterfaceStats();
|
||||||
this.loadDiscoveryConfig();
|
this.loadDiscoveryConfig();
|
||||||
|
this.loadDiscoveredInterfaces();
|
||||||
|
|
||||||
// update info every few seconds
|
// update info every few seconds
|
||||||
this.reloadInterval = setInterval(() => {
|
this.reloadInterval = setInterval(() => {
|
||||||
this.updateInterfaceStats();
|
this.updateInterfaceStats();
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
this.discoveryInterval = setInterval(() => {
|
||||||
|
this.loadDiscoveredInterfaces();
|
||||||
|
}, 5000);
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
relaunch() {
|
relaunch() {
|
||||||
@@ -506,6 +782,77 @@ export default {
|
|||||||
this.trackInterfaceChange();
|
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) {
|
parseBool(value) {
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
|
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
|
||||||
@@ -565,6 +912,39 @@ export default {
|
|||||||
this.savingDiscovery = false;
|
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) {
|
setStatusFilter(value) {
|
||||||
this.statusFilter = value;
|
this.statusFilter = value;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,13 @@
|
|||||||
|
|
||||||
<!-- offline/online toggle -->
|
<!-- offline/online toggle -->
|
||||||
<div class="flex items-center bg-gray-100 dark:bg-zinc-800 rounded-lg p-1">
|
<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
|
<button
|
||||||
:class="
|
:class="
|
||||||
!offlineEnabled
|
!offlineEnabled
|
||||||
@@ -1059,6 +1066,7 @@ import { fromCircle } from "ol/geom/Polygon";
|
|||||||
import { unByKey } from "ol/Observable";
|
import { unByKey } from "ol/Observable";
|
||||||
import Overlay from "ol/Overlay";
|
import Overlay from "ol/Overlay";
|
||||||
import GeoJSON from "ol/format/GeoJSON";
|
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 MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
import ToastUtils from "../../js/ToastUtils";
|
import ToastUtils from "../../js/ToastUtils";
|
||||||
import TileCache from "../../js/TileCache";
|
import TileCache from "../../js/TileCache";
|
||||||
@@ -1090,6 +1098,8 @@ export default {
|
|||||||
markerSource: null,
|
markerSource: null,
|
||||||
markerLayer: null,
|
markerLayer: null,
|
||||||
selectedMarker: null,
|
selectedMarker: null,
|
||||||
|
queryMarker: null,
|
||||||
|
discoveredMarkers: [],
|
||||||
|
|
||||||
// caching
|
// caching
|
||||||
cachingEnabled: true,
|
cachingEnabled: true,
|
||||||
@@ -1256,6 +1266,11 @@ export default {
|
|||||||
await this.fetchPeers();
|
await this.fetchPeers();
|
||||||
await this.fetchTelemetryMarkers();
|
await this.fetchTelemetryMarkers();
|
||||||
|
|
||||||
|
// Handle view modes
|
||||||
|
if (this.$route.query.view === "discovered") {
|
||||||
|
await this.mapDiscoveredNodes();
|
||||||
|
}
|
||||||
|
|
||||||
// Listen for websocket messages
|
// Listen for websocket messages
|
||||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||||
|
|
||||||
@@ -1264,10 +1279,29 @@ export default {
|
|||||||
const lat = parseFloat(this.$route.query.lat);
|
const lat = parseFloat(this.$route.query.lat);
|
||||||
const lon = parseFloat(this.$route.query.lon);
|
const lon = parseFloat(this.$route.query.lon);
|
||||||
const zoom = parseInt(this.$route.query.zoom || 15);
|
const zoom = parseInt(this.$route.query.zoom || 15);
|
||||||
|
const label = this.$route.query.label || "Target";
|
||||||
|
|
||||||
if (!isNaN(lat) && !isNaN(lon)) {
|
if (!isNaN(lat) && !isNaN(lon)) {
|
||||||
this.map.getView().setCenter(fromLonLat([lon, lat]));
|
this.map.getView().setCenter(fromLonLat([lon, lat]));
|
||||||
this.map.getView().setZoom(zoom);
|
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;
|
if (!this.markerSource) return;
|
||||||
this.markerSource.clear();
|
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) {
|
for (const t of this.telemetryList) {
|
||||||
const loc = t.telemetry?.location;
|
const loc = t.telemetry?.location;
|
||||||
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
|
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
|
||||||
|
|
||||||
|
const coord = fromLonLat([loc.longitude, loc.latitude]);
|
||||||
const feature = new Feature({
|
const feature = new Feature({
|
||||||
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
|
geometry: new Point(coord),
|
||||||
telemetry: t,
|
telemetry: t,
|
||||||
peer: this.peers[t.destination_hash],
|
peer: this.peers[t.destination_hash],
|
||||||
});
|
});
|
||||||
|
addFeatureToGroup(coord, feature);
|
||||||
this.markerSource.addFeature(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 }) {
|
createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
|
||||||
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
|
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
|
||||||
@@ -3042,6 +3141,59 @@ export default {
|
|||||||
params: { destinationHash: hash },
|
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>
|
</script>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
:class="{ 'hidden sm:flex': destinationHash }"
|
:class="{ 'hidden sm:flex': destinationHash }"
|
||||||
:conversations="conversations"
|
:conversations="conversations"
|
||||||
:peers="peers"
|
:peers="peers"
|
||||||
|
:folders="folders"
|
||||||
|
:selected-folder-id="selectedFolderId"
|
||||||
:selected-destination-hash="selectedPeer?.destination_hash"
|
:selected-destination-hash="selectedPeer?.destination_hash"
|
||||||
:conversation-search-term="conversationSearchTerm"
|
:conversation-search-term="conversationSearchTerm"
|
||||||
:filter-unread-only="filterUnreadOnly"
|
:filter-unread-only="filterUnreadOnly"
|
||||||
@@ -25,6 +27,15 @@
|
|||||||
@ingest-paper-message="openIngestPaperMessageModal"
|
@ingest-paper-message="openIngestPaperMessageModal"
|
||||||
@load-more="loadMoreConversations"
|
@load-more="loadMoreConversations"
|
||||||
@load-more-announces="loadMoreAnnounces"
|
@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
|
<div
|
||||||
@@ -141,6 +152,8 @@ export default {
|
|||||||
selectedPeer: null,
|
selectedPeer: null,
|
||||||
|
|
||||||
conversations: [],
|
conversations: [],
|
||||||
|
folders: [],
|
||||||
|
selectedFolderId: null,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
hasMoreConversations: true,
|
hasMoreConversations: true,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
@@ -201,11 +214,13 @@ export default {
|
|||||||
|
|
||||||
this.getConfig();
|
this.getConfig();
|
||||||
this.getConversations();
|
this.getConversations();
|
||||||
|
this.getFolders();
|
||||||
this.getLxmfDeliveryAnnounces();
|
this.getLxmfDeliveryAnnounces();
|
||||||
|
|
||||||
// update info every few seconds
|
// update info every few seconds
|
||||||
this.reloadInterval = setInterval(() => {
|
this.reloadInterval = setInterval(() => {
|
||||||
this.getConversations();
|
this.getConversations();
|
||||||
|
this.getFolders();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
// compose message if a destination hash was provided on page load
|
// compose message if a destination hash was provided on page load
|
||||||
@@ -395,6 +410,129 @@ export default {
|
|||||||
this.isLoadingMore = false;
|
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() {
|
async loadMoreConversations() {
|
||||||
if (this.isLoadingMore || !this.hasMoreConversations) return;
|
if (this.isLoadingMore || !this.hasMoreConversations) return;
|
||||||
this.isLoadingMore = true;
|
this.isLoadingMore = true;
|
||||||
@@ -414,6 +552,9 @@ export default {
|
|||||||
if (this.filterHasAttachmentsOnly) {
|
if (this.filterHasAttachmentsOnly) {
|
||||||
params.filter_has_attachments = true;
|
params.filter_has_attachments = true;
|
||||||
}
|
}
|
||||||
|
if (this.selectedFolderId !== null) {
|
||||||
|
params.folder_id = this.selectedFolderId;
|
||||||
|
}
|
||||||
return params;
|
return params;
|
||||||
},
|
},
|
||||||
updatePeerFromAnnounce: function (announce) {
|
updatePeerFromAnnounce: function (announce) {
|
||||||
|
|||||||
@@ -33,6 +33,145 @@
|
|||||||
v-if="tab === 'conversations'"
|
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"
|
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 -->
|
<!-- search + filters -->
|
||||||
<div
|
<div
|
||||||
v-if="conversations.length > 0 || isFilterActive"
|
v-if="conversations.length > 0 || isFilterActive"
|
||||||
@@ -46,6 +185,15 @@
|
|||||||
class="input-field flex-1"
|
class="input-field flex-1"
|
||||||
@input="onConversationSearchInput"
|
@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
|
<button
|
||||||
type="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"
|
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" />
|
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div class="flex flex-wrap gap-1">
|
||||||
<button type="button" :class="filterChipClasses(filterUnreadOnly)" @click="toggleFilter('unread')">
|
<button type="button" :class="filterChipClasses(filterUnreadOnly)" @click="toggleFilter('unread')">
|
||||||
{{ $t("messages.unread") }}
|
{{ $t("messages.unread") }}
|
||||||
@@ -96,15 +307,38 @@
|
|||||||
conversation.failed_messages_count,
|
conversation.failed_messages_count,
|
||||||
selectedDestinationHash === conversation.destination_hash,
|
selectedDestinationHash === conversation.destination_hash,
|
||||||
GlobalState.config.banished_effect_enabled && isBlocked(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="[
|
:class="[
|
||||||
conversation.destination_hash === selectedDestinationHash
|
conversation.destination_hash === selectedDestinationHash
|
||||||
? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400'
|
? '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',
|
: '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 -->
|
<!-- banished overlay -->
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -177,6 +411,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- loading more spinner -->
|
||||||
<div v-if="isLoadingMore" class="p-4 text-center">
|
<div v-if="isLoadingMore" class="p-4 text-center">
|
||||||
<MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
|
<MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
|
||||||
@@ -340,6 +626,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import Utils from "../../js/Utils";
|
import Utils from "../../js/Utils";
|
||||||
|
import DialogUtils from "../../js/DialogUtils";
|
||||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||||
import LxmfUserIcon from "../LxmfUserIcon.vue";
|
import LxmfUserIcon from "../LxmfUserIcon.vue";
|
||||||
import GlobalState from "../../js/GlobalState";
|
import GlobalState from "../../js/GlobalState";
|
||||||
@@ -356,6 +643,14 @@ export default {
|
|||||||
type: Array,
|
type: Array,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
folders: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
selectedFolderId: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
selectedDestinationHash: {
|
selectedDestinationHash: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -414,11 +709,45 @@ export default {
|
|||||||
"ingest-paper-message",
|
"ingest-paper-message",
|
||||||
"load-more",
|
"load-more",
|
||||||
"load-more-announces",
|
"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() {
|
data() {
|
||||||
|
let foldersExpanded = true;
|
||||||
|
try {
|
||||||
|
if (typeof localStorage !== "undefined") {
|
||||||
|
foldersExpanded = localStorage.getItem("meshchatx_folders_expanded") !== "false";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
GlobalState,
|
GlobalState,
|
||||||
tab: "conversations",
|
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: {
|
computed: {
|
||||||
@@ -461,8 +790,126 @@ export default {
|
|||||||
hasUnreadConversations() {
|
hasUnreadConversations() {
|
||||||
return this.conversations.some((c) => c.is_unread);
|
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: {
|
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) {
|
isBlocked(destinationHash) {
|
||||||
return this.blockedDestinations.some((b) => b.destination_hash === destinationHash);
|
return this.blockedDestinations.some((b) => b.destination_hash === destinationHash);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -173,6 +173,167 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Desktop / Electron Settings -->
|
||||||
<section v-if="ElectronUtils.isElectron()" class="glass-card break-inside-avoid">
|
<section v-if="ElectronUtils.isElectron()" class="glass-card break-inside-avoid">
|
||||||
<header class="glass-card__header">
|
<header class="glass-card__header">
|
||||||
@@ -1313,6 +1474,17 @@ export default {
|
|||||||
);
|
);
|
||||||
}, 1000);
|
}, 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() {
|
async flushArchivedPages() {
|
||||||
if (
|
if (
|
||||||
!(await DialogUtils.confirm(
|
!(await DialogUtils.confirm(
|
||||||
@@ -1335,19 +1507,15 @@ export default {
|
|||||||
async onIsTransportEnabledChange() {
|
async onIsTransportEnabledChange() {
|
||||||
if (this.config.is_transport_enabled) {
|
if (this.config.is_transport_enabled) {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.post("/api/v1/reticulum/enable-transport");
|
await window.axios.post("/api/v1/reticulum/enable-transport");
|
||||||
ToastUtils.success(response.data.message);
|
} catch {
|
||||||
} catch (e) {
|
|
||||||
ToastUtils.error("Failed to enable transport mode!");
|
ToastUtils.error("Failed to enable transport mode!");
|
||||||
console.log(e);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const response = await window.axios.post("/api/v1/reticulum/disable-transport");
|
await window.axios.post("/api/v1/reticulum/disable-transport");
|
||||||
ToastUtils.success(response.data.message);
|
} catch {
|
||||||
} catch (e) {
|
|
||||||
ToastUtils.error("Failed to disable transport mode!");
|
ToastUtils.error("Failed to disable transport mode!");
|
||||||
console.log(e);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1358,13 +1526,130 @@ export default {
|
|||||||
this.reloadingRns = true;
|
this.reloadingRns = true;
|
||||||
const response = await window.axios.post("/api/v1/reticulum/reload");
|
const response = await window.axios.post("/api/v1/reticulum/reload");
|
||||||
ToastUtils.success(response.data.message);
|
ToastUtils.success(response.data.message);
|
||||||
} catch (e) {
|
} catch {
|
||||||
ToastUtils.error(e.response?.data?.error || "Failed to reload Reticulum!");
|
ToastUtils.error("Failed to reload Reticulum!");
|
||||||
console.error(e);
|
|
||||||
} finally {
|
} finally {
|
||||||
this.reloadingRns = false;
|
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) {
|
formatSecondsAgo: function (seconds) {
|
||||||
return Utils.formatSecondsAgo(seconds);
|
return Utils.formatSecondsAgo(seconds);
|
||||||
},
|
},
|
||||||
@@ -1394,6 +1679,9 @@ export default {
|
|||||||
.input-field {
|
.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;
|
@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 {
|
.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;
|
@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
|
<RouterLink
|
||||||
v-for="tool in filteredTools"
|
v-for="tool in filteredTools"
|
||||||
:key="tool.name"
|
:key="tool.name"
|
||||||
:to="tool.route"
|
:to="tool.comingSoon ? '' : tool.route"
|
||||||
:class="['tool-card', 'glass-card', tool.customClass].filter(Boolean)"
|
: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">
|
<div :class="tool.iconBg">
|
||||||
<MaterialDesignIcon v-if="tool.icon" :icon-name="tool.icon" class="w-6 h-6" />
|
<MaterialDesignIcon v-if="tool.icon" :icon-name="tool.icon" class="w-6 h-6" />
|
||||||
@@ -48,23 +56,33 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1">
|
<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">
|
<div class="tool-card__description">
|
||||||
{{ tool.description }}
|
{{ tool.description }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tool.extraAction" class="flex items-center gap-2">
|
<div v-if="!tool.comingSoon">
|
||||||
<a
|
<div v-if="tool.extraAction" class="flex items-center gap-2">
|
||||||
:href="tool.extraAction.href"
|
<a
|
||||||
:target="tool.extraAction.target"
|
:href="tool.extraAction.href"
|
||||||
class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
|
:target="tool.extraAction.target"
|
||||||
@click.stop
|
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="tool.extraAction.icon" class="size-5" />
|
||||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
</a>
|
||||||
|
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
|
||||||
|
</div>
|
||||||
|
<MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
|
||||||
</div>
|
</div>
|
||||||
<MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -193,6 +211,30 @@ export default {
|
|||||||
icon: "open-in-new",
|
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",
|
name: "debug-logs",
|
||||||
route: { name: "debug-logs" },
|
route: { name: "debug-logs" },
|
||||||
|
|||||||
@@ -212,7 +212,31 @@
|
|||||||
"confirm": "Bestätigen",
|
"confirm": "Bestätigen",
|
||||||
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden.",
|
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden.",
|
||||||
"search": "Werkzeuge suchen...",
|
"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": {
|
"identities": {
|
||||||
"title": "Identitäten",
|
"title": "Identitäten",
|
||||||
@@ -695,6 +719,18 @@
|
|||||||
"title": "Papiernachricht",
|
"title": "Papiernachricht",
|
||||||
"description": "Erstellen und lesen Sie LXMF-signierte Papiernachrichten über QR-Codes."
|
"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": {
|
"bots": {
|
||||||
"title": "LXMFy-Bots",
|
"title": "LXMFy-Bots",
|
||||||
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy."
|
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy."
|
||||||
@@ -1051,7 +1087,11 @@
|
|||||||
"restart_start": "MeshChatX starten",
|
"restart_start": "MeshChatX starten",
|
||||||
"skip_setup": "Setup überspringen",
|
"skip_setup": "Setup überspringen",
|
||||||
"continue": "Weiter",
|
"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": {
|
"command_palette": {
|
||||||
"search_placeholder": "Befehle suchen, navigieren oder Peers finden...",
|
"search_placeholder": "Befehle suchen, navigieren oder Peers finden...",
|
||||||
|
|||||||
@@ -212,7 +212,31 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"delete_confirm": "Are you sure you want to delete this? This cannot be undone.",
|
"delete_confirm": "Are you sure you want to delete this? This cannot be undone.",
|
||||||
"search": "Search tools...",
|
"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": {
|
"identities": {
|
||||||
"title": "Identities",
|
"title": "Identities",
|
||||||
@@ -695,6 +719,18 @@
|
|||||||
"title": "Paper Message",
|
"title": "Paper Message",
|
||||||
"description": "Generate and read LXMF signed paper messages via QR codes."
|
"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": {
|
"bots": {
|
||||||
"title": "LXMFy Bots",
|
"title": "LXMFy Bots",
|
||||||
"description": "Manage automated bots for echo, notes, and reminders using LXMFy."
|
"description": "Manage automated bots for echo, notes, and reminders using LXMFy."
|
||||||
@@ -1051,7 +1087,11 @@
|
|||||||
"restart_start": "Start MeshChatX",
|
"restart_start": "Start MeshChatX",
|
||||||
"skip_setup": "Skip Setup",
|
"skip_setup": "Skip Setup",
|
||||||
"continue": "Continue",
|
"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": {
|
"command_palette": {
|
||||||
"search_placeholder": "Search commands, navigate, or find peers...",
|
"search_placeholder": "Search commands, navigate, or find peers...",
|
||||||
|
|||||||
@@ -212,7 +212,31 @@
|
|||||||
"confirm": "Подтвердить",
|
"confirm": "Подтвердить",
|
||||||
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
|
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
|
||||||
"search": "Поиск инструментов...",
|
"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": {
|
"identities": {
|
||||||
"title": "Личности",
|
"title": "Личности",
|
||||||
@@ -695,6 +719,18 @@
|
|||||||
"title": "Бумажное сообщение",
|
"title": "Бумажное сообщение",
|
||||||
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
|
"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": {
|
"bots": {
|
||||||
"title": "LXMFy Боты",
|
"title": "LXMFy Боты",
|
||||||
"description": "Управление автоматизированными ботами для эха, заметок и напоминаний с помощью LXMFy."
|
"description": "Управление автоматизированными ботами для эха, заметок и напоминаний с помощью LXMFy."
|
||||||
@@ -1051,7 +1087,11 @@
|
|||||||
"restart_start": "Запустить MeshChatX",
|
"restart_start": "Запустить MeshChatX",
|
||||||
"skip_setup": "Пропустить настройку",
|
"skip_setup": "Пропустить настройку",
|
||||||
"continue": "Продолжить",
|
"continue": "Продолжить",
|
||||||
"skip_confirm": "Вы уверены, что хотите пропустить настройку? Вам придется добавить интерфейсы позже вручную."
|
"skip_confirm": "Вы уверены, что хотите пропустить настройку? Вам придется добавить интерфейсы позже вручную.",
|
||||||
|
"discovery_question": "Вы хотите использовать обнаружение интерфейсов сообщества и автоподключение?",
|
||||||
|
"discovery_desc": "Это позволяет MeshChatX автоматически находить и подключаться к публичным узлам сообщества рядом с вами или в интернете.",
|
||||||
|
"yes": "Да, использовать обнаружение",
|
||||||
|
"no": "Нет, ручная настройка"
|
||||||
},
|
},
|
||||||
"command_palette": {
|
"command_palette": {
|
||||||
"search_placeholder": "Поиск команд, навигация или поиск узлов...",
|
"search_placeholder": "Поиск команд, навигация или поиск узлов...",
|
||||||
|
|||||||
@@ -168,6 +168,33 @@ select.input-field option {
|
|||||||
scrollbar-width: none;
|
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 {
|
@keyframes spin-reverse {
|
||||||
from {
|
from {
|
||||||
transform: rotate(360deg);
|
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):
|
def test_delete_conversation(self):
|
||||||
self.handler.delete_conversation("local", "dest")
|
self.handler.delete_conversation("local", "dest")
|
||||||
self.db.provider.execute.assert_called()
|
self.assertEqual(self.db.provider.execute.call_count, 2)
|
||||||
args, kwargs = self.db.provider.execute.call_args
|
call_args_list = self.db.provider.execute.call_args_list
|
||||||
self.assertIn("DELETE FROM lxmf_messages", args[0])
|
first_call_args, _ = call_args_list[0]
|
||||||
self.assertIn("dest", args[1])
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
import { mount } from "@vue/test-utils";
|
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";
|
import MessagesSidebar from "@/components/messages/MessagesSidebar.vue";
|
||||||
|
|
||||||
describe("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 = {
|
const defaultProps = {
|
||||||
peers: {},
|
peers: {},
|
||||||
conversations: [],
|
conversations: [],
|
||||||
@@ -38,11 +48,13 @@ describe("MessagesSidebar.vue", () => {
|
|||||||
|
|
||||||
const wrapper = mountMessagesSidebar({ conversations });
|
const wrapper = mountMessagesSidebar({ conversations });
|
||||||
|
|
||||||
const nameElement = wrapper.find(".truncate");
|
const nameElement = wrapper.find(".conversation-item .truncate");
|
||||||
expect(nameElement.exists()).toBe(true);
|
expect(nameElement.exists()).toBe(true);
|
||||||
expect(nameElement.text()).toContain("Long Name");
|
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);
|
expect(previewElement.exists()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +72,7 @@ describe("MessagesSidebar.vue", () => {
|
|||||||
expect(scrollContainer.exists()).toBe(true);
|
expect(scrollContainer.exists()).toBe(true);
|
||||||
expect(scrollContainer.classes()).toContain("overflow-y-auto");
|
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);
|
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`
|
`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(renderTime).toBeLessThan(5000);
|
||||||
expect(memGrowth).toBeLessThan(200); // Adjusted for JSDOM/Node.js overhead with 2000 items
|
expect(memGrowth).toBeLessThan(200); // Adjusted for JSDOM/Node.js overhead with 2000 items
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user