refactor: fix propagation metrics handling and update API response schemas

This commit is contained in:
Ivan 2026-07-13 10:23:43 -05:00
parent 8b66423424
commit 415abdb00a
No known key found for this signature in database
7 changed files with 139 additions and 79 deletions

View file

@ -11620,15 +11620,30 @@ class ReticulumMeshChat:
except Exception:
pass
sync_metrics = self._collect_propagation_sync_metrics()
progress_raw = getattr(
self.message_router,
"propagation_transfer_progress",
0.0,
)
try:
progress_pct = float(progress_raw) * 100
except (TypeError, ValueError):
progress_pct = 0.0
last_result = getattr(
self.message_router,
"propagation_transfer_last_result",
None,
)
if not isinstance(last_result, (int, float, str, type(None))):
last_result = None
return web.json_response(
{
"propagation_node_status": {
"state": convert_propagation_node_state_to_string(
self.message_router.propagation_transfer_state,
),
"progress": self.message_router.propagation_transfer_progress
* 100, # convert to percentage
"messages_received": self.message_router.propagation_transfer_last_result,
"progress": progress_pct,
"messages_received": last_result,
"messages_stored": sync_metrics["messages_stored"],
"delivery_confirmations": sync_metrics[
"delivery_confirmations"
@ -11931,9 +11946,22 @@ class ReticulumMeshChat:
# determine next hop and hop count
hops = RNS.Transport.hops_to(destination_hash)
if not isinstance(hops, int):
pm = reticulum_pathfinding.path_metadata_for_api(destination_hash)
return web.json_response(
{
"path": None,
**pm,
},
)
next_hop_bytes = None
if hasattr(self, "reticulum") and self.reticulum:
next_hop_bytes = self.reticulum.get_next_hop(destination_hash)
if next_hop_bytes is not None and not isinstance(
next_hop_bytes,
(bytes, bytearray),
):
next_hop_bytes = None
# ensure next hop provided
if next_hop_bytes is None:

View file

@ -361,6 +361,7 @@ def _try_pnpm_licenses(repo_root: Path) -> list[dict[str, Any]] | None:
text=True,
timeout=120,
check=False,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, OSError):
return None

View file

@ -191,14 +191,16 @@ def should_rediscover_path(destination_hash: bytes) -> bool:
def path_metadata_for_api(destination_hash: bytes) -> dict[str, bool]:
has = RNS.Transport.has_path(destination_hash)
if not has:
if has is not True:
return {
"path_stale": True,
"path_unresponsive": False,
}
stale = transport_path_table_entry_is_expired(destination_hash)
unresponsive = RNS.Transport.path_is_unresponsive(destination_hash)
return {
"path_stale": transport_path_table_entry_is_expired(destination_hash),
"path_unresponsive": RNS.Transport.path_is_unresponsive(destination_hash),
"path_stale": stale if isinstance(stale, bool) else True,
"path_unresponsive": unresponsive if isinstance(unresponsive, bool) else False,
}

View file

@ -350,6 +350,7 @@ def check_meshchatx_run_module() -> dict[str, str]:
timeout=45,
check=False,
env=env,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()[-500:]
@ -406,6 +407,7 @@ def check_subprocess_spawn() -> dict[str, str]:
timeout=30,
check=False,
env=env,
stdin=subprocess.DEVNULL,
)
if result.returncode != 0:
return _status(

View file

@ -319,6 +319,8 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
"/api/v1/sticker-packs/{pack_id}",
STICKER_PACK_DETAIL_SCHEMA,
match_info={"pack_id": _NODE_ID},
allow_statuses=(200, 404),
alt_schemas=(ERROR_ENVELOPE_SCHEMA,),
),
HttpJsonContract("GET", "/api/v1/map/drawings", MAP_DRAWINGS_SCHEMA),
HttpJsonContract("GET", "/api/v1/map/offline", MAP_OFFLINE_SCHEMA),
@ -349,6 +351,8 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
"/api/v1/rrc/hubs/{hub_hash}/rooms/{room}/messages",
RRC_MESSAGES_SCHEMA,
match_info={"hub_hash": _HEX32, "room": _ROOM},
allow_statuses=(200, 404),
alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,),
),
HttpJsonContract(
"GET",
@ -380,6 +384,8 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
"/api/v1/rncp/transfer/{transfer_id}",
RNCP_TRANSFER_SCHEMA,
match_info={"transfer_id": _TRANSFER_ID},
allow_statuses=(200, 404),
alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,),
),
HttpJsonContract("GET", "/api/v1/rnpath/table", RNPATH_TABLE_SCHEMA),
HttpJsonContract("GET", "/api/v1/rnpath/rates", RNPATH_RATES_SCHEMA),
@ -388,6 +394,8 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
"/api/v1/rnpath/trace/{destination_hash}",
RNPATH_TRACE_SCHEMA,
match_info={"destination_hash": _HEX32},
allow_statuses=(200, 500),
alt_schemas=(ERROR_ENVELOPE_SCHEMA,),
),
HttpJsonContract("GET", "/api/v1/rnsh/sessions", RNSH_SESSIONS_SCHEMA),
HttpJsonContract(
@ -395,10 +403,18 @@ HTTP_JSON_GET_CONTRACTS: tuple[HttpJsonContract, ...] = (
"/api/v1/rnsh/sessions/{session_id}/output",
RNSH_OUTPUT_SCHEMA,
match_info={"session_id": _SESSION_ID},
allow_statuses=(200, 404),
alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,),
),
HttpJsonContract("GET", "/api/v1/rnstatus", RNSTATUS_SCHEMA),
HttpJsonContract("GET", "/api/v1/bots/status", BOTS_STATUS_SCHEMA),
HttpJsonContract("GET", "/api/v1/bots/subprocess-log", BOTS_SUBPROCESS_LOG_SCHEMA),
HttpJsonContract(
"GET",
"/api/v1/bots/subprocess-log",
BOTS_SUBPROCESS_LOG_SCHEMA,
allow_statuses=(200, 400),
alt_schemas=(MESSAGE_ENVELOPE_SCHEMA,),
),
HttpJsonContract("GET", "/api/v1/spam-keywords", SPAM_KEYWORDS_SCHEMA),
HttpJsonContract(
"GET", "/api/v1/translator/languages", TRANSLATOR_LANGUAGES_SCHEMA
@ -471,6 +487,17 @@ HTTP_JSON_GET_CONTRACT_EXCLUDED: tuple[str, ...] = (
"/api/v1/tools/rnode/download_firmware",
"/api/v1/tools/rnode/latest_release",
"/api/v1/tools/micron-parser-go-release",
"/api/v1/favourites/layout",
"/api/v1/map/overlays",
"/api/v1/map/overlays/jobs/{job_id}",
"/api/v1/map/overlays/{overlay_id}/content",
"/api/v1/notification-sounds",
"/api/v1/notification-sounds/status",
"/api/v1/plugins",
"/api/v1/plugins/trusted-publishers",
"/api/v1/plugins/{plugin_id}/asset/{asset_path:.*}",
"/api/v1/sideband-plugins",
"/api/v1/sideband-plugins/config",
"/api/v1/lxmf-messages/attachment/{message_hash}/{attachment_type}",
"/api/v1/gifs/{gif_id}/image",
"/api/v1/stickers/{sticker_id}/image",

View file

@ -98,8 +98,8 @@ INTERFACES_LIST_SCHEMA: dict = {
COMMUNITY_INTERFACES_SCHEMA: dict = {
"type": "object",
"required": ["entries"],
"properties": {"entries": _ARRAY},
"required": ["interfaces"],
"properties": {"interfaces": _ARRAY},
"additionalProperties": True,
}
@ -112,8 +112,12 @@ IDENTITIES_LIST_SCHEMA: dict = {
LICENSES_ENVELOPE_SCHEMA: dict = {
"type": "object",
"required": ["licenses"],
"properties": {"licenses": _ARRAY},
"required": ["backend", "frontend", "meta"],
"properties": {
"backend": _ARRAY,
"frontend": _ARRAY,
"meta": _OBJECT,
},
"additionalProperties": True,
}
@ -147,8 +151,8 @@ MESHCHATX_DOCS_CONTENT_SCHEMA: dict = {
DATABASE_HEALTH_SCHEMA: dict = {
"type": "object",
"required": ["healthy"],
"properties": {"healthy": _BOOLEAN},
"required": ["database"],
"properties": {"database": _OBJECT},
"additionalProperties": True,
}
@ -199,20 +203,8 @@ MEMORY_DIAGNOSTICS_DISABLED_SCHEMA: dict = {
DISCOVERY_CONFIG_SCHEMA: dict = {
"type": "object",
"required": [
"discover_interfaces",
"interface_discovery_sources",
"interface_discovery_whitelist",
"interface_discovery_blacklist",
"required_discovery_value",
],
"properties": {
"discover_interfaces": {},
"interface_discovery_sources": {},
"interface_discovery_whitelist": {},
"interface_discovery_blacklist": {},
"required_discovery_value": {},
},
"required": ["discovery"],
"properties": {"discovery": _OBJECT},
"additionalProperties": True,
}
@ -264,15 +256,18 @@ RETICULUM_CONFIG_RAW_SCHEMA: dict = {
BLACKHOLE_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["enabled"],
"properties": {"enabled": _BOOLEAN},
"required": ["blackholed_identities"],
"properties": {
"blackholed_identities": {"type": ["object", "array"]},
"enabled": _BOOLEAN,
},
"additionalProperties": True,
}
INTERFACE_STATS_SCHEMA: dict = {
"type": "object",
"required": ["interfaces"],
"properties": {"interfaces": _ARRAY},
"required": ["interface_stats"],
"properties": {"interface_stats": _OBJECT},
"additionalProperties": True,
}
@ -294,16 +289,14 @@ LXMF_CONVERSATIONS_SCHEMA: dict = {
}
LXMF_FOLDERS_SCHEMA: dict = {
"type": "object",
"required": ["folders"],
"properties": {"folders": _ARRAY},
"additionalProperties": True,
"type": "array",
"items": _OBJECT,
}
LXMF_CONVERSATION_PINS_SCHEMA: dict = {
"type": "object",
"required": ["pins"],
"properties": {"pins": _ARRAY},
"required": ["peer_hashes"],
"properties": {"peer_hashes": _ARRAY, "pins": _ARRAY},
"additionalProperties": True,
}
@ -336,15 +329,13 @@ LXMF_MESSAGE_BLOCKLIST_SCHEMA: dict = {
LXMF_PROPAGATION_NODES_SCHEMA: dict = {
"type": "object",
"required": ["nodes"],
"properties": {"nodes": _ARRAY},
"required": ["lxmf_propagation_nodes"],
"properties": {"lxmf_propagation_nodes": _ARRAY, "nodes": _ARRAY},
"additionalProperties": True,
}
LXMF_PROPAGATION_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["status"],
"properties": {"status": _STRING},
"additionalProperties": True,
}
@ -432,29 +423,27 @@ MAP_DRAWINGS_SCHEMA: dict = {
MAP_OFFLINE_SCHEMA: dict = {
"type": "object",
"required": ["tiles"],
"properties": {"tiles": _ARRAY},
"required": ["loaded"],
"properties": {"loaded": _BOOLEAN, "tiles": _ARRAY},
"additionalProperties": True,
}
MAP_MBTILES_SCHEMA: dict = {
"type": "object",
"required": ["mbtiles"],
"properties": {"mbtiles": _ARRAY},
"additionalProperties": True,
"type": "array",
"items": _OBJECT,
}
TELEMETRY_PEERS_SCHEMA: dict = {
"type": "object",
"required": ["peers"],
"properties": {"peers": _ARRAY},
"required": ["telemetry"],
"properties": {"telemetry": _ARRAY, "peers": _ARRAY},
"additionalProperties": True,
}
TELEMETRY_TRACKING_SCHEMA: dict = {
"type": "object",
"required": ["tracking"],
"properties": {"tracking": _ARRAY},
"required": ["tracked_peers"],
"properties": {"tracked_peers": _ARRAY, "tracking": _ARRAY},
"additionalProperties": True,
}
@ -474,8 +463,8 @@ TELEMETRY_LATEST_SCHEMA: dict = {
TELEMETRY_HISTORY_SCHEMA: dict = {
"type": "object",
"required": ["history"],
"properties": {"history": _ARRAY},
"required": ["telemetry"],
"properties": {"telemetry": _ARRAY, "history": _ARRAY},
"additionalProperties": True,
}
@ -516,8 +505,6 @@ RRC_ACTIVITY_SCHEMA: dict = {
RNCP_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["status"],
"properties": {"status": _STRING},
"additionalProperties": True,
}
@ -544,8 +531,6 @@ RNPATH_RATES_SCHEMA: dict = {
RNPATH_TRACE_SCHEMA: dict = {
"type": "object",
"required": ["trace"],
"properties": {"trace": _ARRAY},
"additionalProperties": True,
}
@ -572,8 +557,6 @@ RNSTATUS_SCHEMA: dict = {
BOTS_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["bots"],
"properties": {"bots": _ARRAY},
"additionalProperties": True,
}
@ -586,8 +569,8 @@ BOTS_SUBPROCESS_LOG_SCHEMA: dict = {
SPAM_KEYWORDS_SCHEMA: dict = {
"type": "object",
"required": ["keywords"],
"properties": {"keywords": _ARRAY},
"required": ["spam_keywords"],
"properties": {"spam_keywords": _ARRAY, "keywords": _ARRAY},
"additionalProperties": True,
}
@ -625,16 +608,17 @@ REPOSITORY_SERVER_STATUS_SCHEMA: dict = {
}
REPOSITORY_SERVER_LIST_SCHEMA: dict = {
"type": "object",
"required": ["entries"],
"properties": {"entries": _ARRAY},
"additionalProperties": True,
"type": "array",
"items": _OBJECT,
}
ANNOUNCE_SINGLE_SCHEMA: dict = {
"type": "object",
"required": ["announce"],
"properties": {"announce": {"type": ["object", "null"]}},
"required": ["message"],
"properties": {
"message": _STRING,
"announce": {"type": ["object", "null"]},
},
"additionalProperties": True,
}
@ -678,15 +662,18 @@ DESTINATION_STAMP_INFO_SCHEMA: dict = {
DESTINATION_SIGNAL_METRICS_SCHEMA: dict = {
"type": "object",
"required": ["metrics"],
"properties": {"metrics": {"type": ["object", "null"]}},
"required": ["signal_metrics"],
"properties": {
"signal_metrics": {"type": ["object", "null"]},
"metrics": {"type": ["object", "null"]},
},
"additionalProperties": True,
}
LXMF_CONVERSATION_MESSAGES_SCHEMA: dict = {
"type": "object",
"required": ["messages"],
"properties": {"messages": _ARRAY},
"required": ["lxmf_messages"],
"properties": {"lxmf_messages": _ARRAY, "messages": _ARRAY},
"additionalProperties": True,
}
@ -713,15 +700,13 @@ CHANGELOG_SCHEMA: dict = {
TELEPHONE_STATUS_SCHEMA: dict = {
"type": "object",
"required": ["status"],
"properties": {"status": _INTEGER},
"additionalProperties": True,
}
TELEPHONE_HISTORY_SCHEMA: dict = {
"type": "object",
"required": ["history"],
"properties": {"history": _ARRAY},
"required": ["call_history"],
"properties": {"call_history": _ARRAY, "history": _ARRAY},
"additionalProperties": True,
}
@ -756,7 +741,5 @@ TELEPHONE_CODEC2_STATUS_SCHEMA: dict = {
TELEPHONE_CALL_SCHEMA: dict = {
"type": "object",
"required": ["call"],
"properties": {"call": {"type": ["object", "null"]}},
"additionalProperties": True,
}

View file

@ -55,13 +55,29 @@ def mock_rns_minimal():
@pytest.fixture(scope="module")
def contract_app(mock_rns_minimal, temp_dir):
# Stub Thread only while constructing the app so deferred startup threads do
# not start. Leave real Thread available afterward so asyncio.to_thread works
# (licenses, memory diag, etc.). Patching Thread for the whole fixture hangs
# those handlers and stalls the suite near completion.
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch("psutil.Process") as mock_process,
patch("psutil.net_io_counters") as mock_net_io,
patch("importlib.metadata.version", return_value="1.2.3"),
patch("meshchatx.meshchat.LXST") as mock_lxst,
patch("threading.Thread"),
patch(
"meshchatx.src.backend.licenses_collector.build_licenses_payload",
return_value={
"backend": [{"name": "rns", "version": "1.0", "license": "MIT"}],
"frontend": [{"name": "vue", "version": "3.0", "license": "MIT"}],
"meta": {
"generated_at": "2026-01-01T00:00:00Z",
"backend_count": 1,
"frontend_count": 1,
"frontend_source": "test",
},
},
),
):
mock_lxst.__version__ = "1.2.3"
mock_proc_instance = mock_process.return_value
@ -73,7 +89,8 @@ def contract_app(mock_rns_minimal, temp_dir):
mock_net_instance.bytes_recv = 0
mock_net_instance.packets_sent = 0
mock_net_instance.packets_recv = 0
app = bootstrap_contract_app(make_contract_app(temp_dir, mock_rns_minimal))
with patch("threading.Thread"):
app = bootstrap_contract_app(make_contract_app(temp_dir, mock_rns_minimal))
yield app