mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
26 Commits
3.5.0
...
fix-room-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95c874e643 | ||
|
|
d36c63f6b1 | ||
|
|
e8a4f5c349 | ||
|
|
b022aea71f | ||
|
|
5225a1c766 | ||
|
|
41400c0528 | ||
|
|
07928d930c | ||
|
|
26742d0c88 | ||
|
|
8b73bef30b | ||
|
|
4b583fe337 | ||
|
|
e6e7267eb1 | ||
|
|
36eeeae64d | ||
|
|
7c988ae3d0 | ||
|
|
1a0c4833d5 | ||
|
|
84c500d018 | ||
|
|
1960a16fb0 | ||
|
|
3580aeda5a | ||
|
|
bb97b983bb | ||
|
|
da31b67d54 | ||
|
|
d840159f9c | ||
|
|
9de4158a6c | ||
|
|
1e21644d74 | ||
|
|
df0ed8452b | ||
|
|
d4a5f0f728 | ||
|
|
3e2c48457d | ||
|
|
d4f518df0c |
@@ -296,9 +296,9 @@ cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
### Before Completing Changes
|
||||
### Before Completing Major Changes
|
||||
|
||||
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes.
|
||||
**Run `./scripts/all_quality.sh` before finishing major changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||
|
||||
## API Summary
|
||||
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
## [3.6.0] - 2026-03-22
|
||||
|
||||
Feature: Add incoming-packet analytics
|
||||
Feature: BYOPacket for analysis
|
||||
Feature: Add room activity to stats view
|
||||
Bugfix: Handle Heltec v3 serial noise
|
||||
Misc: Swap repeaters and room servers for better ordering
|
||||
|
||||
## [3.5.0] - 2026-03-19
|
||||
|
||||
Feature: Add room server alpha support
|
||||
|
||||
@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
|
||||
</details>
|
||||
|
||||
### meshcore (2.3.1) — MIT
|
||||
### meshcore (2.3.2) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
@@ -7,7 +7,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag room names for channels you don't have keys for yet
|
||||
* Search for hashtag channel names for channels you don't have keys for yet
|
||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
@@ -192,7 +192,7 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
|
||||
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
|
||||
|
||||
## Where To Go Next
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
|
||||
|
||||
## HTTPS
|
||||
|
||||
WebGPU room-finding requires a secure context when you are not on `localhost`.
|
||||
WebGPU channel-finding requires a secure context when you are not on `localhost`.
|
||||
|
||||
Generate a local cert and start the backend with TLS:
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ app/
|
||||
- Packet `path_len` values are hop counts, not byte counts.
|
||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
|
||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same channel reuse the loaded slot; new channels fill free slots up to the discovered channel capacity, then evict the least recently used cached channel.
|
||||
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
||||
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
||||
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
|
||||
|
||||
@@ -44,6 +44,7 @@ class MessageAckedPayload(TypedDict):
|
||||
message_id: int
|
||||
ack_count: int
|
||||
paths: NotRequired[list[MessagePath]]
|
||||
packet_id: NotRequired[int | None]
|
||||
|
||||
|
||||
class ToastPayload(TypedDict):
|
||||
|
||||
@@ -82,6 +82,21 @@ class FanoutManager:
|
||||
def __init__(self) -> None:
|
||||
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
|
||||
self._restart_locks: dict[str, asyncio.Lock] = {}
|
||||
self._bots_disabled_until_restart = False
|
||||
|
||||
def get_bots_disabled_source(self) -> str | None:
|
||||
"""Return why bot modules are unavailable, if at all."""
|
||||
from app.config import settings as server_settings
|
||||
|
||||
if server_settings.disable_bots:
|
||||
return "env"
|
||||
if self._bots_disabled_until_restart:
|
||||
return "until_restart"
|
||||
return None
|
||||
|
||||
def bots_disabled_effective(self) -> bool:
|
||||
"""Return True when bot modules should be treated as unavailable."""
|
||||
return self.get_bots_disabled_source() is not None
|
||||
|
||||
async def load_from_db(self) -> None:
|
||||
"""Read enabled fanout_configs and instantiate modules."""
|
||||
@@ -99,13 +114,14 @@ class FanoutManager:
|
||||
config_blob = cfg["config"]
|
||||
scope = cfg["scope"]
|
||||
|
||||
# Skip bot modules when bots are disabled server-wide
|
||||
if config_type == "bot":
|
||||
from app.config import settings as server_settings
|
||||
|
||||
if server_settings.disable_bots:
|
||||
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
|
||||
return
|
||||
# Skip bot modules when bots are disabled server-wide or until restart.
|
||||
if config_type == "bot" and self.bots_disabled_effective():
|
||||
logger.info(
|
||||
"Skipping bot module %s (bots disabled: %s)",
|
||||
config_id,
|
||||
self.get_bots_disabled_source(),
|
||||
)
|
||||
return
|
||||
|
||||
cls = _MODULE_TYPES.get(config_type)
|
||||
if cls is None:
|
||||
@@ -240,6 +256,26 @@ class FanoutManager:
|
||||
}
|
||||
return result
|
||||
|
||||
async def disable_bots_until_restart(self) -> str:
|
||||
"""Stop active bot modules and prevent them from starting again until restart."""
|
||||
source = self.get_bots_disabled_source()
|
||||
if source == "env":
|
||||
return source
|
||||
|
||||
self._bots_disabled_until_restart = True
|
||||
|
||||
from app.repository.fanout import _configs_cache
|
||||
|
||||
bot_ids = [
|
||||
config_id
|
||||
for config_id in list(self._modules)
|
||||
if _configs_cache.get(config_id, {}).get("type") == "bot"
|
||||
]
|
||||
for config_id in bot_ids:
|
||||
await self.remove_config(config_id)
|
||||
|
||||
return "until_restart"
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
fanout_manager = FanoutManager()
|
||||
|
||||
@@ -139,6 +139,18 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
@app.get("/{path:path}")
|
||||
async def serve_frontend(path: str):
|
||||
"""Serve frontend files, falling back to index.html for SPA routing."""
|
||||
if path == "api" or path.startswith("api/"):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"detail": (
|
||||
"API endpoint not found. If you are seeing this in response to a "
|
||||
"frontend request, you may be running a newer frontend with an older "
|
||||
"backend or vice versa. A full update is suggested."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
file_path = (frontend_dir / path).resolve()
|
||||
try:
|
||||
file_path.relative_to(frontend_dir)
|
||||
|
||||
@@ -266,7 +266,7 @@ class ContactNameHistory(BaseModel):
|
||||
|
||||
|
||||
class ContactActiveRoom(BaseModel):
|
||||
"""A channel/room where a contact has been active."""
|
||||
"""A channel where a contact has been active."""
|
||||
|
||||
channel_key: str
|
||||
channel_name: str
|
||||
@@ -413,6 +413,10 @@ class Message(BaseModel):
|
||||
acked: int = 0
|
||||
sender_name: str | None = None
|
||||
channel_name: str | None = None
|
||||
packet_id: int | None = Field(
|
||||
default=None,
|
||||
description="Representative raw packet row ID when archival raw bytes exist",
|
||||
)
|
||||
|
||||
|
||||
class MessagesAroundResponse(BaseModel):
|
||||
@@ -458,6 +462,21 @@ class RawPacketBroadcast(BaseModel):
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
|
||||
|
||||
class RawPacketDetail(BaseModel):
|
||||
"""Stored raw-packet detail returned by the packet API."""
|
||||
|
||||
id: int
|
||||
timestamp: int
|
||||
data: str = Field(description="Hex-encoded packet data")
|
||||
payload_type: str = Field(description="Packet type name (e.g. GROUP_TEXT, ADVERT)")
|
||||
snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB if available")
|
||||
rssi: int | None = Field(
|
||||
default=None, description="Received signal strength in dBm if available"
|
||||
)
|
||||
decrypted: bool = False
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
|
||||
@@ -814,4 +833,5 @@ class StatisticsResponse(BaseModel):
|
||||
total_outgoing: int
|
||||
contacts_heard: ContactActivityCounts
|
||||
repeaters_heard: ContactActivityCounts
|
||||
known_channels_active: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
|
||||
@@ -331,6 +331,12 @@ class MessageRepository:
|
||||
@staticmethod
|
||||
def _row_to_message(row: Any) -> Message:
|
||||
"""Convert a database row to a Message model."""
|
||||
packet_id = None
|
||||
if hasattr(row, "keys"):
|
||||
row_keys = row.keys()
|
||||
if "packet_id" in row_keys:
|
||||
packet_id = row["packet_id"]
|
||||
|
||||
return Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
@@ -345,6 +351,14 @@ class MessageRepository:
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
sender_name=row["sender_name"],
|
||||
packet_id=packet_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _message_select(message_alias: str = "messages") -> str:
|
||||
return (
|
||||
f"{message_alias}.*, "
|
||||
f"(SELECT MIN(id) FROM raw_packets WHERE message_id = {message_alias}.id) AS packet_id"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -363,7 +377,7 @@ class MessageRepository:
|
||||
) -> list[Message]:
|
||||
search_query = MessageRepository._parse_search_query(q) if q else None
|
||||
query = (
|
||||
"SELECT messages.* FROM messages "
|
||||
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
|
||||
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
||||
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
|
||||
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
||||
@@ -470,7 +484,8 @@ class MessageRepository:
|
||||
|
||||
# 1. Get the target message (must satisfy filters if provided)
|
||||
target_cursor = await db.conn.execute(
|
||||
f"SELECT * FROM messages WHERE id = ? AND {where_sql}",
|
||||
f"SELECT {MessageRepository._message_select('messages')} "
|
||||
f"FROM messages WHERE id = ? AND {where_sql}",
|
||||
(message_id, *base_params),
|
||||
)
|
||||
target_row = await target_cursor.fetchone()
|
||||
@@ -481,7 +496,7 @@ class MessageRepository:
|
||||
|
||||
# 2. Get context_size+1 messages before target (DESC)
|
||||
before_query = f"""
|
||||
SELECT * FROM messages WHERE {where_sql}
|
||||
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
|
||||
AND (received_at < ? OR (received_at = ? AND id < ?))
|
||||
ORDER BY received_at DESC, id DESC LIMIT ?
|
||||
"""
|
||||
@@ -500,7 +515,7 @@ class MessageRepository:
|
||||
|
||||
# 3. Get context_size+1 messages after target (ASC)
|
||||
after_query = f"""
|
||||
SELECT * FROM messages WHERE {where_sql}
|
||||
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
|
||||
AND (received_at > ? OR (received_at = ? AND id > ?))
|
||||
ORDER BY received_at ASC, id ASC LIMIT ?
|
||||
"""
|
||||
@@ -545,7 +560,7 @@ class MessageRepository:
|
||||
async def get_by_id(message_id: int) -> "Message | None":
|
||||
"""Look up a message by its ID."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM messages WHERE id = ?",
|
||||
f"SELECT {MessageRepository._message_select('messages')} FROM messages WHERE id = ?",
|
||||
(message_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
@@ -570,7 +585,9 @@ class MessageRepository:
|
||||
) -> "Message | None":
|
||||
"""Look up a message by its unique content fields."""
|
||||
query = """
|
||||
SELECT * FROM messages
|
||||
SELECT messages.*,
|
||||
(SELECT MIN(id) FROM raw_packets WHERE message_id = messages.id) AS packet_id
|
||||
FROM messages
|
||||
WHERE type = ? AND conversation_key = ? AND text = ?
|
||||
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
|
||||
"""
|
||||
|
||||
@@ -121,6 +121,18 @@ class RawPacketRepository:
|
||||
return None
|
||||
return row["message_id"]
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(packet_id: int) -> tuple[int, bytes, int, int | None] | None:
|
||||
"""Return a raw packet row as (id, data, timestamp, message_id)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp, message_id FROM raw_packets WHERE id = ?",
|
||||
(packet_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return (row["id"], bytes(row["data"]), row["timestamp"], row["message_id"])
|
||||
|
||||
@staticmethod
|
||||
async def prune_old_undecrypted(max_age_days: int) -> int:
|
||||
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""
|
||||
|
||||
@@ -270,6 +270,30 @@ class StatisticsRepository:
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _known_channels_active() -> dict[str, int]:
|
||||
"""Count distinct known channel keys with channel traffic in each time window."""
|
||||
now = int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour,
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours,
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week
|
||||
FROM messages m
|
||||
INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key)
|
||||
WHERE m.type = 'CHAN'
|
||||
""",
|
||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
return {
|
||||
"last_hour": row["last_hour"] or 0,
|
||||
"last_24_hours": row["last_24_hours"] or 0,
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _path_hash_width_24h() -> dict[str, int | float]:
|
||||
"""Count parsed raw packets from the last 24h by hop hash width."""
|
||||
@@ -396,6 +420,7 @@ class StatisticsRepository:
|
||||
# Activity windows
|
||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||
known_channels_active = await StatisticsRepository._known_channels_active()
|
||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||
|
||||
return {
|
||||
@@ -411,5 +436,6 @@ class StatisticsRepository:
|
||||
"total_outgoing": total_outgoing,
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"known_channels_active": known_channels_active,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
requested_name = request.name
|
||||
is_hashtag = requested_name.startswith("#")
|
||||
|
||||
# Reserve the canonical Public room so it cannot drift to another key,
|
||||
# Reserve the canonical Public channel so it cannot drift to another key,
|
||||
# and the well-known Public key cannot be renamed to something else.
|
||||
if is_public_channel_name(requested_name):
|
||||
if request.key:
|
||||
|
||||
@@ -9,8 +9,8 @@ import string
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings as server_settings
|
||||
from app.fanout.bot_exec import _analyze_bot_signature
|
||||
from app.fanout.manager import fanout_manager
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -325,6 +325,15 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
return {"messages": messages, "raw_packets": raw_packets}
|
||||
|
||||
|
||||
def _bot_system_disabled_detail() -> str | None:
|
||||
source = fanout_manager.get_bots_disabled_source()
|
||||
if source == "env":
|
||||
return "Bot system disabled by server configuration (MESHCORE_DISABLE_BOTS)"
|
||||
if source == "until_restart":
|
||||
return "Bot system disabled until the server restarts"
|
||||
return None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_fanout_configs() -> list[dict]:
|
||||
"""List all fanout configs."""
|
||||
@@ -340,8 +349,10 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
|
||||
)
|
||||
|
||||
if body.type == "bot" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
if body.type == "bot":
|
||||
disabled_detail = _bot_system_disabled_detail()
|
||||
if disabled_detail:
|
||||
raise HTTPException(status_code=403, detail=disabled_detail)
|
||||
|
||||
normalized_config = _validate_and_normalize_config(body.type, body.config)
|
||||
scope = _enforce_scope(body.type, body.scope)
|
||||
@@ -356,8 +367,6 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
|
||||
# Start the module if enabled
|
||||
if cfg["enabled"]:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.reload_config(cfg["id"])
|
||||
|
||||
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
|
||||
@@ -371,8 +380,10 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
if existing["type"] == "bot" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
if existing["type"] == "bot":
|
||||
disabled_detail = _bot_system_disabled_detail()
|
||||
if disabled_detail:
|
||||
raise HTTPException(status_code=403, detail=disabled_detail)
|
||||
|
||||
kwargs = {}
|
||||
if body.name is not None:
|
||||
@@ -390,8 +401,6 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
# Reload the module to pick up changes
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.reload_config(config_id)
|
||||
|
||||
logger.info("Updated fanout config %s", config_id)
|
||||
@@ -406,10 +415,24 @@ async def delete_fanout_config(config_id: str) -> dict:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
# Stop the module first
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.remove_config(config_id)
|
||||
await FanoutConfigRepository.delete(config_id)
|
||||
|
||||
logger.info("Deleted fanout config %s", config_id)
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/bots/disable-until-restart")
|
||||
async def disable_bots_until_restart() -> dict:
|
||||
"""Stop active bot modules and prevent them from running again until restart."""
|
||||
source = await fanout_manager.disable_bots_until_restart()
|
||||
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
return {
|
||||
"status": "ok",
|
||||
"bots_disabled": True,
|
||||
"bots_disabled_source": source,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
@@ -37,6 +37,8 @@ class HealthResponse(BaseModel):
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
fanout_statuses: dict[str, dict[str, str]] = {}
|
||||
bots_disabled: bool = False
|
||||
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||
basic_auth_enabled: bool = False
|
||||
|
||||
|
||||
def _clean_optional_str(value: object) -> str | None:
|
||||
@@ -46,6 +48,11 @@ def _clean_optional_str(value: object) -> str | None:
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _read_optional_bool_setting(name: str) -> bool:
|
||||
value = getattr(settings, name, False)
|
||||
return value if isinstance(value, bool) else False
|
||||
|
||||
|
||||
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
|
||||
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
|
||||
app_build_info = get_app_build_info()
|
||||
@@ -64,10 +71,14 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
|
||||
# Fanout module statuses
|
||||
fanout_statuses: dict[str, Any] = {}
|
||||
bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None
|
||||
try:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
fanout_statuses = fanout_manager.get_statuses()
|
||||
manager_bots_disabled_source = fanout_manager.get_bots_disabled_source()
|
||||
if manager_bots_disabled_source is not None:
|
||||
bots_disabled_source = manager_bots_disabled_source
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -118,7 +129,9 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
"fanout_statuses": fanout_statuses,
|
||||
"bots_disabled": settings.disable_bots,
|
||||
"bots_disabled": bots_disabled_source is not None,
|
||||
"bots_disabled_source": bots_disabled_source,
|
||||
"basic_auth_enabled": _read_optional_bool_setting("basic_auth_enabled"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||
from app.models import RawPacketDecryptedInfo, RawPacketDetail
|
||||
from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption
|
||||
from app.repository import ChannelRepository, RawPacketRepository
|
||||
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||
from app.websocket import broadcast_success
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -102,6 +103,45 @@ async def get_undecrypted_count() -> dict:
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.get("/{packet_id}", response_model=RawPacketDetail)
|
||||
async def get_raw_packet(packet_id: int) -> RawPacketDetail:
|
||||
"""Fetch one stored raw packet by row ID for on-demand inspection."""
|
||||
packet_row = await RawPacketRepository.get_by_id(packet_id)
|
||||
if packet_row is None:
|
||||
raise HTTPException(status_code=404, detail="Raw packet not found")
|
||||
|
||||
stored_packet_id, packet_data, packet_timestamp, message_id = packet_row
|
||||
packet_info = parse_packet(packet_data)
|
||||
payload_type_name = packet_info.payload_type.name if packet_info else "Unknown"
|
||||
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
if message_id is not None:
|
||||
message = await MessageRepository.get_by_id(message_id)
|
||||
if message is not None:
|
||||
if message.type == "CHAN":
|
||||
channel = await ChannelRepository.get_by_key(message.conversation_key)
|
||||
decrypted_info = RawPacketDecryptedInfo(
|
||||
channel_name=channel.name if channel else None,
|
||||
sender=message.sender_name,
|
||||
channel_key=message.conversation_key,
|
||||
contact_key=message.sender_key,
|
||||
)
|
||||
else:
|
||||
decrypted_info = RawPacketDecryptedInfo(
|
||||
sender=message.sender_name,
|
||||
contact_key=message.conversation_key,
|
||||
)
|
||||
|
||||
return RawPacketDetail(
|
||||
id=stored_packet_id,
|
||||
timestamp=packet_timestamp,
|
||||
data=packet_data.hex(),
|
||||
payload_type=payload_type_name,
|
||||
decrypted=message_id is not None,
|
||||
decrypted_info=decrypted_info,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/decrypt/historical", response_model=DecryptResult)
|
||||
async def decrypt_historical_packets(
|
||||
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response
|
||||
|
||||
@@ -238,6 +238,7 @@ async def _store_direct_message(
|
||||
sender_key=sender_key,
|
||||
outgoing=outgoing,
|
||||
sender_name=sender_name,
|
||||
packet_id=packet_id,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ def build_message_model(
|
||||
acked: int = 0,
|
||||
sender_name: str | None = None,
|
||||
channel_name: str | None = None,
|
||||
packet_id: int | None = None,
|
||||
) -> Message:
|
||||
"""Build a Message model with the canonical backend payload shape."""
|
||||
return Message(
|
||||
@@ -79,6 +80,7 @@ def build_message_model(
|
||||
acked=acked,
|
||||
sender_name=sender_name,
|
||||
channel_name=channel_name,
|
||||
packet_id=packet_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -131,6 +133,7 @@ def broadcast_message_acked(
|
||||
message_id: int,
|
||||
ack_count: int,
|
||||
paths: list[MessagePath] | None,
|
||||
packet_id: int | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Broadcast a message_acked payload."""
|
||||
@@ -140,6 +143,7 @@ def broadcast_message_acked(
|
||||
"message_id": message_id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [path.model_dump() for path in paths] if paths else [],
|
||||
"packet_id": packet_id,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -182,11 +186,16 @@ async def reconcile_duplicate_message(
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
|
||||
representative_packet_id = (
|
||||
existing_msg.packet_id if existing_msg.packet_id is not None else packet_id
|
||||
)
|
||||
|
||||
if existing_msg.outgoing or path is not None:
|
||||
broadcast_message_acked(
|
||||
message_id=existing_msg.id,
|
||||
ack_count=ack_count,
|
||||
paths=paths,
|
||||
packet_id=representative_packet_id,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
@@ -307,6 +316,7 @@ async def create_message_from_decrypted(
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
packet_id=packet_id,
|
||||
),
|
||||
broadcast_fn=broadcast_fn,
|
||||
realtime=realtime,
|
||||
|
||||
@@ -18,15 +18,18 @@ services:
|
||||
environment:
|
||||
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||
# Radio connection -- optional if you map just a single serial device above, as the app will autodetect
|
||||
|
||||
# Serial (USB)
|
||||
# MESHCORE_SERIAL_PORT: /dev/ttyUSB0
|
||||
# MESHCORE_SERIAL_BAUDRATE: 115200
|
||||
|
||||
# TCP
|
||||
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||
# MESHCORE_TCP_PORT: 4000
|
||||
|
||||
# Security
|
||||
# MESHCORE_DISABLE_BOTS: "true"
|
||||
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||
|
||||
# Logging
|
||||
# MESHCORE_LOG_LEVEL: INFO
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<meta name="theme-color" content="#111419" />
|
||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.5.0",
|
||||
"version": "3.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
|
||||
import { api } from './api';
|
||||
import { takePrefetchOrFetch } from './prefetch';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
useConversationNavigation,
|
||||
useRealtimeAppState,
|
||||
useBrowserNotifications,
|
||||
useFaviconBadge,
|
||||
useUnreadTitle,
|
||||
useRawPacketStatsSession,
|
||||
} from './hooks';
|
||||
import { AppShell } from './components/AppShell';
|
||||
@@ -22,6 +24,7 @@ import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
import { CONTACT_TYPE_ROOM } from './types';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
channelId: string;
|
||||
@@ -249,6 +252,21 @@ export function App() {
|
||||
} = useConversationMessages(activeConversation, targetMessageId);
|
||||
removeConversationMessagesRef.current = removeConversationMessages;
|
||||
|
||||
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
|
||||
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
|
||||
// so the display reflects the original send order rather than our radio's receipt order.
|
||||
const activeContactIsRoom =
|
||||
activeConversation?.type === 'contact' &&
|
||||
contacts.find((c) => c.public_key === activeConversation.id)?.type === CONTACT_TYPE_ROOM;
|
||||
const sortedMessages = useMemo(() => {
|
||||
if (!activeContactIsRoom || messages.length === 0) return messages;
|
||||
return [...messages].sort((a, b) => {
|
||||
const aTs = a.sender_timestamp ?? a.received_at;
|
||||
const bTs = b.sender_timestamp ?? b.received_at;
|
||||
return aTs !== bTs ? aTs - bTs : a.id - b.id;
|
||||
});
|
||||
}, [activeContactIsRoom, messages]);
|
||||
|
||||
const {
|
||||
unreadCounts,
|
||||
mentions,
|
||||
@@ -259,6 +277,8 @@ export function App() {
|
||||
markAllRead,
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
useFaviconBadge(unreadCounts, mentions, favorites);
|
||||
useUnreadTitle(unreadCounts, favorites);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeConversation?.type !== 'channel') {
|
||||
@@ -423,7 +443,7 @@ export function App() {
|
||||
config,
|
||||
health,
|
||||
favorites,
|
||||
messages,
|
||||
messages: sortedMessages,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
@@ -502,9 +522,7 @@ export function App() {
|
||||
onChannelCreate: handleCreateCrackedChannel,
|
||||
};
|
||||
const newMessageModalProps = {
|
||||
contacts,
|
||||
undecryptedCount,
|
||||
onSelectConversation: handleSelectConversationWithTargetReset,
|
||||
onCreateContact: handleCreateContact,
|
||||
onCreateChannel: handleCreateChannel,
|
||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
MessagesAroundResponse,
|
||||
MigratePreferencesRequest,
|
||||
MigratePreferencesResponse,
|
||||
RawPacket,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
@@ -247,6 +248,7 @@ export const api = {
|
||||
),
|
||||
|
||||
// Packets
|
||||
getPacket: (packetId: number) => fetchJson<RawPacket>(`/packets/${packetId}`),
|
||||
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
||||
decryptHistoricalPackets: (params: {
|
||||
key_type: 'channel' | 'contact';
|
||||
@@ -341,6 +343,14 @@ export const api = {
|
||||
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
disableBotsUntilRestart: () =>
|
||||
fetchJson<{
|
||||
status: string;
|
||||
bots_disabled: boolean;
|
||||
bots_disabled_source: 'env' | 'until_restart';
|
||||
}>('/fanout/bots/disable-until-restart', {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Statistics
|
||||
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ConversationPane } from './ConversationPane';
|
||||
import { NewMessageModal } from './NewMessageModal';
|
||||
import { ContactInfoPane } from './ContactInfoPane';
|
||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||
import { Toaster } from './ui/sonner';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import {
|
||||
@@ -283,12 +284,9 @@ export function AppShell({
|
||||
{...newMessageModalProps}
|
||||
open={showNewMessage}
|
||||
onClose={onCloseNewMessage}
|
||||
onSelectConversation={(conv) => {
|
||||
newMessageModalProps.onSelectConversation(conv);
|
||||
onCloseNewMessage();
|
||||
}}
|
||||
/>
|
||||
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
<Toaster position="top-right" />
|
||||
|
||||
@@ -45,8 +45,8 @@ export function ChannelFloodScopeOverrideModal({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regional Override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Room-level regional routing temporarily changes the radio flood scope before send and
|
||||
restores it after. This can noticeably slow room sends.
|
||||
Channel-level regional routing temporarily changes the radio flood scope before send and
|
||||
restores it after. This can noticeably slow channel sends.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -201,7 +201,9 @@ export function ChatHeader({
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success(
|
||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||
conversation.type === 'channel'
|
||||
? 'Channel key copied!'
|
||||
: 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
|
||||
@@ -242,8 +242,8 @@ export function ContactInfoPane({
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={analytics?.most_active_rooms ?? []}
|
||||
<MostActiveChannelsSection
|
||||
channels={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
@@ -515,8 +515,8 @@ export function ContactInfoPane({
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={analytics?.most_active_rooms ?? []}
|
||||
<MostActiveChannelsSection
|
||||
channels={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
@@ -588,23 +588,23 @@ function MessageStatsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function MostActiveRoomsSection({
|
||||
rooms,
|
||||
function MostActiveChannelsSection({
|
||||
channels,
|
||||
onNavigateToChannel,
|
||||
}: {
|
||||
rooms: ContactActiveRoom[];
|
||||
channels: ContactActiveRoom[];
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
}) {
|
||||
if (rooms.length === 0) {
|
||||
if (channels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
||||
<SectionLabel>Most Active Channels</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{rooms.map((room) => (
|
||||
<div key={room.channel_key} className="flex justify-between items-center text-sm">
|
||||
{channels.map((channel) => (
|
||||
<div key={channel.channel_key} className="flex justify-between items-center text-sm">
|
||||
<span
|
||||
className={
|
||||
onNavigateToChannel
|
||||
@@ -614,15 +614,15 @@ function MostActiveRoomsSection({
|
||||
role={onNavigateToChannel ? 'button' : undefined}
|
||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
onClick={() => onNavigateToChannel?.(channel.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || isPublicChannelKey(room.channel_key)
|
||||
? room.channel_name
|
||||
: `#${room.channel_name}`}
|
||||
{channel.channel_name.startsWith('#') || isPublicChannelKey(channel.channel_key)
|
||||
? channel.channel_name
|
||||
: `#${channel.channel_name}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{room.message_count.toLocaleString()} msg
|
||||
{room.message_count !== 1 ? 's' : ''}
|
||||
{channel.message_count.toLocaleString()} msg
|
||||
{channel.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -261,6 +261,7 @@ export function ConversationPane({
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { toast } from './ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
||||
|
||||
interface CrackedRoom {
|
||||
roomName: string;
|
||||
interface CrackedChannel {
|
||||
channelName: string;
|
||||
key: string;
|
||||
packetId: number;
|
||||
message: string;
|
||||
@@ -45,7 +45,7 @@ export function CrackerPanel({
|
||||
const [twoWordMode, setTwoWordMode] = useState(false);
|
||||
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
||||
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
||||
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
|
||||
const [crackedChannels, setCrackedChannels] = useState<CrackedChannel[]>([]);
|
||||
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
||||
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
||||
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
|
||||
@@ -325,14 +325,14 @@ export function CrackerPanel({
|
||||
return updated;
|
||||
});
|
||||
|
||||
const newRoom: CrackedRoom = {
|
||||
roomName: result.roomName,
|
||||
const newCracked: CrackedChannel = {
|
||||
channelName: result.roomName,
|
||||
key: result.key,
|
||||
packetId: nextId!,
|
||||
message: result.decryptedMessage || '',
|
||||
crackedAt: Date.now(),
|
||||
};
|
||||
setCrackedRooms((prev) => [...prev, newRoom]);
|
||||
setCrackedChannels((prev) => [...prev, newCracked]);
|
||||
|
||||
// Auto-add channel if not already exists
|
||||
const keyUpper = result.key.toUpperCase();
|
||||
@@ -580,20 +580,20 @@ export function CrackerPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cracked rooms list */}
|
||||
{crackedRooms.length > 0 && (
|
||||
{/* Cracked channels list */}
|
||||
{crackedChannels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
|
||||
<div className="space-y-1">
|
||||
{crackedRooms.map((room, i) => (
|
||||
{crackedChannels.map((channel, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
|
||||
>
|
||||
<span className="text-success font-medium">#{room.roomName}</span>
|
||||
<span className="text-success font-medium">#{channel.channelName}</span>
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
"{room.message.slice(0, 50)}
|
||||
{room.message.length > 50 ? '...' : ''}"
|
||||
"{channel.message.slice(0, 50)}
|
||||
{channel.message.length > 50 ? '...' : ''}"
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -604,16 +604,17 @@ export function CrackerPanel({
|
||||
<hr className="border-border" />
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
|
||||
force payloads as they arrive, testing room names up to the specified length to discover
|
||||
active rooms on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||
force payloads as they arrive, testing channel names up to the specified length to discover
|
||||
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||
way of knowing but try as if they are).
|
||||
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
||||
pick up messages it couldn't crack, attempting them at one longer length.
|
||||
<strong> Try word pairs</strong> will also try every combination of two dictionary words
|
||||
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
||||
dictionary pass; this can substantially increase search time.
|
||||
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
|
||||
if any historically captured packets will decrypt with that key.
|
||||
dictionary pass; this can substantially increase search time and also result in
|
||||
false-positives.
|
||||
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
|
||||
see if any historically captured packets will decrypt with that key.
|
||||
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
||||
may allow accelerated cracking and/or system instability.
|
||||
</p>
|
||||
|
||||
@@ -8,19 +8,23 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
|
||||
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { api } from '../api';
|
||||
import { formatTime, parseSenderFromText } from '../utils/messageParser';
|
||||
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
|
||||
import { getDirectContactRoute } from '../utils/pathUtils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { PathModal } from './PathModal';
|
||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
import { toast } from './ui/sonner';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
contacts: Contact[];
|
||||
channels?: Channel[];
|
||||
loading: boolean;
|
||||
loadingOlder?: boolean;
|
||||
hasOlderMessages?: boolean;
|
||||
@@ -153,6 +157,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
|
||||
const RESEND_WINDOW_SECONDS = 30;
|
||||
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
|
||||
const ANALYZE_PACKET_NOTICE =
|
||||
'This analyzer shows one stored full packet copy only. When multiple receives have identical payloads, the backend deduplicates them to a single stored packet and appends any additional receive paths onto the message path history instead of storing multiple full packet copies.';
|
||||
|
||||
function hasUnexpectedControlChars(text: string): boolean {
|
||||
for (const char of text) {
|
||||
@@ -173,6 +179,7 @@ function hasUnexpectedControlChars(text: string): boolean {
|
||||
export function MessageList({
|
||||
messages,
|
||||
contacts,
|
||||
channels = [],
|
||||
loading,
|
||||
loadingOlder = false,
|
||||
hasOlderMessages = false,
|
||||
@@ -199,10 +206,18 @@ export function MessageList({
|
||||
paths: MessagePath[];
|
||||
senderInfo: SenderInfo;
|
||||
messageId?: number;
|
||||
packetId?: number | null;
|
||||
isOutgoingChan?: boolean;
|
||||
} | null>(null);
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
|
||||
const [packetInspectorSource, setPacketInspectorSource] = useState<
|
||||
| { kind: 'packet'; packet: RawPacket }
|
||||
| { kind: 'loading'; message: string }
|
||||
| { kind: 'unavailable'; message: string }
|
||||
| null
|
||||
>(null);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
|
||||
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
|
||||
const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false);
|
||||
@@ -221,6 +236,43 @@ export function MessageList({
|
||||
// Track conversation key to detect when entire message set changes
|
||||
const prevConvKeyRef = useRef<string | null>(null);
|
||||
|
||||
const handleAnalyzePacket = useCallback(async (message: Message) => {
|
||||
if (message.packet_id == null) {
|
||||
setPacketInspectorSource({
|
||||
kind: 'unavailable',
|
||||
message:
|
||||
'No archival raw packet is available for this message, so packet analysis cannot be shown.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = packetCacheRef.current.get(message.packet_id);
|
||||
if (cached) {
|
||||
setPacketInspectorSource({ kind: 'packet', packet: cached });
|
||||
return;
|
||||
}
|
||||
|
||||
setPacketInspectorSource({ kind: 'loading', message: 'Loading packet analysis...' });
|
||||
|
||||
try {
|
||||
const packet = await api.getPacket(message.packet_id);
|
||||
packetCacheRef.current.set(message.packet_id, packet);
|
||||
setPacketInspectorSource({ kind: 'packet', packet });
|
||||
} catch (error) {
|
||||
const description = error instanceof Error ? error.message : 'Unknown error';
|
||||
const isMissing = error instanceof Error && /not found/i.test(error.message);
|
||||
if (!isMissing) {
|
||||
toast.error('Failed to load raw packet', { description });
|
||||
}
|
||||
setPacketInspectorSource({
|
||||
kind: 'unavailable',
|
||||
message: isMissing
|
||||
? 'The archival raw packet for this message is no longer available. It may have been purged from Settings > Database, so only the stored message and merged route history remain.'
|
||||
: `Could not load the archival raw packet for this message: ${description}`,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle scroll position AFTER render
|
||||
useLayoutEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
@@ -833,6 +885,8 @@ export function MessageList({
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -859,6 +913,8 @@ export function MessageList({
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -879,6 +935,7 @@ export function MessageList({
|
||||
paths: msg.paths!,
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
||||
});
|
||||
}}
|
||||
@@ -900,6 +957,7 @@ export function MessageList({
|
||||
paths: [],
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
isOutgoingChan: true,
|
||||
});
|
||||
}}
|
||||
@@ -997,9 +1055,31 @@ export function MessageList({
|
||||
contacts={contacts}
|
||||
config={config ?? null}
|
||||
messageId={selectedPath.messageId}
|
||||
packetId={selectedPath.packetId}
|
||||
isOutgoingChan={selectedPath.isOutgoingChan}
|
||||
isResendable={isSelectedMessageResendable}
|
||||
onResend={onResendChannelMessage}
|
||||
onAnalyzePacket={
|
||||
selectedPath.packetId != null
|
||||
? () => {
|
||||
const message = messages.find((entry) => entry.id === selectedPath.messageId);
|
||||
if (message) {
|
||||
void handleAnalyzePacket(message);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{packetInspectorSource && (
|
||||
<RawPacketInspectorDialog
|
||||
open={packetInspectorSource !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
|
||||
channels={channels}
|
||||
source={packetInspectorSource}
|
||||
title="Analyze Packet"
|
||||
description="On-demand raw packet analysis for a message-backed archival packet."
|
||||
notice={ANALYZE_PACKET_NOTICE}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Dice5 } from 'lucide-react';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,14 +15,12 @@ import { Checkbox } from './ui/checkbox';
|
||||
import { Button } from './ui/button';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
|
||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
||||
|
||||
interface NewMessageModalProps {
|
||||
open: boolean;
|
||||
contacts: Contact[];
|
||||
undecryptedCount: number;
|
||||
onClose: () => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||
@@ -32,18 +28,16 @@ interface NewMessageModalProps {
|
||||
|
||||
export function NewMessageModal({
|
||||
open,
|
||||
contacts,
|
||||
undecryptedCount,
|
||||
onClose,
|
||||
onSelectConversation,
|
||||
onCreateContact,
|
||||
onCreateChannel,
|
||||
onCreateHashtagChannel,
|
||||
}: NewMessageModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('existing');
|
||||
const [tab, setTab] = useState<Tab>('new-contact');
|
||||
const [name, setName] = useState('');
|
||||
const [contactKey, setContactKey] = useState('');
|
||||
const [roomKey, setRoomKey] = useState('');
|
||||
const [channelKey, setChannelKey] = useState('');
|
||||
const [tryHistorical, setTryHistorical] = useState(false);
|
||||
const [permitCapitals, setPermitCapitals] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -53,7 +47,7 @@ export function NewMessageModal({
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setContactKey('');
|
||||
setRoomKey('');
|
||||
setChannelKey('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
@@ -71,12 +65,12 @@ export function NewMessageModal({
|
||||
}
|
||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||
} else if (tab === 'new-room') {
|
||||
if (!name.trim() || !roomKey.trim()) {
|
||||
setError('Room name and key are required');
|
||||
} else if (tab === 'new-channel') {
|
||||
if (!name.trim() || !channelKey.trim()) {
|
||||
setError('Channel name and key are required');
|
||||
return;
|
||||
}
|
||||
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical);
|
||||
await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical);
|
||||
} else if (tab === 'hashtag') {
|
||||
const channelName = name.trim();
|
||||
const validationError = validateHashtagName(channelName);
|
||||
@@ -136,7 +130,7 @@ export function NewMessageModal({
|
||||
}
|
||||
};
|
||||
|
||||
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
|
||||
const showHistoricalOption = undecryptedCount > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -152,9 +146,8 @@ export function NewMessageModal({
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{tab === 'existing' && 'Select an existing contact to start a conversation'}
|
||||
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
||||
{tab === 'new-room' && 'Create a private room with a shared encryption key'}
|
||||
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
||||
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -167,53 +160,12 @@ export function NewMessageModal({
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="existing">Existing</TabsTrigger>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||
<TabsTrigger value="new-room">Room</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
|
||||
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="existing" className="mt-4">
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-md border">
|
||||
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
|
||||
) : (
|
||||
contacts
|
||||
.filter((contact) => contact.public_key.length === 64)
|
||||
.map((contact) => (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
onSelectConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(
|
||||
contact.name,
|
||||
contact.public_key,
|
||||
contact.last_advert
|
||||
),
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-name">Name</Label>
|
||||
@@ -235,23 +187,23 @@ export function NewMessageModal({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-room" className="mt-4 space-y-4">
|
||||
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room-name">Room Name</Label>
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="room-name"
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Room name"
|
||||
placeholder="Channel name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room-key">Room Key</Label>
|
||||
<Label htmlFor="channel-key">Channel Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="room-key"
|
||||
value={roomKey}
|
||||
onChange={(e) => setRoomKey(e.target.value)}
|
||||
id="channel-key"
|
||||
value={channelKey}
|
||||
onChange={(e) => setChannelKey(e.target.value)}
|
||||
placeholder="Pre-shared key (hex)"
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -265,7 +217,7 @@ export function NewMessageModal({
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
setRoomKey(hex);
|
||||
setChannelKey(hex);
|
||||
}}
|
||||
title="Generate random key"
|
||||
aria-label="Generate random key"
|
||||
@@ -299,7 +251,7 @@ export function NewMessageModal({
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in room key derivation</span>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground pl-7">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
@@ -353,11 +305,9 @@ export function NewMessageModal({
|
||||
{loading ? 'Creating...' : 'Create & Add Another'}
|
||||
</Button>
|
||||
)}
|
||||
{tab !== 'existing' && (
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -29,9 +29,11 @@ interface PathModalProps {
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
messageId?: number;
|
||||
packetId?: number | null;
|
||||
isOutgoingChan?: boolean;
|
||||
isResendable?: boolean;
|
||||
onResend?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
onAnalyzePacket?: () => void;
|
||||
}
|
||||
|
||||
export function PathModal({
|
||||
@@ -42,14 +44,17 @@ export function PathModal({
|
||||
contacts,
|
||||
config,
|
||||
messageId,
|
||||
packetId,
|
||||
isOutgoingChan,
|
||||
isResendable,
|
||||
onResend,
|
||||
onAnalyzePacket,
|
||||
}: PathModalProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
|
||||
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
|
||||
const hasPaths = paths.length > 0;
|
||||
const showAnalyzePacket = hasPaths && packetId != null && onAnalyzePacket;
|
||||
|
||||
// Resolve all paths
|
||||
const resolvedPaths = hasPaths
|
||||
@@ -90,6 +95,12 @@ export function PathModal({
|
||||
|
||||
{hasPaths && (
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-4">
|
||||
{showAnalyzePacket ? (
|
||||
<Button type="button" variant="outline" className="w-full" onClick={onAnalyzePacket}>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{/* Raw path summary */}
|
||||
<div className="text-sm">
|
||||
{paths.map((p, index) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
inspectRawPacketWithOptions,
|
||||
type PacketByteField,
|
||||
} from '../utils/rawPacketInspector';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
interface RawPacketDetailModalProps {
|
||||
@@ -16,6 +18,38 @@ interface RawPacketDetailModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type RawPacketInspectorDialogSource =
|
||||
| {
|
||||
kind: 'packet';
|
||||
packet: RawPacket;
|
||||
}
|
||||
| {
|
||||
kind: 'paste';
|
||||
}
|
||||
| {
|
||||
kind: 'loading';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: 'unavailable';
|
||||
message: string;
|
||||
};
|
||||
|
||||
interface RawPacketInspectorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
channels: Channel[];
|
||||
source: RawPacketInspectorDialogSource;
|
||||
title: string;
|
||||
description: string;
|
||||
notice?: ReactNode;
|
||||
}
|
||||
|
||||
interface RawPacketInspectionPanelProps {
|
||||
packet: RawPacket;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
interface FieldPaletteEntry {
|
||||
box: string;
|
||||
boxActive: string;
|
||||
@@ -127,7 +161,7 @@ function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResol
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveGroupTextRoomName(
|
||||
function resolveGroupTextChannelName(
|
||||
payload: {
|
||||
channelHash?: string;
|
||||
cipherMac?: string;
|
||||
@@ -177,15 +211,15 @@ function getPacketContext(
|
||||
groupTextCandidates: GroupTextResolutionCandidate[]
|
||||
) {
|
||||
const fallbackSender = packet.decrypted_info?.sender ?? null;
|
||||
const fallbackRoom = packet.decrypted_info?.channel_name ?? null;
|
||||
const fallbackChannel = packet.decrypted_info?.channel_name ?? null;
|
||||
|
||||
if (!inspection.decoded?.payload.decoded) {
|
||||
if (!fallbackSender && !fallbackRoom) {
|
||||
if (!fallbackSender && !fallbackChannel) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: fallbackRoom ? 'Room' : 'Context',
|
||||
primary: fallbackRoom ?? 'Sender metadata available',
|
||||
title: fallbackChannel ? 'Channel' : 'Context',
|
||||
primary: fallbackChannel ?? 'Sender metadata available',
|
||||
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
|
||||
};
|
||||
}
|
||||
@@ -197,11 +231,12 @@ function getPacketContext(
|
||||
ciphertext?: string;
|
||||
decrypted?: { sender?: string; message?: string };
|
||||
};
|
||||
const roomName = fallbackRoom ?? resolveGroupTextRoomName(payload, groupTextCandidates);
|
||||
const channelName =
|
||||
fallbackChannel ?? resolveGroupTextChannelName(payload, groupTextCandidates);
|
||||
return {
|
||||
title: roomName ? 'Room' : 'Channel',
|
||||
title: 'Channel',
|
||||
primary:
|
||||
roomName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
||||
channelName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
||||
secondary: payload.decrypted?.sender
|
||||
? `Sender: ${payload.decrypted.sender}`
|
||||
: fallbackSender
|
||||
@@ -358,6 +393,36 @@ function renderFieldValue(field: PacketByteField) {
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePacketHex(input: string): string {
|
||||
return input.replace(/\s+/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function validatePacketHex(input: string): string | null {
|
||||
if (!input) {
|
||||
return 'Paste a packet hex string to analyze.';
|
||||
}
|
||||
if (!/^[0-9A-F]+$/.test(input)) {
|
||||
return 'Packet hex may only contain 0-9 and A-F characters.';
|
||||
}
|
||||
if (input.length % 2 !== 0) {
|
||||
return 'Packet hex must contain an even number of characters.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPastedRawPacket(packetHex: string): RawPacket {
|
||||
return {
|
||||
id: -1,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
data: packetHex,
|
||||
payload_type: 'Unknown',
|
||||
snr: null,
|
||||
rssi: null,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
}
|
||||
|
||||
function FieldBox({
|
||||
field,
|
||||
palette,
|
||||
@@ -500,145 +565,256 @@ function FieldSection({
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
|
||||
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
const groupTextCandidates = useMemo(
|
||||
() => buildGroupTextResolutionCandidates(channels),
|
||||
[channels]
|
||||
);
|
||||
const inspection = useMemo(
|
||||
() => (packet ? inspectRawPacketWithOptions(packet, decoderOptions) : null),
|
||||
() => inspectRawPacketWithOptions(packet, decoderOptions),
|
||||
[decoderOptions, packet]
|
||||
);
|
||||
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
||||
|
||||
const packetDisplayFields = useMemo(
|
||||
() => (inspection ? inspection.packetFields.filter((field) => field.name !== 'Payload') : []),
|
||||
[inspection]
|
||||
);
|
||||
const fullPacketFields = useMemo(
|
||||
() => (inspection ? buildDisplayFields(inspection) : []),
|
||||
() => inspection.packetFields.filter((field) => field.name !== 'Payload'),
|
||||
[inspection]
|
||||
);
|
||||
const fullPacketFields = useMemo(() => buildDisplayFields(inspection), [inspection]);
|
||||
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
|
||||
const packetContext = useMemo(
|
||||
() => (packet && inspection ? getPacketContext(packet, inspection, groupTextCandidates) : null),
|
||||
() => getPacketContext(packet, inspection, groupTextCandidates),
|
||||
[groupTextCandidates, inspection, packet]
|
||||
);
|
||||
const packetIsDecrypted = useMemo(
|
||||
() => (packet && inspection ? packetShowsDecryptedState(packet, inspection) : false),
|
||||
() => packetShowsDecryptedState(packet, inspection),
|
||||
[inspection, packet]
|
||||
);
|
||||
|
||||
if (!packet || !inspection) {
|
||||
return null;
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
{inspection.summary.summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatTimestamp(packet.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||
{packetContext.primary}
|
||||
</div>
|
||||
{packetContext.secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
{packetContext.secondary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<CompactMetaCard
|
||||
label="Packet"
|
||||
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
|
||||
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Transport"
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{inspection.validationErrors.length > 0 ? (
|
||||
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
|
||||
<div className="text-sm font-semibold text-foreground">Validation notes</div>
|
||||
<div className="mt-1.5 space-y-1 text-sm text-foreground">
|
||||
{inspection.validationErrors.map((error) => (
|
||||
<div key={error}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(packet.data);
|
||||
toast.success('Packet hex copied!');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2.5">
|
||||
<FullPacketHex
|
||||
packetHex={packet.data}
|
||||
fields={fullPacketFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<FieldSection
|
||||
title="Packet fields"
|
||||
fields={packetDisplayFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
|
||||
<FieldSection
|
||||
title="Payload fields"
|
||||
fields={inspection.payloadFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketInspectorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channels,
|
||||
source,
|
||||
title,
|
||||
description,
|
||||
notice,
|
||||
}: RawPacketInspectorDialogProps) {
|
||||
const [packetInput, setPacketInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || source.kind !== 'paste') {
|
||||
setPacketInput('');
|
||||
}
|
||||
}, [open, source.kind]);
|
||||
|
||||
const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]);
|
||||
const packetInputError = useMemo(
|
||||
() => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null),
|
||||
[normalizedPacketInput]
|
||||
);
|
||||
const analyzedPacket = useMemo(
|
||||
() =>
|
||||
normalizedPacketInput.length > 0 && packetInputError === null
|
||||
? buildPastedRawPacket(normalizedPacketInput)
|
||||
: null,
|
||||
[normalizedPacketInput, packetInputError]
|
||||
);
|
||||
|
||||
let body: ReactNode;
|
||||
if (source.kind === 'packet') {
|
||||
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
|
||||
} else if (source.kind === 'paste') {
|
||||
body = (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-3 pr-14">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="raw-packet-input">
|
||||
Packet Hex
|
||||
</label>
|
||||
<textarea
|
||||
id="raw-packet-input"
|
||||
value={packetInput}
|
||||
onChange={(event) => setPacketInput(event.target.value)}
|
||||
placeholder="Paste raw packet hex here..."
|
||||
className="min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{packetInputError ? (
|
||||
<div className="text-sm text-destructive">{packetInputError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{analyzedPacket ? (
|
||||
<RawPacketInspectionPanel packet={analyzedPacket} channels={channels} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
Paste a packet above to inspect it.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (source.kind === 'loading') {
|
||||
body = (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
{source.message}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 p-4 text-sm text-foreground">
|
||||
{source.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={packet !== null} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border px-5 py-3">
|
||||
<DialogTitle>Packet Details</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Detailed byte and field breakdown for the selected raw packet.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
{inspection.summary.summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatTimestamp(packet.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||
{packetContext.primary}
|
||||
</div>
|
||||
{packetContext.secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
{packetContext.secondary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<CompactMetaCard
|
||||
label="Packet"
|
||||
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
|
||||
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Transport"
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{inspection.validationErrors.length > 0 ? (
|
||||
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
|
||||
<div className="text-sm font-semibold text-foreground">Validation notes</div>
|
||||
<div className="mt-1.5 space-y-1 text-sm text-foreground">
|
||||
{inspection.validationErrors.map((error) => (
|
||||
<div key={error}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
|
||||
<div className="mt-2.5">
|
||||
<FullPacketHex
|
||||
packetHex={packet.data}
|
||||
fields={fullPacketFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
{notice ? (
|
||||
<div className="border-b border-border px-3 py-3 text-sm text-foreground">
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
{notice}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<FieldSection
|
||||
title="Packet fields"
|
||||
fields={packetDisplayFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
|
||||
<FieldSection
|
||||
title="Payload fields"
|
||||
fields={inspection.payloadFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{body}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
|
||||
if (!packet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RawPacketInspectorDialog
|
||||
open={packet !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && onClose()}
|
||||
channels={channels}
|
||||
source={{ kind: 'packet', packet }}
|
||||
title="Packet Details"
|
||||
description="Detailed byte and field breakdown for the selected raw packet."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketDetailModal } from './RawPacketDetailModal';
|
||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
import { Button } from './ui/button';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
import {
|
||||
RAW_PACKET_STATS_WINDOWS,
|
||||
@@ -385,6 +386,7 @@ export function RawPacketFeedView({
|
||||
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
|
||||
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
@@ -418,7 +420,6 @@ export function RawPacketFeedView({
|
||||
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||
[contacts, stats.newestNeighbors]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
||||
@@ -428,15 +429,26 @@ export function RawPacketFeedView({
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAnalyzeModalOpen(true)}
|
||||
>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
>
|
||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
@@ -599,10 +611,26 @@ export function RawPacketFeedView({
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<RawPacketDetailModal
|
||||
packet={selectedPacket}
|
||||
<RawPacketInspectorDialog
|
||||
open={selectedPacket !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setSelectedPacket(null)}
|
||||
channels={channels}
|
||||
onClose={() => setSelectedPacket(null)}
|
||||
source={
|
||||
selectedPacket
|
||||
? { kind: 'packet', packet: selectedPacket }
|
||||
: { kind: 'loading', message: 'Loading packet...' }
|
||||
}
|
||||
title="Packet Details"
|
||||
description="Detailed byte and field breakdown for the selected raw packet."
|
||||
/>
|
||||
|
||||
<RawPacketInspectorDialog
|
||||
open={analyzeModalOpen}
|
||||
onOpenChange={setAnalyzeModalOpen}
|
||||
channels={channels}
|
||||
source={{ kind: 'paste' }}
|
||||
title="Analyze Packet"
|
||||
description="Paste and inspect a raw packet hex string."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import type {
|
||||
Contact,
|
||||
PaneState,
|
||||
@@ -59,7 +60,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
useRememberedServerPassword('room', contact.public_key);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginMessage, setLoginMessage] = useState<string | null>(null);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [paneData, setPaneData] = useState<RoomPaneData>({
|
||||
@@ -74,7 +74,6 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
useEffect(() => {
|
||||
setLoginLoading(false);
|
||||
setLoginError(null);
|
||||
setLoginMessage(null);
|
||||
setAuthenticated(false);
|
||||
setAdvancedOpen(false);
|
||||
setPaneData({
|
||||
@@ -135,20 +134,15 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
setLoginMessage(null);
|
||||
try {
|
||||
const result = await api.roomLogin(contact.public_key, password);
|
||||
setAuthenticated(true);
|
||||
setLoginMessage(
|
||||
result.message ??
|
||||
(result.authenticated
|
||||
? 'Login confirmed. You can now send room messages and open admin tools.'
|
||||
: 'Login request sent, but authentication was not confirmed.')
|
||||
);
|
||||
if (result.authenticated) {
|
||||
toast.success('Room login confirmed');
|
||||
} else {
|
||||
toast(result.message ?? 'Room login was not confirmed');
|
||||
toast.warning('Room login not confirmed', {
|
||||
description: result.message ?? 'Room login was not confirmed',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
@@ -251,62 +245,69 @@ export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPa
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm font-medium">Room Server Controls</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Room access is active. Use the chat history and message box below to participate, and
|
||||
open admin tools when needed.
|
||||
</p>
|
||||
{loginMessage && <p className="text-xs text-muted-foreground">{loginMessage}</p>}
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row lg:w-auto">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
>
|
||||
Refresh ACL Login
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{advancedOpen && (
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<TelemetryPane
|
||||
data={paneData.status}
|
||||
state={paneStates.status}
|
||||
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
|
||||
/>
|
||||
<AclPane
|
||||
data={paneData.acl}
|
||||
state={paneStates.acl}
|
||||
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
|
||||
/>
|
||||
<LppTelemetryPane
|
||||
data={paneData.lppTelemetry}
|
||||
state={paneStates.lppTelemetry}
|
||||
onRefresh={() =>
|
||||
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
|
||||
}
|
||||
/>
|
||||
<ConsolePane
|
||||
history={consoleHistory}
|
||||
loading={consoleLoading}
|
||||
onSend={handleConsoleCommand}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
</div>
|
||||
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Room Server Tools</SheetTitle>
|
||||
<SheetDescription>
|
||||
Room server telemetry, ACL tools, sensor data, and CLI console
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="border-b border-border px-4 py-3 pr-14">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
|
||||
<p className="text-sm text-muted-foreground">{panelTitle}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
className="self-start sm:self-auto"
|
||||
>
|
||||
Refresh ACL Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<TelemetryPane
|
||||
data={paneData.status}
|
||||
state={paneStates.status}
|
||||
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
|
||||
/>
|
||||
<AclPane
|
||||
data={paneData.acl}
|
||||
state={paneStates.acl}
|
||||
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
|
||||
/>
|
||||
<LppTelemetryPane
|
||||
data={paneData.lppTelemetry}
|
||||
state={paneStates.lppTelemetry}
|
||||
onRefresh={() =>
|
||||
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
|
||||
}
|
||||
/>
|
||||
<ConsolePane
|
||||
history={consoleHistory}
|
||||
loading={consoleLoading}
|
||||
onSend={handleConsoleCommand}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
181
frontend/src/components/SecurityWarningModal.tsx
Normal file
181
frontend/src/components/SecurityWarningModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { api } from '../api';
|
||||
import type { HealthStatus } from '../types';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
const STORAGE_KEY = 'meshcore_security_warning_acknowledged';
|
||||
|
||||
function readAcknowledgedState(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeAcknowledgedState(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, 'true');
|
||||
} catch {
|
||||
// Best effort only; the warning will continue to show if localStorage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
interface SecurityWarningModalProps {
|
||||
health: HealthStatus | null;
|
||||
}
|
||||
|
||||
export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
|
||||
const [acknowledged, setAcknowledged] = useState(readAcknowledgedState);
|
||||
const [confirmedRisk, setConfirmedRisk] = useState(false);
|
||||
const [disablingBots, setDisablingBots] = useState(false);
|
||||
const [botsDisabledLocally, setBotsDisabledLocally] = useState(false);
|
||||
|
||||
const shouldWarn =
|
||||
health !== null &&
|
||||
health.bots_disabled !== true &&
|
||||
health.basic_auth_enabled !== true &&
|
||||
!botsDisabledLocally &&
|
||||
!acknowledged;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldWarn) {
|
||||
setConfirmedRisk(false);
|
||||
}
|
||||
}, [shouldWarn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (health?.bots_disabled !== true) {
|
||||
setBotsDisabledLocally(false);
|
||||
}
|
||||
}, [health?.bots_disabled, health?.bots_disabled_source]);
|
||||
|
||||
if (!shouldWarn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="top-3 w-[calc(100vw-1rem)] max-w-[42rem] translate-y-0 gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100vh-1.5rem)] sm:top-[50%] sm:w-full sm:max-h-[min(90vh,48rem)] sm:translate-y-[-50%] sm:px-6"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="space-y-0 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-destructive/30 bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<DialogTitle className="leading-tight">
|
||||
Unprotected bot execution is enabled
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
<div className="space-y-3 break-words text-sm leading-6 text-muted-foreground">
|
||||
<DialogDescription>
|
||||
Bots are not disabled, and app-wide Basic Auth is not configured.
|
||||
</DialogDescription>
|
||||
<p>
|
||||
Without one of those protections, or another access-control layer in front of
|
||||
RemoteTerm, anyone on your local network who can reach this app can run Python code on
|
||||
the computer hosting this instance via the bot system.
|
||||
</p>
|
||||
<p className="font-semibold text-foreground">
|
||||
This is only safe on protected or isolated networks with appropriate access control. If
|
||||
your network is untrusted or later compromised, this setup may expose the host system to
|
||||
arbitrary code execution.
|
||||
</p>
|
||||
<p>
|
||||
To reduce that risk, run the server with environment variables to either disable bots
|
||||
with{' '}
|
||||
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_DISABLE_BOTS=true
|
||||
</code>{' '}
|
||||
or enable the built-in login with{' '}
|
||||
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_BASIC_AUTH_USERNAME
|
||||
</code>{' '}
|
||||
/{' '}
|
||||
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_BASIC_AUTH_PASSWORD
|
||||
</code>
|
||||
. Another external auth or access-control system is also acceptable.
|
||||
</p>
|
||||
<p>
|
||||
If you just want a temporary safety measure while you learn the system, you can use the
|
||||
button below to disable bots until the server restarts. That is only a temporary guard;
|
||||
permanent protection through Basic Auth or env-based bot disablement is still
|
||||
encouraged.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="h-auto w-full whitespace-normal py-3 text-center"
|
||||
disabled={disablingBots}
|
||||
onClick={async () => {
|
||||
setDisablingBots(true);
|
||||
try {
|
||||
await api.disableBotsUntilRestart();
|
||||
setBotsDisabledLocally(true);
|
||||
toast.success('Bots disabled until restart');
|
||||
} catch (err) {
|
||||
toast.error('Failed to disable bots', {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setDisablingBots(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{disablingBots ? 'Disabling Bots...' : 'Disable Bots Until Server Restart'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-input bg-muted/20 p-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={confirmedRisk}
|
||||
onCheckedChange={(checked) => setConfirmedRisk(checked === true)}
|
||||
aria-label="Acknowledge bot security risk"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-sm leading-6 text-foreground">
|
||||
I understand that continuing with my existing security setup may put me at risk on
|
||||
untrusted networks or if my home network is compromised.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="h-auto w-full whitespace-normal py-3 text-center"
|
||||
variant="outline"
|
||||
disabled={!confirmedRisk || disablingBots}
|
||||
onClick={() => {
|
||||
writeAcknowledgedState();
|
||||
setAcknowledged(true);
|
||||
}}
|
||||
>
|
||||
Do Not Warn Me On This Device Again
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -748,7 +748,7 @@ export function Sidebar({
|
||||
icon: <LockOpen className="h-4 w-4" />,
|
||||
label: (
|
||||
<>
|
||||
{showCracker ? 'Hide' : 'Show'} Room Finder
|
||||
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 text-[11px]',
|
||||
@@ -945,21 +945,6 @@ export function Sidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Room Servers */}
|
||||
{nonFavoriteRooms.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Room Servers',
|
||||
roomsCollapsed,
|
||||
() => setRoomsCollapsed((prev) => !prev),
|
||||
'rooms',
|
||||
roomsUnreadCount,
|
||||
roomsUnreadCount > 0
|
||||
)}
|
||||
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Repeaters */}
|
||||
{nonFavoriteRepeaters.length > 0 && (
|
||||
<>
|
||||
@@ -975,6 +960,21 @@ export function Sidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Room Servers */}
|
||||
{nonFavoriteRooms.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Room Servers',
|
||||
roomsCollapsed,
|
||||
() => setRoomsCollapsed((prev) => !prev),
|
||||
'rooms',
|
||||
roomsUnreadCount,
|
||||
roomsUnreadCount > 0
|
||||
)}
|
||||
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{nonFavoriteContacts.length === 0 &&
|
||||
nonFavoriteRooms.length === 0 &&
|
||||
|
||||
@@ -173,8 +173,8 @@ export function SettingsDatabaseSection({
|
||||
Deletes archival copies of raw packet bytes for messages that are already decrypted and
|
||||
visible in your chat history.{' '}
|
||||
<em className="text-muted-foreground/80">
|
||||
This will not affect any displayed messages or app functionality, nor impact your
|
||||
ability to do historical decryption.
|
||||
This will not affect any displayed messages or your ability to do historical decryption,
|
||||
but it will remove packet-analysis availability for those historical messages.
|
||||
</em>{' '}
|
||||
The raw bytes are only useful for manual packet analysis.
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api';
|
||||
@@ -15,21 +17,12 @@ const BotCodeEditor = lazy(() =>
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
mqtt_private: 'Private MQTT',
|
||||
mqtt_community: 'Community MQTT',
|
||||
bot: 'Bot',
|
||||
bot: 'Python Bot',
|
||||
webhook: 'Webhook',
|
||||
apprise: 'Apprise',
|
||||
sqs: 'Amazon SQS',
|
||||
};
|
||||
|
||||
const LIST_TYPE_OPTIONS = [
|
||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
||||
{ value: 'mqtt_community', label: 'Community MQTT' },
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'apprise', label: 'Apprise' },
|
||||
{ value: 'sqs', label: 'Amazon SQS' },
|
||||
];
|
||||
|
||||
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
|
||||
const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net';
|
||||
const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net';
|
||||
@@ -42,30 +35,6 @@ const DEFAULT_MESHRANK_TRANSPORT = 'tcp';
|
||||
const DEFAULT_MESHRANK_AUTH_MODE = 'none';
|
||||
const DEFAULT_MESHRANK_IATA = 'XYZ';
|
||||
|
||||
const CREATE_TYPE_OPTIONS = [
|
||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
||||
{ value: 'mqtt_community_meshrank', label: 'MeshRank' },
|
||||
{ value: 'mqtt_community_letsmesh_us', label: 'LetsMesh (US)' },
|
||||
{ value: 'mqtt_community_letsmesh_eu', label: 'LetsMesh (EU)' },
|
||||
{ value: 'mqtt_community', label: 'Community MQTT/meshcoretomqtt' },
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'apprise', label: 'Apprise' },
|
||||
{ value: 'sqs', label: 'Amazon SQS' },
|
||||
] as const;
|
||||
|
||||
type DraftType = (typeof CREATE_TYPE_OPTIONS)[number]['value'];
|
||||
|
||||
type DraftRecipe = {
|
||||
savedType: string;
|
||||
detailLabel: string;
|
||||
defaultName: string;
|
||||
defaults: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
function createCommunityConfigDefaults(
|
||||
overrides: Partial<Record<string, unknown>> = {}
|
||||
): Record<string, unknown> {
|
||||
@@ -122,11 +91,41 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
|
||||
return "[BOT] Plong!"
|
||||
return None`;
|
||||
|
||||
const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
mqtt_private: {
|
||||
type DraftType =
|
||||
| 'mqtt_private'
|
||||
| 'mqtt_community'
|
||||
| 'mqtt_community_meshrank'
|
||||
| 'mqtt_community_letsmesh_us'
|
||||
| 'mqtt_community_letsmesh_eu'
|
||||
| 'webhook'
|
||||
| 'apprise'
|
||||
| 'sqs'
|
||||
| 'bot';
|
||||
|
||||
type CreateIntegrationDefinition = {
|
||||
value: DraftType;
|
||||
savedType: string;
|
||||
label: string;
|
||||
section: string;
|
||||
description: string;
|
||||
defaultName: string;
|
||||
nameMode: 'counted' | 'fixed';
|
||||
defaults: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
{
|
||||
value: 'mqtt_private',
|
||||
savedType: 'mqtt_private',
|
||||
detailLabel: 'Private MQTT',
|
||||
label: 'Private MQTT',
|
||||
section: 'Bulk Forwarding',
|
||||
description:
|
||||
'Customizable-scope forwarding of all or some messages to an MQTT broker of your choosing, in raw and/or decrypted form.',
|
||||
defaultName: 'Private MQTT',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
broker_host: '',
|
||||
@@ -140,10 +139,29 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_meshrank: {
|
||||
{
|
||||
value: 'mqtt_community',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'MeshRank',
|
||||
label: 'Community MQTT/meshcoretomqtt',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.',
|
||||
defaultName: 'Community MQTT',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults(),
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'mqtt_community_meshrank',
|
||||
savedType: 'mqtt_community',
|
||||
label: 'MeshRank',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'MeshRank',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
||||
@@ -158,10 +176,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_letsmesh_us: {
|
||||
{
|
||||
value: 'mqtt_community_letsmesh_us',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'LetsMesh (US)',
|
||||
label: 'LetsMesh (US)',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'LetsMesh (US)',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
|
||||
@@ -170,10 +193,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_letsmesh_eu: {
|
||||
{
|
||||
value: 'mqtt_community_letsmesh_eu',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'LetsMesh (EU)',
|
||||
label: 'LetsMesh (EU)',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'LetsMesh (EU)',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU,
|
||||
@@ -182,30 +210,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community: {
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'Community MQTT/meshcoretomqtt',
|
||||
defaultName: 'Community MQTT',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults(),
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
bot: {
|
||||
savedType: 'bot',
|
||||
detailLabel: 'Bot',
|
||||
defaultName: 'Bot',
|
||||
defaults: {
|
||||
config: {
|
||||
code: DEFAULT_BOT_CODE,
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
webhook: {
|
||||
{
|
||||
value: 'webhook',
|
||||
savedType: 'webhook',
|
||||
detailLabel: 'Webhook',
|
||||
label: 'Webhook',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'Generic webhook for decrypted channel/DM messages with customizable verb, method, and optional HMAC signature.',
|
||||
defaultName: 'Webhook',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
url: '',
|
||||
@@ -217,10 +230,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
apprise: {
|
||||
{
|
||||
value: 'apprise',
|
||||
savedType: 'apprise',
|
||||
detailLabel: 'Apprise',
|
||||
label: 'Apprise',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'A wide-ranging generic fanout, capable of forwarding decrypted channel/DM messages to Discord, Telegram, email, SMS, and many others.',
|
||||
defaultName: 'Apprise',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
urls: '',
|
||||
@@ -230,10 +248,14 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
sqs: {
|
||||
{
|
||||
value: 'sqs',
|
||||
savedType: 'sqs',
|
||||
detailLabel: 'Amazon SQS',
|
||||
label: 'Amazon SQS',
|
||||
section: 'Bulk Forwarding',
|
||||
description: 'Send full or scope-customized raw or decrypted packets to an SQS',
|
||||
defaultName: 'Amazon SQS',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
queue_url: '',
|
||||
@@ -246,15 +268,41 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
};
|
||||
{
|
||||
value: 'bot',
|
||||
savedType: 'bot',
|
||||
label: 'Python Bot',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'A simple, Python-based interface for basic bots that can respond to DM and channel messages.',
|
||||
defaultName: 'Bot',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
code: DEFAULT_BOT_CODE,
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
||||
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
||||
) as Record<DraftType, CreateIntegrationDefinition>;
|
||||
|
||||
function isDraftType(value: string): value is DraftType {
|
||||
return value in DRAFT_RECIPES;
|
||||
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
||||
}
|
||||
|
||||
function getCreateIntegrationDefinition(draftType: DraftType) {
|
||||
return CREATE_INTEGRATION_DEFINITIONS_BY_VALUE[draftType];
|
||||
}
|
||||
|
||||
function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) {
|
||||
const recipe = DRAFT_RECIPES[draftType];
|
||||
return name || getDefaultIntegrationName(recipe.savedType, configs);
|
||||
const definition = getCreateIntegrationDefinition(draftType);
|
||||
if (name) return name;
|
||||
if (definition.nameMode === 'fixed') return definition.defaultName;
|
||||
return getDefaultIntegrationName(definition.savedType, configs);
|
||||
}
|
||||
|
||||
function normalizeDraftConfig(draftType: DraftType, config: Record<string, unknown>) {
|
||||
@@ -305,22 +353,160 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
}
|
||||
|
||||
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
||||
if (draftType.startsWith('mqtt_community_')) {
|
||||
if (getCreateIntegrationDefinition(draftType).savedType === 'mqtt_community') {
|
||||
return { messages: 'none', raw_packets: 'all' };
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
function cloneDraftDefaults(draftType: DraftType) {
|
||||
const recipe = DRAFT_RECIPES[draftType];
|
||||
const recipe = getCreateIntegrationDefinition(draftType);
|
||||
return {
|
||||
config: structuredClone(recipe.defaults.config),
|
||||
scope: structuredClone(recipe.defaults.scope),
|
||||
};
|
||||
}
|
||||
|
||||
function CreateIntegrationDialog({
|
||||
open,
|
||||
options,
|
||||
selectedType,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
options: readonly CreateIntegrationDefinition[];
|
||||
selectedType: DraftType | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (type: DraftType) => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
const selectedOption =
|
||||
options.find((option) => option.value === selectedType) ?? options[0] ?? null;
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
|
||||
const updateScrollHint = useCallback(() => {
|
||||
const container = listRef.current;
|
||||
if (!container) {
|
||||
setShowScrollHint(false);
|
||||
return;
|
||||
}
|
||||
setShowScrollHint(container.scrollTop + container.clientHeight < container.scrollHeight - 8);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const frame = window.requestAnimationFrame(updateScrollHint);
|
||||
window.addEventListener('resize', updateScrollHint);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updateScrollHint);
|
||||
};
|
||||
}, [open, options, updateScrollHint]);
|
||||
|
||||
const sectionedOptions = [...new Set(options.map((o) => o.section))]
|
||||
.map((section) => ({
|
||||
section,
|
||||
options: options.filter((option) => option.section === section),
|
||||
}))
|
||||
.filter((group) => group.options.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
hideCloseButton
|
||||
className="flex max-h-[calc(100dvh-2rem)] w-[96vw] max-w-[960px] flex-col overflow-hidden p-0 sm:rounded-xl"
|
||||
>
|
||||
<DialogHeader className="border-b border-border px-5 py-4">
|
||||
<DialogTitle>Create Integration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden md:grid-cols-[240px_minmax(0,1fr)]">
|
||||
<div className="relative border-b border-border bg-muted/20 md:border-b-0 md:border-r">
|
||||
<div
|
||||
ref={listRef}
|
||||
onScroll={updateScrollHint}
|
||||
className="max-h-56 overflow-y-auto p-2 md:max-h-[420px]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{sectionedOptions.map((group) => (
|
||||
<div key={group.section} className="space-y-1.5">
|
||||
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{group.section}
|
||||
</div>
|
||||
{group.options.map((option) => {
|
||||
const selected = option.value === selectedOption?.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-left transition-colors',
|
||||
selected
|
||||
? 'border-primary bg-accent text-foreground'
|
||||
: 'border-transparent bg-transparent hover:bg-accent/70'
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(option.value)}
|
||||
>
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showScrollHint && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-background via-background/85 to-transparent px-4 pb-2 pt-8">
|
||||
<div className="rounded-full border border-border/80 bg-background/95 px-2 py-1 text-muted-foreground shadow-sm">
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5 md:min-h-[280px] md:max-h-[420px]">
|
||||
{selectedOption ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{selectedOption.section}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{selectedOption.description}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No integration types are currently available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-border px-5 py-4 sm:justify-end">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={onCreate} disabled={!selectedOption}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function getDetailTypeLabel(detailType: string) {
|
||||
if (isDraftType(detailType)) return DRAFT_RECIPES[detailType].detailLabel;
|
||||
if (isDraftType(detailType)) return getCreateIntegrationDefinition(detailType).label;
|
||||
return TYPE_LABELS[detailType] || detailType;
|
||||
}
|
||||
|
||||
@@ -1499,9 +1685,9 @@ export function SettingsFanoutSection({
|
||||
const [editName, setEditName] = useState('');
|
||||
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
||||
const [inlineEditName, setInlineEditName] = useState('');
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const addMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const loadConfigs = useCallback(async () => {
|
||||
try {
|
||||
@@ -1516,18 +1702,28 @@ export function SettingsFanoutSection({
|
||||
loadConfigs();
|
||||
}, [loadConfigs]);
|
||||
|
||||
const availableCreateOptions = useMemo(
|
||||
() =>
|
||||
CREATE_INTEGRATION_DEFINITIONS.filter(
|
||||
(definition) => definition.savedType !== 'bot' || !health?.bots_disabled
|
||||
),
|
||||
[health?.bots_disabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!addMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!addMenuRef.current?.contains(event.target as Node)) {
|
||||
setAddMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [addMenuOpen]);
|
||||
if (!createDialogOpen) return;
|
||||
if (availableCreateOptions.length === 0) {
|
||||
setSelectedCreateType(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedCreateType &&
|
||||
availableCreateOptions.some((option) => option.value === selectedCreateType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSelectedCreateType(availableCreateOptions[0].value);
|
||||
}, [createDialogOpen, availableCreateOptions, selectedCreateType]);
|
||||
|
||||
const handleToggleEnabled = async (cfg: FanoutConfig) => {
|
||||
try {
|
||||
@@ -1541,7 +1737,7 @@ export function SettingsFanoutSection({
|
||||
};
|
||||
|
||||
const handleEdit = (cfg: FanoutConfig) => {
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setInlineEditingId(null);
|
||||
setInlineEditName('');
|
||||
setDraftType(null);
|
||||
@@ -1552,7 +1748,7 @@ export function SettingsFanoutSection({
|
||||
};
|
||||
|
||||
const handleStartInlineEdit = (cfg: FanoutConfig) => {
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setInlineEditingId(cfg.id);
|
||||
setInlineEditName(cfg.name);
|
||||
};
|
||||
@@ -1611,7 +1807,7 @@ export function SettingsFanoutSection({
|
||||
setBusy(true);
|
||||
try {
|
||||
if (currentDraftType) {
|
||||
const recipe = DRAFT_RECIPES[currentDraftType];
|
||||
const recipe = getCreateIntegrationDefinition(currentDraftType);
|
||||
await api.createFanoutConfig({
|
||||
type: recipe.savedType,
|
||||
name: normalizeDraftName(currentDraftType, editName.trim(), configs),
|
||||
@@ -1663,18 +1859,16 @@ export function SettingsFanoutSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCreate = async (type: string) => {
|
||||
if (!isDraftType(type)) return;
|
||||
const handleAddCreate = (type: DraftType) => {
|
||||
const definition = getCreateIntegrationDefinition(type);
|
||||
const defaults = cloneDraftDefaults(type);
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setEditingId(null);
|
||||
setDraftType(type);
|
||||
setEditName(
|
||||
type === 'mqtt_community_meshrank' ||
|
||||
type === 'mqtt_community_letsmesh_us' ||
|
||||
type === 'mqtt_community_letsmesh_eu'
|
||||
? DRAFT_RECIPES[type].defaultName
|
||||
: getDefaultIntegrationName(DRAFT_RECIPES[type].savedType, configs)
|
||||
definition.nameMode === 'fixed'
|
||||
? definition.defaultName
|
||||
: getDefaultIntegrationName(definition.savedType, configs)
|
||||
);
|
||||
setEditConfig(defaults.config);
|
||||
setEditScope(defaults.scope);
|
||||
@@ -1683,13 +1877,15 @@ export function SettingsFanoutSection({
|
||||
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
|
||||
const detailType = draftType ?? editingConfig?.type ?? null;
|
||||
const isDraft = draftType !== null;
|
||||
const configGroups = LIST_TYPE_OPTIONS.map((opt) => ({
|
||||
type: opt.value,
|
||||
label: opt.label,
|
||||
configs: configs
|
||||
.filter((cfg) => cfg.type === opt.value)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
|
||||
})).filter((group) => group.configs.length > 0);
|
||||
const configGroups = Object.entries(TYPE_LABELS)
|
||||
.map(([type, label]) => ({
|
||||
type,
|
||||
label,
|
||||
configs: configs
|
||||
.filter((cfg) => cfg.type === type)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
|
||||
}))
|
||||
.filter((group) => group.configs.length > 0);
|
||||
|
||||
// Detail view
|
||||
if (detailType) {
|
||||
@@ -1817,42 +2013,28 @@ export function SettingsFanoutSection({
|
||||
|
||||
{health?.bots_disabled && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations
|
||||
cannot be created or modified.
|
||||
{health.bots_disabled_source === 'until_restart'
|
||||
? 'Bot system is disabled until the server restarts. Bot integrations cannot run, be created, or be modified right now.'
|
||||
: 'Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations cannot run, be created, or be modified.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative inline-block" ref={addMenuRef}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={addMenuOpen}
|
||||
onClick={() => setAddMenuOpen((open) => !open)}
|
||||
>
|
||||
Add Integration
|
||||
</Button>
|
||||
{addMenuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute left-0 top-full z-10 mt-2 min-w-72 rounded-md border border-input bg-background p-1 shadow-md"
|
||||
>
|
||||
{CREATE_TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
|
||||
(opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="flex w-full rounded-sm px-3 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => handleAddCreate(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
Add Integration
|
||||
</Button>
|
||||
|
||||
<CreateIntegrationDialog
|
||||
open={createDialogOpen}
|
||||
options={availableCreateOptions}
|
||||
selectedType={selectedCreateType}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSelect={setSelectedCreateType}
|
||||
onCreate={() => {
|
||||
if (selectedCreateType) {
|
||||
handleAddCreate(selectedCreateType);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{configGroups.length > 0 && (
|
||||
<div className="columns-1 gap-4 md:columns-2">
|
||||
|
||||
@@ -164,6 +164,12 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_24_hours}</td>
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_week}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1">Known-channels active</td>
|
||||
<td className="text-right py-1">{stats.known_channels_active.last_hour}</td>
|
||||
<td className="text-right py-1">{stats.known_channels_active.last_24_hours}</td>
|
||||
<td className="text-right py-1">{stats.known_channels_active.last_week}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -29,10 +29,16 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps extends React.ComponentPropsWithoutRef<
|
||||
typeof DialogPrimitive.Content
|
||||
> {
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
DialogContentProps
|
||||
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@@ -44,10 +50,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
|
||||
@@ -10,4 +10,5 @@ export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
export { useFaviconBadge, useUnreadTitle } from './useFaviconBadge';
|
||||
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
|
||||
|
||||
@@ -110,6 +110,8 @@ export function useBrowserNotifications() {
|
||||
|
||||
const toggleConversationNotifications = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string, label: string) => {
|
||||
const blockedDescription =
|
||||
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.';
|
||||
const conversationKey = getConversationNotificationKey(type, id);
|
||||
if (enabledByConversation[conversationKey]) {
|
||||
setEnabledByConversation((prev) => {
|
||||
@@ -131,7 +133,7 @@ export function useBrowserNotifications() {
|
||||
|
||||
if (permission === 'denied') {
|
||||
toast.error('Browser notifications blocked', {
|
||||
description: 'Allow notifications in your browser settings, then try again.',
|
||||
description: blockedDescription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -159,9 +161,7 @@ export function useBrowserNotifications() {
|
||||
|
||||
toast.error('Browser notifications not enabled', {
|
||||
description:
|
||||
nextPermission === 'denied'
|
||||
? 'Permission was denied by the browser.'
|
||||
: 'Permission request was dismissed.',
|
||||
nextPermission === 'denied' ? blockedDescription : 'Permission request was dismissed.',
|
||||
});
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
|
||||
@@ -86,7 +86,12 @@ export class ConversationMessageCache {
|
||||
return true;
|
||||
}
|
||||
|
||||
updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
|
||||
updateAck(
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
): void {
|
||||
for (const entry of this.cache.values()) {
|
||||
const index = entry.messages.findIndex((message) => message.id === messageId);
|
||||
if (index < 0) continue;
|
||||
@@ -96,6 +101,7 @@ export class ConversationMessageCache {
|
||||
...current,
|
||||
acked: Math.max(current.acked, ackCount),
|
||||
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
|
||||
...(packetId !== undefined && { packet_id: packetId }),
|
||||
};
|
||||
entry.messages = updated;
|
||||
return;
|
||||
@@ -146,12 +152,16 @@ export function reconcileConversationMessages(
|
||||
current: Message[],
|
||||
fetched: Message[]
|
||||
): Message[] | null {
|
||||
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
|
||||
const currentById = new Map<
|
||||
number,
|
||||
{ acked: number; pathsLen: number; text: string; packetId: number | null | undefined }
|
||||
>();
|
||||
for (const message of current) {
|
||||
currentById.set(message.id, {
|
||||
acked: message.acked,
|
||||
pathsLen: message.paths?.length ?? 0,
|
||||
text: message.text,
|
||||
packetId: message.packet_id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,7 +172,8 @@ export function reconcileConversationMessages(
|
||||
!currentMessage ||
|
||||
currentMessage.acked !== message.acked ||
|
||||
currentMessage.pathsLen !== (message.paths?.length ?? 0) ||
|
||||
currentMessage.text !== message.text
|
||||
currentMessage.text !== message.text ||
|
||||
currentMessage.packetId !== message.packet_id
|
||||
) {
|
||||
needsUpdate = true;
|
||||
break;
|
||||
@@ -180,17 +191,20 @@ export const conversationMessageCache = new ConversationMessageCache();
|
||||
interface PendingAckUpdate {
|
||||
ackCount: number;
|
||||
paths?: MessagePath[];
|
||||
packetId?: number | null;
|
||||
}
|
||||
|
||||
export function mergePendingAck(
|
||||
existing: PendingAckUpdate | undefined,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[]
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
): PendingAckUpdate {
|
||||
if (!existing) {
|
||||
return {
|
||||
ackCount,
|
||||
...(paths !== undefined && { paths }),
|
||||
...(packetId !== undefined && { packetId }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,6 +213,9 @@ export function mergePendingAck(
|
||||
ackCount,
|
||||
...(paths !== undefined && { paths }),
|
||||
...(paths === undefined && existing.paths !== undefined && { paths: existing.paths }),
|
||||
...(packetId !== undefined && { packetId }),
|
||||
...(packetId === undefined &&
|
||||
existing.packetId !== undefined && { packetId: existing.packetId }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -206,16 +223,31 @@ export function mergePendingAck(
|
||||
return existing;
|
||||
}
|
||||
|
||||
const packetIdChanged = packetId !== undefined && packetId !== existing.packetId;
|
||||
|
||||
if (paths === undefined) {
|
||||
return existing;
|
||||
if (!packetIdChanged) {
|
||||
return existing;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
packetId,
|
||||
};
|
||||
}
|
||||
|
||||
const existingPathCount = existing.paths?.length ?? -1;
|
||||
if (paths.length >= existingPathCount) {
|
||||
return { ackCount, paths };
|
||||
return { ackCount, paths, ...(packetId !== undefined && { packetId }) };
|
||||
}
|
||||
|
||||
return existing;
|
||||
if (!packetIdChanged) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
packetId,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseConversationMessagesResult {
|
||||
@@ -230,7 +262,12 @@ interface UseConversationMessagesResult {
|
||||
jumpToBottom: () => void;
|
||||
reloadCurrentConversation: () => void;
|
||||
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
|
||||
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
receiveMessageAck: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => void;
|
||||
reconcileOnReconnect: () => void;
|
||||
renameConversationMessages: (oldId: string, newId: string) => void;
|
||||
removeConversationMessages: (conversationId: string) => void;
|
||||
@@ -291,9 +328,9 @@ export function useConversationMessages(
|
||||
const pendingAcksRef = useRef<Map<number, PendingAckUpdate>>(new Map());
|
||||
|
||||
const setPendingAck = useCallback(
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
|
||||
const existing = pendingAcksRef.current.get(messageId);
|
||||
const merged = mergePendingAck(existing, ackCount, paths);
|
||||
const merged = mergePendingAck(existing, ackCount, paths, packetId);
|
||||
|
||||
// Update insertion order so most recent updates remain in the buffer longest.
|
||||
pendingAcksRef.current.delete(messageId);
|
||||
@@ -319,6 +356,7 @@ export function useConversationMessages(
|
||||
...msg,
|
||||
acked: Math.max(msg.acked, pending.ackCount),
|
||||
...(pending.paths !== undefined && { paths: pending.paths }),
|
||||
...(pending.packetId !== undefined && { packet_id: pending.packetId }),
|
||||
};
|
||||
}, []);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
@@ -782,10 +820,10 @@ export function useConversationMessages(
|
||||
|
||||
// Update a message's ack count and paths
|
||||
const updateMessageAck = useCallback(
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
|
||||
const hasMessageLoaded = messagesRef.current.some((m) => m.id === messageId);
|
||||
if (!hasMessageLoaded) {
|
||||
setPendingAck(messageId, ackCount, paths);
|
||||
setPendingAck(messageId, ackCount, paths, packetId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -807,10 +845,11 @@ export function useConversationMessages(
|
||||
...current,
|
||||
acked: nextAck,
|
||||
...(paths !== undefined && { paths: nextPaths }),
|
||||
...(packetId !== undefined && { packet_id: packetId }),
|
||||
};
|
||||
return updated;
|
||||
}
|
||||
setPendingAck(messageId, ackCount, paths);
|
||||
setPendingAck(messageId, ackCount, paths, packetId);
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
@@ -818,9 +857,9 @@ export function useConversationMessages(
|
||||
);
|
||||
|
||||
const receiveMessageAck = useCallback(
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
updateMessageAck(messageId, ackCount, paths);
|
||||
conversationMessageCache.updateAck(messageId, ackCount, paths);
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
|
||||
updateMessageAck(messageId, ackCount, paths, packetId);
|
||||
conversationMessageCache.updateAck(messageId, ackCount, paths, packetId);
|
||||
},
|
||||
[updateMessageAck]
|
||||
);
|
||||
|
||||
196
frontend/src/hooks/useFaviconBadge.ts
Normal file
196
frontend/src/hooks/useFaviconBadge.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import type { Favorite } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const APP_TITLE = 'RemoteTerm for MeshCore';
|
||||
const UNREAD_APP_TITLE = 'RemoteTerm';
|
||||
const BASE_FAVICON_PATH = '/favicon.svg';
|
||||
const GREEN_BADGE_FILL = '#16a34a';
|
||||
const RED_BADGE_FILL = '#dc2626';
|
||||
const BADGE_CENTER = 750;
|
||||
const BADGE_OUTER_RADIUS = 220;
|
||||
const BADGE_INNER_RADIUS = 180;
|
||||
|
||||
let baseFaviconSvgPromise: Promise<string> | null = null;
|
||||
|
||||
export type FaviconBadgeState = 'none' | 'green' | 'red';
|
||||
|
||||
function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): number {
|
||||
return Object.entries(unreadCounts).reduce(
|
||||
(sum, [stateKey, count]) => sum + (stateKey.startsWith('contact-') ? count : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function getUnreadFavoriteChannelCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
): number {
|
||||
return favorites.reduce(
|
||||
(sum, favorite) =>
|
||||
sum +
|
||||
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function getTotalUnreadCount(unreadCounts: Record<string, number>): number {
|
||||
return Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
|
||||
}
|
||||
|
||||
export function getFavoriteUnreadCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
): number {
|
||||
return favorites.reduce((sum, favorite) => {
|
||||
const stateKey = getStateKey(favorite.type, favorite.id);
|
||||
return sum + (unreadCounts[stateKey] || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function getUnreadTitle(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
): string {
|
||||
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
|
||||
if (unreadCount <= 0) {
|
||||
return APP_TITLE;
|
||||
}
|
||||
|
||||
const label = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||
return `(${label}) ${UNREAD_APP_TITLE}`;
|
||||
}
|
||||
|
||||
export function deriveFaviconBadgeState(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
): FaviconBadgeState {
|
||||
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
|
||||
return 'red';
|
||||
}
|
||||
|
||||
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function buildBadgedFaviconSvg(baseSvg: string, badgeFill: string): string {
|
||||
const closingTagIndex = baseSvg.lastIndexOf('</svg>');
|
||||
if (closingTagIndex === -1) {
|
||||
return baseSvg;
|
||||
}
|
||||
|
||||
const badge = `
|
||||
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_OUTER_RADIUS}" fill="#ffffff"/>
|
||||
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_INNER_RADIUS}" fill="${badgeFill}"/>
|
||||
`;
|
||||
return `${baseSvg.slice(0, closingTagIndex)}${badge}</svg>`;
|
||||
}
|
||||
|
||||
async function loadBaseFaviconSvg(): Promise<string> {
|
||||
if (!baseFaviconSvgPromise) {
|
||||
baseFaviconSvgPromise = fetch(BASE_FAVICON_PATH, { cache: 'force-cache' })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load favicon SVG: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.catch((error) => {
|
||||
baseFaviconSvgPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return baseFaviconSvgPromise;
|
||||
}
|
||||
|
||||
function upsertFaviconLinks(rel: 'icon' | 'shortcut icon', href: string): void {
|
||||
const links = Array.from(document.head.querySelectorAll<HTMLLinkElement>(`link[rel="${rel}"]`));
|
||||
const targets = links.length > 0 ? links : [document.createElement('link')];
|
||||
|
||||
for (const link of targets) {
|
||||
if (!link.parentNode) {
|
||||
link.rel = rel;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.type = 'image/svg+xml';
|
||||
link.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFaviconHref(href: string): void {
|
||||
upsertFaviconLinks('icon', href);
|
||||
upsertFaviconLinks('shortcut icon', href);
|
||||
}
|
||||
|
||||
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
|
||||
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
|
||||
return () => {
|
||||
document.title = APP_TITLE;
|
||||
};
|
||||
}, [title]);
|
||||
}
|
||||
|
||||
export function useFaviconBadge(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
): void {
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const badgeState = useMemo(
|
||||
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
|
||||
[favorites, mentions, unreadCounts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
|
||||
if (badgeState === 'none') {
|
||||
applyFaviconHref(BASE_FAVICON_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeFill = badgeState === 'red' ? RED_BADGE_FILL : GREEN_BADGE_FILL;
|
||||
let cancelled = false;
|
||||
|
||||
void loadBaseFaviconSvg()
|
||||
.then((baseSvg) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(
|
||||
new Blob([buildBadgedFaviconSvg(baseSvg, badgeFill)], {
|
||||
type: 'image/svg+xml',
|
||||
})
|
||||
);
|
||||
objectUrlRef.current = objectUrl;
|
||||
applyFaviconHref(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
applyFaviconHref(BASE_FAVICON_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [badgeState]);
|
||||
}
|
||||
@@ -48,7 +48,12 @@ interface UseRealtimeAppStateArgs {
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
renameConversationMessages: (oldId: string, newId: string) => void;
|
||||
removeConversationMessages: (conversationId: string) => void;
|
||||
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
receiveMessageAck: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => void;
|
||||
notifyIncomingMessage?: (msg: Message) => void;
|
||||
recordRawPacketObservation?: (packet: RawPacket) => void;
|
||||
maxRawPackets?: number;
|
||||
@@ -246,8 +251,13 @@ export function useRealtimeAppState({
|
||||
recordRawPacketObservation?.(packet);
|
||||
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
|
||||
},
|
||||
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
receiveMessageAck(messageId, ackCount, paths);
|
||||
onMessageAcked: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => {
|
||||
receiveMessageAck(messageId, ackCount, paths, packetId);
|
||||
},
|
||||
}),
|
||||
[
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('ContactInfoPane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('loads name-only channel stats and most active rooms', async () => {
|
||||
it('loads name-only channel stats and most active channels', async () => {
|
||||
getContactAnalytics.mockResolvedValue(
|
||||
createAnalytics(null, {
|
||||
lookup_type: 'name',
|
||||
@@ -188,7 +188,7 @@ describe('ContactInfoPane', () => {
|
||||
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
|
||||
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
|
||||
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most Active Channels')).toBeInTheDocument();
|
||||
expect(screen.getByText('#ops')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Name-only analytics include channel messages only/i)
|
||||
|
||||
@@ -67,6 +67,29 @@ function renderSectionWithRefresh(
|
||||
);
|
||||
}
|
||||
|
||||
function startsWithAccessibleName(name: string) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${escaped}(?:\\s|$)`);
|
||||
}
|
||||
|
||||
async function openCreateIntegrationDialog() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
return screen.findByRole('dialog', { name: 'Create Integration' });
|
||||
}
|
||||
|
||||
function selectCreateIntegration(name: string) {
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: startsWithAccessibleName(name) }));
|
||||
}
|
||||
|
||||
function confirmCreateIntegration() {
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
@@ -76,35 +99,64 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('SettingsFanoutSection', () => {
|
||||
it('shows add integration menu with all integration types', async () => {
|
||||
it('shows add integration dialog with all integration types', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
|
||||
expect(screen.getByRole('menuitem', { name: 'Private MQTT' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'MeshRank' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'LetsMesh (US)' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' })).toBeInTheDocument();
|
||||
const optionButtons = within(dialog)
|
||||
.getAllByRole('button')
|
||||
.filter((button) => button.hasAttribute('aria-pressed'));
|
||||
expect(optionButtons).toHaveLength(9);
|
||||
expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' })
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Private MQTT') })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Amazon SQS' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('MeshRank') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (US)') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (EU)') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: startsWithAccessibleName('Community MQTT/meshcoretomqtt'),
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Webhook') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Apprise') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Amazon SQS') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
|
||||
const genericCommunityIndex = optionButtons.findIndex((button) =>
|
||||
button.textContent?.startsWith('Community MQTT/meshcoretomqtt')
|
||||
);
|
||||
const meshRankIndex = optionButtons.findIndex((button) =>
|
||||
button.textContent?.startsWith('MeshRank')
|
||||
);
|
||||
expect(genericCommunityIndex).toBeGreaterThan(-1);
|
||||
expect(meshRankIndex).toBeGreaterThan(-1);
|
||||
expect(genericCommunityIndex).toBeLessThan(meshRankIndex);
|
||||
});
|
||||
|
||||
it('shows bot option in add integration menu when bots are enabled', async () => {
|
||||
it('shows bot option in add integration dialog when bots are enabled', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows bots disabled banner when bots_disabled', async () => {
|
||||
@@ -114,14 +166,21 @@ describe('SettingsFanoutSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('hides bot option from add integration menu when bots_disabled', async () => {
|
||||
renderSection({ health: { ...baseHealth, bots_disabled: true } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
it('shows restart-scoped bots disabled messaging when disabled until restart', async () => {
|
||||
renderSection({
|
||||
health: { ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/disabled until the server restarts/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
expect(screen.queryByRole('menuitem', { name: 'Bot' })).not.toBeInTheDocument();
|
||||
it('hides bot option from add integration dialog when bots_disabled', async () => {
|
||||
renderSection({ health: { ...baseHealth, bots_disabled: true } });
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists existing configs after load', async () => {
|
||||
@@ -296,12 +355,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('navigates to create view when clicking add button', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||
@@ -315,12 +371,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('new SQS draft shows queue url fields and sensible defaults', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Amazon SQS' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Amazon SQS');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||
@@ -332,12 +385,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('backing out of a new draft does not create an integration', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('← Back to list'));
|
||||
@@ -411,12 +461,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdWebhook]);
|
||||
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' }));
|
||||
@@ -444,8 +491,9 @@ describe('SettingsFanoutSection', () => {
|
||||
renderSection();
|
||||
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Webhook #3'));
|
||||
});
|
||||
|
||||
@@ -647,21 +695,21 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Broker: mqtt-us-v1.letsmesh.net:443')).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
|
||||
const group = await screen.findByRole('group', { name: 'Integration Community Feed' });
|
||||
expect(
|
||||
within(group).getByText(
|
||||
(_, element) => element?.textContent === 'Broker: mqtt-us-v1.letsmesh.net:443'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(within(group).getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Region: LAX')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('MeshRank preset pre-fills the broker settings and asks for the topic template', async () => {
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('MeshRank');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
@@ -698,12 +746,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('MeshRank');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Packet Topic Template'), {
|
||||
@@ -765,12 +810,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (US)' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('LetsMesh (US)');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('LetsMesh (US)');
|
||||
@@ -833,12 +875,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
|
||||
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('LetsMesh (EU)');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
|
||||
@@ -871,12 +910,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('generic Community MQTT entry still opens the full editor', async () => {
|
||||
renderSection();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Community MQTT/meshcoretomqtt');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
@@ -900,9 +936,12 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([privateConfig]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Broker: broker.local:1883')).toBeInTheDocument());
|
||||
const group = await screen.findByRole('group', { name: 'Integration Private Broker' });
|
||||
expect(
|
||||
screen.getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
|
||||
within(group).getByText((_, element) => element?.textContent === 'Broker: broker.local:1883')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(group).getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -920,7 +959,8 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('https://example.com/hook')).toBeInTheDocument());
|
||||
const group = await screen.findByRole('group', { name: 'Integration Webhook Feed' });
|
||||
expect(within(group).getByText('https://example.com/hook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('apprise list shows compact target summary', async () => {
|
||||
@@ -941,9 +981,10 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)).toBeInTheDocument()
|
||||
);
|
||||
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
|
||||
expect(
|
||||
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sqs list shows queue url summary', async () => {
|
||||
@@ -963,11 +1004,10 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
const group = await screen.findByRole('group', { name: 'Integration Queue Feed' });
|
||||
expect(
|
||||
within(group).getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups integrations by type and sorts entries alphabetically within each group', async () => {
|
||||
|
||||
@@ -10,7 +10,6 @@ import userEvent from '@testing-library/user-event';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { NewMessageModal } from '../components/NewMessageModal';
|
||||
import type { Contact } from '../types';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
|
||||
// Mock sonner (toast)
|
||||
@@ -18,24 +17,6 @@ vi.mock('../components/ui/sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockContact: Contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
|
||||
const mockToast = toast as unknown as {
|
||||
success: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
@@ -43,7 +24,6 @@ const mockToast = toast as unknown as {
|
||||
|
||||
describe('NewMessageModal form reset', () => {
|
||||
const onClose = vi.fn();
|
||||
const onSelectConversation = vi.fn();
|
||||
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -56,10 +36,8 @@ describe('NewMessageModal form reset', () => {
|
||||
return render(
|
||||
<NewMessageModal
|
||||
open={open}
|
||||
contacts={[mockContact]}
|
||||
undecryptedCount={5}
|
||||
onClose={onClose}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onCreateContact={onCreateContact}
|
||||
onCreateChannel={onCreateChannel}
|
||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||
@@ -75,7 +53,7 @@ describe('NewMessageModal form reset', () => {
|
||||
it('clears name after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
||||
await user.type(input, 'testchan');
|
||||
@@ -91,14 +69,14 @@ describe('NewMessageModal form reset', () => {
|
||||
|
||||
// Re-render to simulate reopening — state should be reset
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('clears name when Cancel is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
||||
await user.type(input, 'mychannel');
|
||||
@@ -127,13 +105,13 @@ describe('NewMessageModal form reset', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('new-room tab', () => {
|
||||
describe('new-channel tab', () => {
|
||||
it('clears name and key after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
@@ -148,9 +126,9 @@ describe('NewMessageModal form reset', () => {
|
||||
const user = userEvent.setup();
|
||||
onCreateChannel.mockRejectedValueOnce(new Error('Bad key'));
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
@@ -164,7 +142,7 @@ describe('NewMessageModal form reset', () => {
|
||||
});
|
||||
|
||||
describe('tab switching resets form', () => {
|
||||
it('clears contact fields when switching to room tab', async () => {
|
||||
it('clears contact fields when switching to channel tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Contact');
|
||||
@@ -172,24 +150,24 @@ describe('NewMessageModal form reset', () => {
|
||||
await user.type(screen.getByPlaceholderText('Contact name'), 'Bob');
|
||||
await user.type(screen.getByPlaceholderText('64-character hex public key'), 'deadbeef');
|
||||
|
||||
// Switch to Room tab — fields should reset
|
||||
await switchToTab(user, 'Room');
|
||||
// Switch to Private Channel tab — fields should reset
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
expect((screen.getByPlaceholderText('Room name') as HTMLInputElement).value).toBe('');
|
||||
expect((screen.getByPlaceholderText('Channel name') as HTMLInputElement).value).toBe('');
|
||||
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
it('clears room fields when switching to hashtag tab', async () => {
|
||||
it('clears channel fields when switching to hashtag tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'SecretRoom');
|
||||
await user.type(screen.getByPlaceholderText('Channel name'), 'SecretRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
|
||||
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
@@ -199,7 +177,7 @@ describe('NewMessageModal form reset', () => {
|
||||
it('resets tryHistorical when switching tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
// Check the "Try decrypting" checkbox
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Try decrypting/ });
|
||||
@@ -210,7 +188,7 @@ describe('NewMessageModal form reset', () => {
|
||||
|
||||
// Switch tab and come back
|
||||
await switchToTab(user, 'Contact');
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
// The streaming message should be gone (tryHistorical was reset)
|
||||
expect(screen.queryByText(/Messages will stream in/)).toBeNull();
|
||||
|
||||
@@ -4,6 +4,19 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { RawPacketDetailModal } from '../components/RawPacketDetailModal';
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { toast } = await import('../components/ui/sonner');
|
||||
const mockToast = toast as unknown as {
|
||||
success: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const BOT_CHANNEL: Channel = {
|
||||
key: 'eb50a1bcb3e4e5d7bf69a57c9dada211',
|
||||
name: '#bot',
|
||||
@@ -25,6 +38,20 @@ const BOT_PACKET: RawPacket = {
|
||||
};
|
||||
|
||||
describe('RawPacketDetailModal', () => {
|
||||
it('copies the full packet hex to the clipboard', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText },
|
||||
});
|
||||
|
||||
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(BOT_PACKET.data);
|
||||
expect(mockToast.success).toHaveBeenCalledWith('Packet hex copied!');
|
||||
});
|
||||
|
||||
it('renders path hops as nowrap arrow-delimited groups and links hover state to the full packet hex', () => {
|
||||
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
|
||||
|
||||
|
||||
@@ -135,6 +135,22 @@ describe('RawPacketFeedView', () => {
|
||||
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('analyzes a pasted raw packet without adding it to the live feed', () => {
|
||||
renderView({ channels: [TEST_CHANNEL] });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Analyze Packet' }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Analyze Packet' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Packet Hex'), {
|
||||
target: { value: GROUP_TEXT_PACKET_HEX },
|
||||
});
|
||||
|
||||
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packet fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Payload fields')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows stats by default on desktop', () => {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
@@ -345,7 +361,7 @@ describe('RawPacketFeedView', () => {
|
||||
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens a packet detail modal from the raw feed and decrypts room messages when a key is loaded', () => {
|
||||
it('opens a packet detail modal from the raw feed and decrypts channel messages when a key is loaded', () => {
|
||||
renderView({
|
||||
packets: [
|
||||
{
|
||||
@@ -376,7 +392,7 @@ describe('RawPacketFeedView', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not guess a room name when multiple loaded channels collide on the group hash', () => {
|
||||
it('does not guess a channel name when multiple loaded channels collide on the group hash', () => {
|
||||
renderView({
|
||||
packets: [
|
||||
{
|
||||
|
||||
@@ -24,6 +24,8 @@ vi.mock('../components/ui/sonner', () => ({
|
||||
|
||||
const { api: _rawApi } = await import('../api');
|
||||
const mockApi = _rawApi as unknown as Record<string, Mock>;
|
||||
const { toast } = await import('../components/ui/sonner');
|
||||
const mockToast = toast as unknown as Record<string, Mock>;
|
||||
|
||||
const roomContact: Contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
@@ -63,9 +65,13 @@ describe('RoomServerPanel', () => {
|
||||
fireEvent.click(screen.getByText('Login with ACL / Guest'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Room Server Controls')).toBeInTheDocument();
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
expect(mockToast.warning).toHaveBeenCalledWith('Room login not confirmed', {
|
||||
description:
|
||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
||||
});
|
||||
expect(screen.getByText(/control panel is still available/i)).toBeInTheDocument();
|
||||
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
|
||||
119
frontend/src/test/securityWarningModal.test.tsx
Normal file
119
frontend/src/test/securityWarningModal.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
disableBotsUntilRestart: vi.fn(),
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
disableBotsUntilRestart: mocks.disableBotsUntilRestart,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: mocks.toast,
|
||||
}));
|
||||
|
||||
import { SecurityWarningModal } from '../components/SecurityWarningModal';
|
||||
import type { HealthStatus } from '../types';
|
||||
|
||||
const baseHealth: HealthStatus = {
|
||||
status: 'degraded',
|
||||
radio_connected: false,
|
||||
radio_initializing: false,
|
||||
connection_info: null,
|
||||
database_size_mb: 1.2,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
fanout_statuses: {},
|
||||
bots_disabled: false,
|
||||
basic_auth_enabled: false,
|
||||
};
|
||||
|
||||
describe('SecurityWarningModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
mocks.disableBotsUntilRestart.mockResolvedValue({
|
||||
status: 'ok',
|
||||
bots_disabled: true,
|
||||
bots_disabled_source: 'until_restart',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the warning when bots are enabled and basic auth is off', () => {
|
||||
render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Do Not Warn Me On This Device Again' })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not show when bots are disabled', () => {
|
||||
render(<SecurityWarningModal health={{ ...baseHealth, bots_disabled: true }} />);
|
||||
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show when basic auth is enabled', () => {
|
||||
render(<SecurityWarningModal health={{ ...baseHealth, basic_auth_enabled: true }} />);
|
||||
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists dismissal only after the checkbox is acknowledged', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
const dismissButton = screen.getByRole('button', {
|
||||
name: 'Do Not Warn Me On This Device Again',
|
||||
});
|
||||
await user.click(screen.getByLabelText('Acknowledge bot security risk'));
|
||||
expect(dismissButton).toBeEnabled();
|
||||
|
||||
await user.click(dismissButton);
|
||||
|
||||
expect(window.localStorage.getItem('meshcore_security_warning_acknowledged')).toBe('true');
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables bots until restart from the warning modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
|
||||
|
||||
expect(mocks.disableBotsUntilRestart).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.toast.success).toHaveBeenCalledWith('Bots disabled until restart');
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the warning again after temporary bot disable disappears on a later health update', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SecurityWarningModal
|
||||
health={{ ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' }}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -557,6 +557,10 @@ describe('SettingsModal', () => {
|
||||
renderModal();
|
||||
openDatabaseSection();
|
||||
|
||||
expect(
|
||||
screen.getByText(/remove packet-analysis availability for those historical messages/i)
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -580,6 +584,7 @@ describe('SettingsModal', () => {
|
||||
total_outgoing: 30,
|
||||
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
|
||||
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
|
||||
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
|
||||
path_hash_width_24h: {
|
||||
total_packets: 120,
|
||||
single_byte: 60,
|
||||
@@ -626,6 +631,7 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText('24 (20.0%)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
||||
|
||||
// Busiest channels
|
||||
expect(screen.getByText('general')).toBeInTheDocument();
|
||||
@@ -646,6 +652,7 @@ describe('SettingsModal', () => {
|
||||
total_outgoing: 30,
|
||||
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
|
||||
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
|
||||
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
|
||||
path_hash_width_24h: {
|
||||
total_packets: 120,
|
||||
single_byte: 60,
|
||||
|
||||
@@ -148,4 +148,25 @@ describe('useBrowserNotifications', () => {
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
expect(notificationInstance.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the browser guidance toast when notifications are blocked', async () => {
|
||||
Object.assign(window.Notification, {
|
||||
permission: 'denied',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useBrowserNotifications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith('Browser notifications blocked', {
|
||||
description:
|
||||
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
255
frontend/src/test/useFaviconBadge.test.ts
Normal file
255
frontend/src/test/useFaviconBadge.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBadgedFaviconSvg,
|
||||
deriveFaviconBadgeState,
|
||||
getFavoriteUnreadCount,
|
||||
getUnreadTitle,
|
||||
getTotalUnreadCount,
|
||||
useFaviconBadge,
|
||||
useUnreadTitle,
|
||||
} from '../hooks/useFaviconBadge';
|
||||
import type { Favorite } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
function getIconHref(rel: 'icon' | 'shortcut icon'): string | null {
|
||||
return (
|
||||
document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)?.getAttribute('href') ?? null
|
||||
);
|
||||
}
|
||||
|
||||
describe('useFaviconBadge', () => {
|
||||
const baseSvg =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><rect width="1000" height="1000"/></svg>';
|
||||
const originalCreateObjectURL = URL.createObjectURL;
|
||||
const originalRevokeObjectURL = URL.revokeObjectURL;
|
||||
let objectUrlCounter = 0;
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
let createObjectURLMock: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURLMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
document.head.innerHTML = `
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
`;
|
||||
document.title = 'RemoteTerm for MeshCore';
|
||||
objectUrlCounter = 0;
|
||||
fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
text: async () => baseSvg,
|
||||
});
|
||||
createObjectURLMock = vi.fn(() => `blob:generated-${++objectUrlCounter}`);
|
||||
revokeObjectURLMock = vi.fn();
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: createObjectURLMock,
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: revokeObjectURLMock,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
Object.defineProperty(URL, 'createObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalCreateObjectURL,
|
||||
});
|
||||
Object.defineProperty(URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalRevokeObjectURL,
|
||||
});
|
||||
});
|
||||
|
||||
it('derives badge priority from unread counts, mentions, and favorites', () => {
|
||||
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
|
||||
|
||||
expect(deriveFaviconBadgeState({}, {}, favorites)).toBe('none');
|
||||
expect(
|
||||
deriveFaviconBadgeState(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 3,
|
||||
},
|
||||
{},
|
||||
favorites
|
||||
)
|
||||
).toBe('green');
|
||||
expect(
|
||||
deriveFaviconBadgeState(
|
||||
{
|
||||
[getStateKey('contact', 'abc')]: 12,
|
||||
},
|
||||
{},
|
||||
favorites
|
||||
)
|
||||
).toBe('red');
|
||||
expect(
|
||||
deriveFaviconBadgeState(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 1,
|
||||
},
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: true,
|
||||
},
|
||||
favorites
|
||||
)
|
||||
).toBe('red');
|
||||
});
|
||||
|
||||
it('builds a dot-only badge into the base svg markup', () => {
|
||||
const svg = buildBadgedFaviconSvg(baseSvg, '#16a34a');
|
||||
|
||||
expect(svg).toContain('<circle cx="750" cy="750" r="220" fill="#ffffff"/>');
|
||||
expect(svg).toContain('<circle cx="750" cy="750" r="180" fill="#16a34a"/>');
|
||||
expect(svg).not.toContain('<text');
|
||||
});
|
||||
|
||||
it('derives the unread count and page title', () => {
|
||||
expect(getTotalUnreadCount({})).toBe(0);
|
||||
expect(getTotalUnreadCount({ a: 2, b: 5 })).toBe(7);
|
||||
expect(getFavoriteUnreadCount({}, [])).toBe(0);
|
||||
expect(
|
||||
getFavoriteUnreadCount(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 7,
|
||||
[getStateKey('contact', 'fav-contact')]: 3,
|
||||
[getStateKey('channel', 'other-chan')]: 9,
|
||||
},
|
||||
[
|
||||
{ type: 'channel', id: 'fav-chan' },
|
||||
{ type: 'contact', id: 'fav-contact' },
|
||||
]
|
||||
)
|
||||
).toBe(10);
|
||||
expect(getUnreadTitle({}, [])).toBe('RemoteTerm for MeshCore');
|
||||
expect(
|
||||
getUnreadTitle(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 7,
|
||||
[getStateKey('channel', 'other-chan')]: 9,
|
||||
},
|
||||
[{ type: 'channel', id: 'fav-chan' }]
|
||||
)
|
||||
).toBe('(7) RemoteTerm');
|
||||
expect(
|
||||
getUnreadTitle(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 120,
|
||||
},
|
||||
[{ type: 'channel', id: 'fav-chan' }]
|
||||
)
|
||||
).toBe('(99+) RemoteTerm');
|
||||
});
|
||||
|
||||
it('switches between the base favicon and generated blob badges', async () => {
|
||||
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
|
||||
const { rerender } = renderHook(
|
||||
({
|
||||
unreadCounts,
|
||||
mentions,
|
||||
currentFavorites,
|
||||
}: {
|
||||
unreadCounts: Record<string, number>;
|
||||
mentions: Record<string, boolean>;
|
||||
currentFavorites: Favorite[];
|
||||
}) => useFaviconBadge(unreadCounts, mentions, currentFavorites),
|
||||
{
|
||||
initialProps: {
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
|
||||
});
|
||||
|
||||
rerender({
|
||||
unreadCounts: {
|
||||
[getStateKey('channel', 'fav-chan')]: 1,
|
||||
},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('blob:generated-1');
|
||||
expect(getIconHref('shortcut icon')).toBe('blob:generated-1');
|
||||
});
|
||||
|
||||
rerender({
|
||||
unreadCounts: {
|
||||
[getStateKey('contact', 'dm-key')]: 12,
|
||||
},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('blob:generated-2');
|
||||
expect(getIconHref('shortcut icon')).toBe('blob:generated-2');
|
||||
});
|
||||
|
||||
rerender({
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(createObjectURLMock).toHaveBeenCalledTimes(2);
|
||||
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-1');
|
||||
expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:generated-2');
|
||||
});
|
||||
|
||||
it('writes unread counts into the page title', () => {
|
||||
const { rerender, unmount } = renderHook(
|
||||
({
|
||||
unreadCounts,
|
||||
favorites,
|
||||
}: {
|
||||
unreadCounts: Record<string, number>;
|
||||
favorites: Favorite[];
|
||||
}) => useUnreadTitle(unreadCounts, favorites),
|
||||
{
|
||||
initialProps: {
|
||||
unreadCounts: {},
|
||||
favorites: [{ type: 'channel', id: 'fav-chan' }],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(document.title).toBe('RemoteTerm for MeshCore');
|
||||
|
||||
rerender({
|
||||
unreadCounts: {
|
||||
[getStateKey('channel', 'fav-chan')]: 4,
|
||||
[getStateKey('contact', 'dm-key')]: 2,
|
||||
},
|
||||
favorites: [{ type: 'channel', id: 'fav-chan' }],
|
||||
});
|
||||
|
||||
expect(document.title).toBe('(4) RemoteTerm');
|
||||
|
||||
unmount();
|
||||
|
||||
expect(document.title).toBe('RemoteTerm for MeshCore');
|
||||
});
|
||||
});
|
||||
@@ -136,7 +136,7 @@ describe('useWebSocket dispatch', () => {
|
||||
expect(onContactResolved).toHaveBeenCalledWith('abc123def456', contact);
|
||||
});
|
||||
|
||||
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths)', () => {
|
||||
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths, packetId)', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
renderHook(() => useWebSocket({ onMessageAcked }));
|
||||
|
||||
@@ -144,7 +144,7 @@ describe('useWebSocket dispatch', () => {
|
||||
fireMessage({ type, data });
|
||||
|
||||
expect(onMessageAcked).toHaveBeenCalledOnce();
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined);
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(42, 1, undefined, undefined);
|
||||
});
|
||||
|
||||
it('routes message_acked with paths', () => {
|
||||
@@ -154,7 +154,16 @@ describe('useWebSocket dispatch', () => {
|
||||
const paths = [{ path: 'aabb', received_at: 1700000000 }];
|
||||
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, paths } });
|
||||
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths);
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, paths, undefined);
|
||||
});
|
||||
|
||||
it('routes message_acked with packet_id', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
renderHook(() => useWebSocket({ onMessageAcked }));
|
||||
|
||||
fireMessage({ type: 'message_acked', data: { message_id: 7, ack_count: 2, packet_id: 99 } });
|
||||
|
||||
expect(onMessageAcked).toHaveBeenCalledWith(7, 2, undefined, 99);
|
||||
});
|
||||
|
||||
it('routes error event to onError', () => {
|
||||
|
||||
@@ -78,6 +78,8 @@ export interface HealthStatus {
|
||||
oldest_undecrypted_timestamp: number | null;
|
||||
fanout_statuses: Record<string, FanoutStatusEntry>;
|
||||
bots_disabled: boolean;
|
||||
bots_disabled_source?: 'env' | 'until_restart' | null;
|
||||
basic_auth_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FanoutConfig {
|
||||
@@ -267,6 +269,7 @@ export interface Message {
|
||||
acked: number;
|
||||
sender_name: string | null;
|
||||
channel_name?: string | null;
|
||||
packet_id?: number | null;
|
||||
}
|
||||
|
||||
export interface MessagesAroundResponse {
|
||||
@@ -513,6 +516,7 @@ export interface StatisticsResponse {
|
||||
total_outgoing: number;
|
||||
contacts_heard: ContactActivityCounts;
|
||||
repeaters_heard: ContactActivityCounts;
|
||||
known_channels_active: ContactActivityCounts;
|
||||
path_hash_width_24h: {
|
||||
total_packets: number;
|
||||
single_byte: number;
|
||||
|
||||
@@ -21,7 +21,12 @@ export interface UseWebSocketOptions {
|
||||
onChannel?: (channel: Channel) => void;
|
||||
onChannelDeleted?: (key: string) => void;
|
||||
onRawPacket?: (packet: RawPacket) => void;
|
||||
onMessageAcked?: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
onMessageAcked?: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => void;
|
||||
onError?: (error: ErrorEvent) => void;
|
||||
onSuccess?: (success: SuccessEvent) => void;
|
||||
onReconnect?: () => void;
|
||||
@@ -128,8 +133,14 @@ export function useWebSocket(options: UseWebSocketOptions) {
|
||||
message_id: number;
|
||||
ack_count: number;
|
||||
paths?: MessagePath[];
|
||||
packet_id?: number | null;
|
||||
};
|
||||
handlers.onMessageAcked?.(ackData.message_id, ackData.ack_count, ackData.paths);
|
||||
handlers.onMessageAcked?.(
|
||||
ackData.message_id,
|
||||
ackData.ack_count,
|
||||
ackData.paths,
|
||||
ackData.packet_id
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'error':
|
||||
|
||||
@@ -67,8 +67,8 @@ export function describeCiphertextStructure(
|
||||
case PayloadType.GroupText:
|
||||
return `Encrypted message content (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||
• Timestamp (4 bytes) - send time as unix timestamp
|
||||
• Flags (1 byte) - room-message flags byte
|
||||
• Message (remaining bytes) - UTF-8 room message text`;
|
||||
• Flags (1 byte) - channel-message flags byte
|
||||
• Message (remaining bytes) - UTF-8 channel message text`;
|
||||
case PayloadType.TextMessage:
|
||||
return `Encrypted message data (${byteLength} bytes). Contains encrypted plaintext with this structure:
|
||||
• Timestamp (4 bytes) - send time as unix timestamp
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface MessageAckedPayload {
|
||||
message_id: number;
|
||||
ack_count: number;
|
||||
paths?: MessagePath[];
|
||||
packet_id?: number | null;
|
||||
}
|
||||
|
||||
export interface ContactDeletedPayload {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.5.0"
|
||||
version = "3.6.0"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"httpx>=0.28.1",
|
||||
"pycryptodome>=3.20.0",
|
||||
"pynacl>=1.5.0",
|
||||
"meshcore==2.3.1",
|
||||
"meshcore==2.3.2",
|
||||
"aiomqtt>=2.0",
|
||||
"apprise>=1.9.7",
|
||||
"boto3>=1.38.0",
|
||||
|
||||
@@ -23,6 +23,9 @@ export interface HealthStatus {
|
||||
radio_connected: boolean;
|
||||
radio_initializing: boolean;
|
||||
connection_info: string | null;
|
||||
bots_disabled?: boolean;
|
||||
bots_disabled_source?: 'env' | 'until_restart' | null;
|
||||
basic_auth_enabled?: boolean;
|
||||
}
|
||||
|
||||
export function getHealth(): Promise<HealthStatus> {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { Locator, Page } from '@playwright/test';
|
||||
import http from 'http';
|
||||
|
||||
function escapeRegex(text: string): string {
|
||||
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function createCaptureServer(urlFactory: (port: number) => string) {
|
||||
const requests: { body: string; headers: http.IncomingHttpHeaders }[] = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
@@ -38,6 +42,15 @@ export async function openFanoutSettings(page: Page): Promise<void> {
|
||||
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
|
||||
}
|
||||
|
||||
export async function startIntegrationDraft(page: Page, integrationName: string): Promise<void> {
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
const dialog = page.getByRole('dialog', { name: 'Create Integration' });
|
||||
await dialog
|
||||
.getByRole('button', { name: new RegExp(`^${escapeRegex(integrationName)}(?:\\s|$)`) })
|
||||
.click();
|
||||
await dialog.getByRole('button', { name: 'Create' }).click();
|
||||
}
|
||||
|
||||
export function fanoutHeader(page: Page, name: string): Locator {
|
||||
const nameButton = page.getByRole('button', { name, exact: true });
|
||||
return page
|
||||
|
||||
@@ -12,8 +12,8 @@ export default defineConfig({
|
||||
timeout: 60_000,
|
||||
expect: { timeout: 15_000 },
|
||||
|
||||
// Don't retry — failures likely indicate real hardware/app issues
|
||||
retries: 0,
|
||||
// Give hardware-backed flows one automatic retry before marking the test failed.
|
||||
retries: 1,
|
||||
|
||||
// Run tests serially — single radio means no parallelism
|
||||
fullyParallel: false,
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
deleteFanoutConfig,
|
||||
getFanoutConfigs,
|
||||
} from '../helpers/api';
|
||||
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
|
||||
import {
|
||||
createCaptureServer,
|
||||
fanoutHeader,
|
||||
openFanoutSettings,
|
||||
startIntegrationDraft,
|
||||
} from '../helpers/fanout';
|
||||
|
||||
test.describe('Apprise integration settings', () => {
|
||||
let createdAppriseId: string | null = null;
|
||||
@@ -35,9 +40,7 @@ test.describe('Apprise integration settings', () => {
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
// Open add menu and pick Apprise
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Apprise' }).click();
|
||||
await startIntegrationDraft(page, 'Apprise');
|
||||
|
||||
// Should navigate to the detail/edit view with a numbered default name
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Apprise #\d+/);
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
ensureFlightlessChannel,
|
||||
createFanoutConfig,
|
||||
deleteFanoutConfig,
|
||||
getFanoutConfigs,
|
||||
} from '../helpers/api';
|
||||
import { openFanoutSettings, startIntegrationDraft } from '../helpers/fanout';
|
||||
|
||||
const BOT_CODE = `def bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path):
|
||||
if channel_name == "#flightless" and "!e2etest" in message_text.lower():
|
||||
@@ -28,32 +29,35 @@ test.describe('Bot functionality', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('create a bot via API, verify it in UI, trigger it, and verify response', async ({
|
||||
test('create a bot via UI, trigger it, and verify response', async ({
|
||||
page,
|
||||
}) => {
|
||||
// --- Step 1: Create and enable bot via fanout API ---
|
||||
const bot = await createFanoutConfig({
|
||||
type: 'bot',
|
||||
name: 'E2E Test Bot',
|
||||
config: { code: BOT_CODE },
|
||||
enabled: true,
|
||||
});
|
||||
createdBotId = bot.id;
|
||||
|
||||
// --- Step 2: Verify bot appears in settings UI ---
|
||||
await page.goto('/');
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
await page.getByText('Settings').click();
|
||||
await page.getByRole('button', { name: /MQTT.*Automation/ }).click();
|
||||
await startIntegrationDraft(page, 'Python Bot');
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Python Bot #\d+/);
|
||||
|
||||
await page.locator('#fanout-edit-name').fill('E2E Test Bot');
|
||||
|
||||
const codeEditor = page.getByLabel('Bot code editor');
|
||||
await codeEditor.click();
|
||||
await codeEditor.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A');
|
||||
await codeEditor.fill(BOT_CODE);
|
||||
|
||||
await page.getByRole('button', { name: /Save as Enabled/i }).click();
|
||||
await expect(page.getByText('Integration saved and enabled')).toBeVisible();
|
||||
|
||||
// The bot name should be visible in the integration list
|
||||
await expect(page.getByText('E2E Test Bot')).toBeVisible();
|
||||
|
||||
// Exit settings page mode
|
||||
const configs = await getFanoutConfigs();
|
||||
const createdBot = configs.find((config) => config.name === 'E2E Test Bot');
|
||||
if (createdBot) {
|
||||
createdBotId = createdBot.id;
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: /Back to Chat/i }).click();
|
||||
|
||||
// --- Step 3: Trigger the bot ---
|
||||
await page.getByText('#flightless', { exact: true }).first().click();
|
||||
|
||||
const triggerMessage = `!e2etest ${Date.now()}`;
|
||||
@@ -61,8 +65,6 @@ test.describe('Bot functionality', () => {
|
||||
await input.fill(triggerMessage);
|
||||
await page.getByRole('button', { name: 'Send', exact: true }).click();
|
||||
|
||||
// --- Step 4: Verify bot response appears ---
|
||||
// Bot has ~2s delay before responding, plus radio send time
|
||||
await expect(page.getByText('[BOT] e2e-ok')).toBeVisible({ timeout: 30_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createChannel, getChannels, getMessages } from '../helpers/api';
|
||||
* Timeout is 3 minutes to allow for intermittent traffic.
|
||||
*/
|
||||
|
||||
const ROOMS = [
|
||||
const CHANNELS = [
|
||||
'#flightless', '#bot', '#snoco', '#skagit', '#edmonds', '#bachelorette',
|
||||
'#emergency', '#furry', '#public', '#puppy', '#foobar', '#capitolhill',
|
||||
'#hamradio', '#icewatch', '#saucefamily', '#scvsar', '#startrek', '#metalmusic',
|
||||
@@ -39,14 +39,14 @@ test.describe('Incoming mesh messages', () => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
test.beforeAll(async () => {
|
||||
// Ensure all rooms exist — create any that are missing
|
||||
// Ensure all channels exist — create any that are missing
|
||||
const existing = await getChannels();
|
||||
const existingNames = new Set(existing.map((c) => c.name));
|
||||
|
||||
for (const room of ROOMS) {
|
||||
if (!existingNames.has(room)) {
|
||||
for (const channel of CHANNELS) {
|
||||
if (!existingNames.has(channel)) {
|
||||
try {
|
||||
await createChannel(room);
|
||||
await createChannel(channel);
|
||||
} catch {
|
||||
// May already exist from a concurrent creation, ignore
|
||||
}
|
||||
@@ -54,7 +54,7 @@ test.describe('Incoming mesh messages', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('receive an incoming message in any room', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||
test('receive an incoming message in any channel', { tag: '@mesh-traffic' }, async ({ page }) => {
|
||||
// Nudge echo bot on #flightless — may generate an incoming packet quickly
|
||||
await nudgeEchoBot();
|
||||
|
||||
|
||||
@@ -4,7 +4,12 @@ import {
|
||||
deleteFanoutConfig,
|
||||
getFanoutConfigs,
|
||||
} from '../helpers/api';
|
||||
import { createCaptureServer, fanoutHeader, openFanoutSettings } from '../helpers/fanout';
|
||||
import {
|
||||
createCaptureServer,
|
||||
fanoutHeader,
|
||||
openFanoutSettings,
|
||||
startIntegrationDraft,
|
||||
} from '../helpers/fanout';
|
||||
|
||||
test.describe('Webhook integration settings', () => {
|
||||
let createdWebhookId: string | null = null;
|
||||
@@ -35,9 +40,7 @@ test.describe('Webhook integration settings', () => {
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
// Open add menu and pick Webhook
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Webhook' }).click();
|
||||
await startIntegrationDraft(page, 'Webhook');
|
||||
|
||||
// Should navigate to the detail/edit view with a numbered default name
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
|
||||
@@ -77,8 +80,7 @@ test.describe('Webhook integration settings', () => {
|
||||
await openFanoutSettings(page);
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Add Integration' }).click();
|
||||
await page.getByRole('menuitem', { name: 'Webhook' }).click();
|
||||
await startIntegrationDraft(page, 'Webhook');
|
||||
await expect(page.locator('#fanout-edit-name')).toHaveValue(/Webhook #\d+/);
|
||||
|
||||
await page.locator('#fanout-edit-name').fill('Unsaved Webhook Draft');
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""Tests for the --disable-bots (MESHCORE_DISABLE_BOTS) startup flag.
|
||||
"""Tests for bot-disable enforcement.
|
||||
|
||||
Verifies that when disable_bots=True:
|
||||
- POST /api/fanout with type=bot returns 403
|
||||
- Health endpoint includes bots_disabled=True
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.config import Settings
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
from app.routers.fanout import FanoutConfigCreate, create_fanout_config
|
||||
from app.routers.health import build_health_data
|
||||
|
||||
@@ -33,7 +34,9 @@ class TestDisableBotsFanoutEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_create_returns_403_when_disabled(self, test_db):
|
||||
"""POST /api/fanout with type=bot returns 403."""
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
with patch(
|
||||
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_fanout_config(
|
||||
FanoutConfigCreate(
|
||||
@@ -50,7 +53,9 @@ class TestDisableBotsFanoutEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mqtt_create_allowed_when_bots_disabled(self, test_db):
|
||||
"""Non-bot fanout configs can still be created when bots are disabled."""
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
with patch(
|
||||
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
|
||||
):
|
||||
# Create as disabled so fanout_manager.reload_config is not called
|
||||
result = await create_fanout_config(
|
||||
FanoutConfigCreate(
|
||||
@@ -62,13 +67,68 @@ class TestDisableBotsFanoutEndpoint:
|
||||
)
|
||||
assert result["type"] == "mqtt_private"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_create_returns_403_when_disabled_until_restart(self, test_db):
|
||||
with patch(
|
||||
"app.routers.fanout.fanout_manager.get_bots_disabled_source",
|
||||
return_value="until_restart",
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await create_fanout_config(
|
||||
FanoutConfigCreate(
|
||||
type="bot",
|
||||
name="Test Bot",
|
||||
config={"code": "def bot(**k): pass"},
|
||||
enabled=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "until the server restarts" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disable_bots_until_restart_endpoint(self, test_db):
|
||||
from app.routers.fanout import disable_bots_until_restart
|
||||
|
||||
await FanoutConfigRepository.create(
|
||||
config_type="bot",
|
||||
name="Test Bot",
|
||||
config={"code": "def bot(**k): pass"},
|
||||
scope={"messages": "all", "raw_packets": "none"},
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.routers.fanout.fanout_manager.disable_bots_until_restart",
|
||||
new=AsyncMock(return_value="until_restart"),
|
||||
) as mock_disable,
|
||||
patch("app.websocket.broadcast_health") as mock_broadcast_health,
|
||||
patch("app.services.radio_runtime.radio_runtime") as mock_radio_runtime,
|
||||
):
|
||||
mock_radio_runtime.is_connected = True
|
||||
mock_radio_runtime.connection_info = "TCP: 1.2.3.4:4000"
|
||||
|
||||
result = await disable_bots_until_restart()
|
||||
|
||||
mock_disable.assert_awaited_once()
|
||||
mock_broadcast_health.assert_called_once_with(True, "TCP: 1.2.3.4:4000")
|
||||
assert result == {
|
||||
"status": "ok",
|
||||
"bots_disabled": True,
|
||||
"bots_disabled_source": "until_restart",
|
||||
}
|
||||
|
||||
|
||||
class TestDisableBotsHealthEndpoint:
|
||||
"""Test that bots_disabled is exposed in health data."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_includes_bots_disabled_true(self, test_db):
|
||||
with patch("app.routers.health.settings", MagicMock(disable_bots=True, database_path="x")):
|
||||
with patch(
|
||||
"app.routers.health.settings",
|
||||
MagicMock(disable_bots=True, basic_auth_enabled=False, database_path="x"),
|
||||
):
|
||||
with patch("app.routers.health.os.path.getsize", return_value=0):
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
@@ -76,8 +136,39 @@ class TestDisableBotsHealthEndpoint:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_includes_bots_disabled_false(self, test_db):
|
||||
with patch("app.routers.health.settings", MagicMock(disable_bots=False, database_path="x")):
|
||||
with patch(
|
||||
"app.routers.health.settings",
|
||||
MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"),
|
||||
):
|
||||
with patch("app.routers.health.os.path.getsize", return_value=0):
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
assert data["bots_disabled"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_includes_basic_auth_enabled(self, test_db):
|
||||
with patch(
|
||||
"app.routers.health.settings",
|
||||
MagicMock(disable_bots=False, basic_auth_enabled=True, database_path="x"),
|
||||
):
|
||||
with patch("app.routers.health.os.path.getsize", return_value=0):
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
assert data["basic_auth_enabled"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_includes_runtime_bot_disable_source(self, test_db):
|
||||
with (
|
||||
patch(
|
||||
"app.routers.health.settings",
|
||||
MagicMock(disable_bots=False, basic_auth_enabled=False, database_path="x"),
|
||||
),
|
||||
patch("app.routers.health.os.path.getsize", return_value=0),
|
||||
patch("app.fanout.manager.fanout_manager") as mock_fm,
|
||||
):
|
||||
mock_fm.get_statuses.return_value = {}
|
||||
mock_fm.get_bots_disabled_source.return_value = "until_restart"
|
||||
data = await build_health_data(True, "TCP: 1.2.3.4:4000")
|
||||
|
||||
assert data["bots_disabled"] is True
|
||||
assert data["bots_disabled_source"] == "until_restart"
|
||||
|
||||
@@ -1131,7 +1131,7 @@ class TestMessageAckedBroadcastShape:
|
||||
# Frontend MessageAckedEvent keys (from useWebSocket.ts:113-117)
|
||||
# The 'paths' key is optional in the TypeScript interface
|
||||
REQUIRED_KEYS = {"message_id", "ack_count"}
|
||||
OPTIONAL_KEYS = {"paths"}
|
||||
OPTIONAL_KEYS = {"paths", "packet_id"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_outgoing_echo_broadcast_shape(self, test_db, captured_broadcasts):
|
||||
@@ -1177,6 +1177,7 @@ class TestMessageAckedBroadcastShape:
|
||||
assert isinstance(payload["ack_count"], int)
|
||||
assert payload["message_id"] == msg_id
|
||||
assert payload["ack_count"] == 1
|
||||
assert payload["packet_id"] == pkt_id
|
||||
|
||||
# paths should be a list of dicts with path and received_at keys
|
||||
assert isinstance(payload["paths"], list)
|
||||
@@ -1228,6 +1229,7 @@ class TestMessageAckedBroadcastShape:
|
||||
assert payload_keys >= self.REQUIRED_KEYS
|
||||
assert payload_keys <= (self.REQUIRED_KEYS | self.OPTIONAL_KEYS)
|
||||
assert payload["ack_count"] == 0 # Not outgoing, no ack increment
|
||||
assert payload["packet_id"] == pkt1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dm_echo_broadcast_shape(self, test_db, captured_broadcasts):
|
||||
@@ -1283,3 +1285,4 @@ class TestMessageAckedBroadcastShape:
|
||||
assert isinstance(payload["message_id"], int)
|
||||
assert isinstance(payload["ack_count"], int)
|
||||
assert payload["ack_count"] == 0 # Outgoing DM duplicates no longer count as delivery
|
||||
assert payload["packet_id"] == pkt1
|
||||
|
||||
@@ -384,6 +384,7 @@ class TestContactMessageCLIFiltering:
|
||||
"acked",
|
||||
"sender_name",
|
||||
"channel_name",
|
||||
"packet_id",
|
||||
}
|
||||
|
||||
with patch("app.event_handlers.broadcast_event") as mock_broadcast:
|
||||
|
||||
@@ -593,7 +593,9 @@ class TestDisableBotsPatchGuard:
|
||||
)
|
||||
|
||||
# Now try to update with bots disabled
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
with patch(
|
||||
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await update_fanout_config(
|
||||
cfg["id"],
|
||||
@@ -617,7 +619,9 @@ class TestDisableBotsPatchGuard:
|
||||
enabled=False,
|
||||
)
|
||||
|
||||
with patch("app.routers.fanout.server_settings", MagicMock(disable_bots=True)):
|
||||
with patch(
|
||||
"app.routers.fanout.fanout_manager.get_bots_disabled_source", return_value="env"
|
||||
):
|
||||
with patch("app.fanout.manager.fanout_manager.reload_config", new_callable=AsyncMock):
|
||||
result = await update_fanout_config(
|
||||
cfg["id"],
|
||||
|
||||
@@ -86,6 +86,16 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
|
||||
assert "index page" in missing_response.text
|
||||
assert missing_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
missing_api_response = client.get("/api/not-a-real-endpoint")
|
||||
assert missing_api_response.status_code == 404
|
||||
assert missing_api_response.json() == {
|
||||
"detail": (
|
||||
"API endpoint not found. If you are seeing this in response to a frontend "
|
||||
"request, you may be running a newer frontend with an older backend or vice "
|
||||
"versa. A full update is suggested."
|
||||
)
|
||||
}
|
||||
|
||||
asset_response = client.get("/assets/app.js")
|
||||
assert asset_response.status_code == 200
|
||||
assert "console.log('ok');" in asset_response.text
|
||||
|
||||
@@ -56,6 +56,48 @@ class TestUndecryptedCount:
|
||||
assert response.json()["count"] == 3
|
||||
|
||||
|
||||
class TestGetRawPacket:
|
||||
"""Test GET /api/packets/{id}."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_404_when_missing(self, test_db, client):
|
||||
response = await client.get("/api/packets/999999")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_linked_packet_details(self, test_db, client):
|
||||
channel_key = "DEADBEEF" * 4
|
||||
await ChannelRepository.upsert(key=channel_key, name="#ops", is_hashtag=False)
|
||||
packet_id, _ = await RawPacketRepository.create(b"\x09\x00test-packet", 1700000000)
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Alice: hello",
|
||||
conversation_key=channel_key,
|
||||
sender_timestamp=1700000000,
|
||||
received_at=1700000000,
|
||||
sender_name="Alice",
|
||||
)
|
||||
assert msg_id is not None
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
response = await client.get(f"/api/packets/{packet_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == packet_id
|
||||
assert data["timestamp"] == 1700000000
|
||||
assert data["data"] == "0900746573742d7061636b6574"
|
||||
assert data["decrypted"] is True
|
||||
assert data["decrypted_info"] == {
|
||||
"channel_name": "#ops",
|
||||
"sender": "Alice",
|
||||
"channel_key": channel_key,
|
||||
"contact_key": None,
|
||||
}
|
||||
|
||||
|
||||
class TestDecryptHistoricalPackets:
|
||||
"""Test POST /api/packets/decrypt/historical."""
|
||||
|
||||
|
||||
@@ -29,6 +29,9 @@ class TestStatisticsEmpty:
|
||||
assert result["repeaters_heard"]["last_hour"] == 0
|
||||
assert result["repeaters_heard"]["last_24_hours"] == 0
|
||||
assert result["repeaters_heard"]["last_week"] == 0
|
||||
assert result["known_channels_active"]["last_hour"] == 0
|
||||
assert result["known_channels_active"]["last_24_hours"] == 0
|
||||
assert result["known_channels_active"]["last_week"] == 0
|
||||
assert result["path_hash_width_24h"] == {
|
||||
"total_packets": 0,
|
||||
"single_byte": 0,
|
||||
@@ -256,6 +259,51 @@ class TestActivityWindows:
|
||||
assert result["repeaters_heard"]["last_24_hours"] == 1
|
||||
assert result["repeaters_heard"]["last_week"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_known_channels_active_windows(self, test_db):
|
||||
"""Known channels are counted by distinct active keys in each time window."""
|
||||
now = int(time.time())
|
||||
conn = test_db.conn
|
||||
|
||||
known_1h = "AA" * 16
|
||||
known_24h = "BB" * 16
|
||||
known_7d = "CC" * 16
|
||||
unknown_key = "DD" * 16
|
||||
|
||||
await conn.execute("INSERT INTO channels (key, name) VALUES (?, ?)", (known_1h, "chan-1h"))
|
||||
await conn.execute(
|
||||
"INSERT INTO channels (key, name) VALUES (?, ?)", (known_24h, "chan-24h")
|
||||
)
|
||||
await conn.execute("INSERT INTO channels (key, name) VALUES (?, ?)", (known_7d, "chan-7d"))
|
||||
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", known_1h, "recent-1", now - 1200),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", known_1h, "recent-2", now - 600),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", known_24h, "day-old", now - 43200),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", known_7d, "week-old", now - 259200),
|
||||
)
|
||||
await conn.execute(
|
||||
"INSERT INTO messages (type, conversation_key, text, received_at) VALUES (?, ?, ?, ?)",
|
||||
("CHAN", unknown_key, "unknown", now - 600),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
result = await StatisticsRepository.get_all()
|
||||
|
||||
assert result["known_channels_active"]["last_hour"] == 1
|
||||
assert result["known_channels_active"]["last_24_hours"] == 2
|
||||
assert result["known_channels_active"]["last_week"] == 3
|
||||
|
||||
|
||||
class TestPathHashWidthStats:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
10
uv.lock
generated
10
uv.lock
generated
@@ -534,7 +534,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meshcore"
|
||||
version = "2.3.1"
|
||||
version = "2.3.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "bleak" },
|
||||
@@ -542,9 +542,9 @@ dependencies = [
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "pyserial-asyncio-fast" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/a8/79f84f32cad056358b1e31dbb343d7f986f78fd93021dbbde306a9b4d36e/meshcore-2.3.1.tar.gz", hash = "sha256:07bd2267cb84a335b915ea6dab1601ae7ae13cad5923793e66b2356c3e351e24", size = 69503 }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/32/6e7a3e7dcc379888bc2bfcbbdf518af89e47b3697977cbfefd0b87fdf333/meshcore-2.3.2.tar.gz", hash = "sha256:98ceb8c28a8abe5b5b77f0941b30f99ba3d4fc2350f76de99b6c8a4e778dad6f", size = 69871 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/df/66d615298b717c2c6471592e2b96117f391ae3c99f477d7f424449897bf0/meshcore-2.3.1-py3-none-any.whl", hash = "sha256:59bb8b66fd9e3261dbdb0e69fc038d4606bfd4ad1a260bbdd8659066e4bf12d2", size = 53084 },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/e4/9aafcd70315e48ca1bbae2f4ad1e00a13d5ef00019c486f964b31c34c488/meshcore-2.3.2-py3-none-any.whl", hash = "sha256:7b98e6d71f2c1e1ee146dd2fe96da40eb5bf33077e34ca840557ee53b192e322", size = 53325 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1098,7 +1098,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.5.0"
|
||||
version = "3.6.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aiomqtt" },
|
||||
@@ -1142,7 +1142,7 @@ requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
||||
{ name = "httpx", specifier = ">=0.28.1" },
|
||||
{ name = "httpx", marker = "extra == 'test'", specifier = ">=0.27.0" },
|
||||
{ name = "meshcore", specifier = "==2.3.1" },
|
||||
{ name = "meshcore", specifier = "==2.3.2" },
|
||||
{ name = "pycryptodome", specifier = ">=3.20.0" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.0.0" },
|
||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||
|
||||
Reference in New Issue
Block a user