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 {