Skip to content
This repository was archived by the owner on May 3, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions TASK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

32 changes: 26 additions & 6 deletions examples/EmergencyManagement/web_gateway/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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,
Expand All @@ -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,
}


Expand Down
64 changes: 64 additions & 0 deletions examples/EmergencyManagement/web_gateway/interface_status.py
Original file line number Diff line number Diff line change
@@ -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
53 changes: 53 additions & 0 deletions examples/EmergencyManagement/webui/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,6 +36,7 @@ interface GatewayInfo {
lxmfStoragePath?: string | null;
allowedOrigins?: string[];
linkStatus?: LinkStatus | null;
reticulumInterfaces?: ReticulumInterfaceStatus[] | null;
}

export function DashboardPage(): JSX.Element {
Expand All @@ -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;
Expand Down Expand Up @@ -126,6 +159,26 @@ export function DashboardPage(): JSX.Element {
<dd>{resolvedLastError}</dd>
</div>
)}
<div>
<dt>Active Interfaces</dt>
<dd>
{activeInterfaces.length > 0
? activeInterfaces.map((item) => formatInterface(item)).join(', ')
: 'No active interfaces reported'}
</dd>
</div>
<div>
<dt>Configured Interfaces</dt>
<dd>
{reticulumInterfaces.length > 0
? reticulumInterfaces
.map((item) =>
`${formatInterface(item)} (${item.online ? 'online' : 'offline'})`,
)
.join(', ')
: 'No interfaces reported'}
</dd>
</div>
</dl>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions tests/examples/emergency_management/test_web_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import pytest
from fastapi.testclient import TestClient
import RNS

from examples.EmergencyManagement.Server.models_emergency import (
EAMStatus,
Expand Down Expand Up @@ -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:
Expand All @@ -74,13 +98,16 @@ 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

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:
Expand Down Expand Up @@ -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."""

Expand Down
Loading