diff --git a/TASK.md b/TASK.md index 47ee888..2cc3257 100644 --- a/TASK.md +++ b/TASK.md @@ -96,6 +96,9 @@ - [x] Add HTTP integration tests for the EmergencyManagement web UI message and event flows. +## 2025-09-25 +- [x] Surface active Reticulum interfaces in the EmergencyManagement gateway startup logs and dashboard. + ## 2025-10-01 - [x] Upgrade esbuild dependency to version 0.25.0 or later to address the development server request vulnerability. diff --git a/examples/EmergencyManagement/web_gateway/app.py b/examples/EmergencyManagement/web_gateway/app.py index 46bc4ee..120e39a 100644 --- a/examples/EmergencyManagement/web_gateway/app.py +++ b/examples/EmergencyManagement/web_gateway/app.py @@ -55,6 +55,10 @@ from reticulum_openapi.codec_msgpack import CodecError from reticulum_openapi.codec_msgpack import decode_payload_bytes +from examples.EmergencyManagement.web_gateway.interface_status import ( + gather_interface_status, +) + COMMAND_CREATE_EAM = "CreateEmergencyActionMessage" COMMAND_DELETE_EAM = "DeleteEmergencyActionMessage" @@ -163,6 +167,7 @@ def _load_gateway_config() -> ConfigDict: _NOTIFICATION_UNSUBSCRIBER: Optional[Callable[[], Awaitable[None]]] = None _LINK_RETRY_DELAY_SECONDS: float = 5.0 _LINK_TASK: Optional[asyncio.Task[None]] = None +_INTERFACE_STATUS: List[Dict[str, Any]] = [] def _format_timestamp(value: Optional[datetime]) -> Optional[str]: @@ -200,6 +205,14 @@ def to_dict(self) -> Dict[str, Optional[str]]: _LINK_STATUS = _LinkStatus() +def _refresh_interface_status() -> List[Dict[str, Any]]: + """Refresh and cache the current Reticulum interface metadata.""" + + global _INTERFACE_STATUS + _INTERFACE_STATUS = gather_interface_status() + return _INTERFACE_STATUS + + def _normalise_optional_path(value: Optional[str]) -> Optional[str]: """Return a stripped path string or ``None`` when empty.""" @@ -296,9 +309,7 @@ def _record_link_success(server_identity: str, attempt_time: datetime) -> None: logger.info("Established LXMF link with server %s", server_identity) -async def _ensure_link_with_retry( - client: LXMFClient, server_identity: str -) -> None: +async def _ensure_link_with_retry(client: LXMFClient, server_identity: str) -> None: """Continuously attempt to connect the LXMF client to the server.""" while True: @@ -330,15 +341,22 @@ async def _startup() -> None: """Ensure the LXMF client is ready before serving requests.""" client = get_shared_client() + interface_status = _refresh_interface_status() + active_interfaces = [ + status["name"] for status in interface_status if status.get("online") + ] + if active_interfaces: + joined = ", ".join(active_interfaces) + print("[Emergency Gateway] Active Reticulum interfaces: " f"{joined}") + else: + print("[Emergency Gateway] No active Reticulum interfaces reported.") global _LINK_TASK server_identity = _DEFAULT_SERVER_IDENTITY if server_identity: _LINK_STATUS.server_identity = server_identity _LINK_STATUS.state = "connecting" _LINK_STATUS.last_error = None - _LINK_STATUS.message = ( - f"Attempting to connect to LXMF server {server_identity}" - ) + _LINK_STATUS.message = f"Attempting to connect to LXMF server {server_identity}" if _LINK_TASK is None or _LINK_TASK.done(): _LINK_TASK = asyncio.create_task( _ensure_link_with_retry(client, server_identity) @@ -473,6 +491,7 @@ async def get_gateway_status() -> Dict[str, Any]: storage_path_override = _normalise_optional_path( _CONFIG_DATA.get(LXMF_STORAGE_PATH_KEY) ) + interface_status = _refresh_interface_status() return { "version": _GATEWAY_VERSION, @@ -484,6 +503,7 @@ async def get_gateway_status() -> Dict[str, Any]: "lxmfStoragePath": storage_path_override, "allowedOrigins": _ALLOWED_ORIGINS, "linkStatus": _LINK_STATUS.to_dict(), + "reticulumInterfaces": interface_status, } diff --git a/examples/EmergencyManagement/web_gateway/interface_status.py b/examples/EmergencyManagement/web_gateway/interface_status.py new file mode 100644 index 0000000..8274860 --- /dev/null +++ b/examples/EmergencyManagement/web_gateway/interface_status.py @@ -0,0 +1,64 @@ +"""Helpers for reporting Reticulum interface status.""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +import RNS +from RNS.Interfaces import Interface as RNSInterface + + +def _resolve_interface_mode_name(mode: Optional[int]) -> Optional[str]: + """Return a descriptive name for a Reticulum interface mode.""" + + if mode is None: + return None + mapping = { + RNSInterface.Interface.MODE_FULL: "full", + RNSInterface.Interface.MODE_ACCESS_POINT: "access_point", + RNSInterface.Interface.MODE_POINT_TO_POINT: "point_to_point", + RNSInterface.Interface.MODE_ROAMING: "roaming", + } + return mapping.get(mode, str(mode)) + + +def _resolve_interface_name(interface: Any, index: int) -> str: + """Return a human readable name for a Reticulum interface.""" + + name_value = getattr(interface, "name", None) + if isinstance(name_value, str) and name_value.strip(): + return name_value.strip() + return f"Interface-{index}" + + +def _coerce_optional_int(value: Any) -> Optional[int]: + """Return an integer value when possible.""" + + if isinstance(value, bool): + return None + if isinstance(value, (int, float)): + try: + return int(value) + except (TypeError, ValueError): + return None + return None + + +def gather_interface_status() -> List[Dict[str, Any]]: + """Return status metadata for all configured Reticulum interfaces.""" + + statuses: List[Dict[str, Any]] = [] + for index, interface in enumerate(RNS.Transport.interfaces): + mode_value = getattr(interface, "mode", None) + bitrate_value = _coerce_optional_int(getattr(interface, "bitrate", None)) + statuses.append( + { + "id": f"{type(interface).__name__}:{index}", + "name": _resolve_interface_name(interface, index), + "type": type(interface).__name__, + "online": bool(getattr(interface, "online", False)), + "mode": _resolve_interface_mode_name(mode_value), + "bitrate": bitrate_value, + } + ) + return statuses diff --git a/examples/EmergencyManagement/webui/src/pages/DashboardPage.tsx b/examples/EmergencyManagement/webui/src/pages/DashboardPage.tsx index b932917..61c6ab2 100644 --- a/examples/EmergencyManagement/webui/src/pages/DashboardPage.tsx +++ b/examples/EmergencyManagement/webui/src/pages/DashboardPage.tsx @@ -17,6 +17,15 @@ interface LinkStatus { lastError?: string | null; } +interface ReticulumInterfaceStatus { + id: string; + name: string; + type: string; + online: boolean; + mode?: string | null; + bitrate?: number | null; +} + interface GatewayInfo { version: string; uptime: string; @@ -27,6 +36,7 @@ interface GatewayInfo { lxmfStoragePath?: string | null; allowedOrigins?: string[]; linkStatus?: LinkStatus | null; + reticulumInterfaces?: ReticulumInterfaceStatus[] | null; } export function DashboardPage(): JSX.Element { @@ -47,12 +57,35 @@ export function DashboardPage(): JSX.Element { : [], [gatewayInfo], ); + const reticulumInterfaces = useMemo( + () => + gatewayInfo && Array.isArray(gatewayInfo.reticulumInterfaces) + ? gatewayInfo.reticulumInterfaces + : [], + [gatewayInfo], + ); const linkStatus = gatewayInfo?.linkStatus ?? null; const resolvedLinkMessage = linkStatus?.message ?? 'No link status reported yet.'; const resolvedLinkState = linkStatus?.state ?? 'unknown'; const resolvedLastSuccess = linkStatus?.lastSuccess ?? 'Never'; const resolvedLastAttempt = linkStatus?.lastAttempt ?? 'Never'; const resolvedLastError = linkStatus?.lastError ?? null; + const activeInterfaces = useMemo( + () => reticulumInterfaces.filter((item) => item.online), + [reticulumInterfaces], + ); + const formatInterface = (item: ReticulumInterfaceStatus): string => { + const parts: string[] = []; + const resolvedName = item.name?.trim() ? item.name.trim() : item.type; + parts.push(resolvedName); + if (item.mode?.trim()) { + parts.push(item.mode.trim()); + } + if (typeof item.bitrate === 'number' && Number.isFinite(item.bitrate)) { + parts.push(`${item.bitrate} bps`); + } + return parts.join(' • '); + }; useEffect(() => { let isMounted = true; @@ -126,6 +159,26 @@ export function DashboardPage(): JSX.Element {
{resolvedLastError}
)} +
+
Active Interfaces
+
+ {activeInterfaces.length > 0 + ? activeInterfaces.map((item) => formatInterface(item)).join(', ') + : 'No active interfaces reported'} +
+
+
+
Configured Interfaces
+
+ {reticulumInterfaces.length > 0 + ? reticulumInterfaces + .map((item) => + `${formatInterface(item)} (${item.online ? 'online' : 'offline'})`, + ) + .join(', ') + : 'No interfaces reported'} +
+
)} diff --git a/examples/EmergencyManagement/webui/src/pages/__tests__/DashboardPage.test.tsx b/examples/EmergencyManagement/webui/src/pages/__tests__/DashboardPage.test.tsx index a9bce97..f43cbb0 100644 --- a/examples/EmergencyManagement/webui/src/pages/__tests__/DashboardPage.test.tsx +++ b/examples/EmergencyManagement/webui/src/pages/__tests__/DashboardPage.test.tsx @@ -42,6 +42,24 @@ describe('DashboardPage', () => { lastAttempt: '2025-09-23T12:34:56Z', lastError: null, }, + reticulumInterfaces: [ + { + id: 'AutoInterface:0', + name: 'Mesh Neighbors', + type: 'AutoInterface', + online: true, + mode: 'full', + bitrate: 125000, + }, + { + id: 'TCPClientInterface:1', + name: 'WAN Link', + type: 'TCPClientInterface', + online: false, + mode: 'access_point', + bitrate: null, + }, + ], }, } as AxiosResponse<{ version: string; @@ -59,6 +77,14 @@ describe('DashboardPage', () => { lastAttempt?: string | null; lastError?: string | null; }; + reticulumInterfaces: { + id: string; + name: string; + type: string; + online: boolean; + mode?: string | null; + bitrate?: number | null; + }[]; }>; vi.spyOn(apiClientModule.apiClient, 'get').mockResolvedValueOnce(gatewayInfoResponse); @@ -75,6 +101,12 @@ describe('DashboardPage', () => { expect(screen.getByText('Connected to LXMF server abc123')).toBeInTheDocument(); const timestampMatches = screen.getAllByText('2025-09-23T12:34:56Z'); expect(timestampMatches).toHaveLength(2); + expect(screen.getByText('Mesh Neighbors • full • 125000 bps')).toBeInTheDocument(); + expect( + screen.getByText( + 'Mesh Neighbors • full • 125000 bps (online), WAN Link • access_point (offline)', + ), + ).toBeInTheDocument(); expect(screen.getByText('http://localhost:8000')).toBeInTheDocument(); expect(screen.getByText('http://localhost:8000/notifications/stream')).toBeInTheDocument(); expect( diff --git a/tests/examples/emergency_management/test_web_gateway.py b/tests/examples/emergency_management/test_web_gateway.py index d14e4c7..cb5d1df 100644 --- a/tests/examples/emergency_management/test_web_gateway.py +++ b/tests/examples/emergency_management/test_web_gateway.py @@ -11,6 +11,7 @@ import pytest from fastapi.testclient import TestClient +import RNS from examples.EmergencyManagement.Server.models_emergency import ( EAMStatus, @@ -55,6 +56,29 @@ def announce(self) -> None: self.announce_called = True monkeypatch.setattr(module, "LXMFClient", StubClient) + mode_full = RNS.Interfaces.Interface.Interface.MODE_FULL + mode_roaming = RNS.Interfaces.Interface.Interface.MODE_ROAMING + + class StubInterface: + """Simple stand-in for Reticulum interface status.""" + + def __init__(self, name: str, online: bool, mode: int, bitrate: int) -> None: + self.name = name + self.online = online + self.mode = mode + self.bitrate = bitrate + + status_module = importlib.import_module( + "examples.EmergencyManagement.web_gateway.interface_status" + ) + monkeypatch.setattr( + status_module.RNS.Transport, + "interfaces", + [ + StubInterface("Local Gateway", True, mode_full, 1_000_000), + StubInterface("Long Range", False, mode_roaming, 62_500), + ], + ) with TestClient(module.app) as client: if not created_clients: @@ -74,6 +98,8 @@ def announce(self) -> None: module._LINK_STATUS.server_identity == module._DEFAULT_SERVER_IDENTITY ) assert module._LINK_STATUS.message.startswith("Connected to LXMF") + assert module._INTERFACE_STATUS + assert module._INTERFACE_STATUS[0]["name"] == "Local Gateway" stub.send_command.reset_mock() stub.ensure_link.reset_mock() yield module, client, stub @@ -81,6 +107,7 @@ def announce(self) -> None: module._CLIENT_INSTANCE = None module._LINK_TASK = None module._LINK_STATUS = module._LinkStatus() + module._INTERFACE_STATUS = [] def test_default_identity_uses_json_config(monkeypatch) -> None: @@ -134,6 +161,22 @@ def test_create_emergency_action_message_routes_payload(gateway_app) -> None: assert kwargs["await_response"] is True +def test_gateway_status_includes_interface_details(gateway_app) -> None: + """Gateway status endpoint should expose Reticulum interface metadata.""" + + module, client, _stub = gateway_app + response = client.get("/") + + assert response.status_code == 200 + payload = response.json() + interfaces = payload.get("reticulumInterfaces") + assert isinstance(interfaces, list) + assert interfaces + first = interfaces[0] + assert first["name"] == "Local Gateway" + assert first["online"] is True + + def test_list_emergency_action_messages_decodes_messagepack(gateway_app) -> None: """Listing EAMs should decode MessagePack arrays to JSON lists."""