diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 5a6a97c..39f9bdf 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -3001,7 +3001,7 @@ async def api_service_detail(unit: str, icon: str | None = None): "detail": ( "Sovran_SystemsOS is ready to use these ports on this computer. Now forward them in your router so Element Call can work from outside your home network." if all_local_ready - else "Sovran_SystemsOS is not ready to use all required Element Call ports on this computer yet. Fix the ports marked “Not ready” below, then forward them in your router." + else "Sovran_SystemsOS is not ready to use all required Element Call ports on this computer yet. Fix the ports marked “Not ready yet” below, then forward them in your router." ), }) diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js index 2437c8c..627f9a4 100644 --- a/app/sovran_systemsos_web/static/js/service-detail.js +++ b/app/sovran_systemsos_web/static/js/service-detail.js @@ -154,17 +154,32 @@ async function openServiceDetailModal(unit, name, icon) { ''; if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) { + var internalIp = (data.internal_ip && String(data.internal_ip).trim()) ? String(data.internal_ip).trim() : ""; + var internalIpHtml = internalIp ? escHtml(internalIp) : "Could not detect"; + var routerIpHelp = internalIp + ? "Use this IP address as the destination/internal IP when creating each router forwarding rule." + : "Use this computer’s internal IP as the destination/internal IP when creating each router forwarding rule."; + var routerNextStep = internalIp + ? 'Next step: Log in to your router and create forwarding rules for the ports above. Set the destination/internal IP to ' + internalIpHtml + '.' + : 'Next step: Log in to your router and create forwarding rules for the ports above. Use this computer’s internal IP as the destination/internal IP.'; + var domainConfigured = !!(data.domain && String(data.domain).trim()); var extraRows = ""; data.extra_ports.forEach(function(p) { var statusIcon, statusClass2; - if (p.status === "listening") { + if (!effectiveEnabled) { + statusIcon = "⚠ Configure Element Call first"; + statusClass2 = "port-status-open"; + } else if (!domainConfigured) { + statusIcon = "⚠ Configure domain first"; + statusClass2 = "port-status-open"; + } else if (p.status === "listening") { statusIcon = "✅ Ready"; statusClass2 = "port-status-listening"; } else if (p.status === "firewall_open") { statusIcon = "✅ Ready"; statusClass2 = "port-status-open"; } else if (p.status === "closed") { - statusIcon = "❌ Not ready"; + statusIcon = "❌ Not ready yet"; statusClass2 = "port-status-closed"; } else { statusIcon = "— Could not check"; @@ -179,12 +194,15 @@ async function openServiceDetailModal(unit, name, icon) { }); html += '
' + '
Ports to Forward in Your Router
' + - '
These checks only confirm that Sovran_SystemsOS is prepared on this computer. Your router still needs to forward these ports from the public internet to this computer.
' + + '
Forward these ports in your router to this Sovran_SystemsOS computer.
' + + '
Router Forward-To IP: ' + internalIpHtml + '
' + + '
' + routerIpHelp + '
' + '' + '' + '' + extraRows + '' + '
PortProtocolUsed ForSovran_SystemsOS Status
' + - '
Next step: Log in to your router and forward the ports above to this Sovran_SystemsOS computer.
Full public port verification requires an outside internet check, so the Hub cannot fully confirm router forwarding from inside your home network.
' + + '
The Hub can check whether Sovran_SystemsOS is ready on this computer, but full public port verification requires an outside internet check.
' + + '
' + routerNextStep + '
' + '
'; } } else if (data.port_statuses && data.port_statuses.length > 0) { diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js index 91d97a5..b703b4d 100644 --- a/app/sovran_systemsos_web/static/onboarding.js +++ b/app/sovran_systemsos_web/static/onboarding.js @@ -527,9 +527,15 @@ async function loadStep4() { return; } - var internalIp = (networkData && networkData.internal_ip) || "unknown"; - - var ip = escHtml(internalIp); + var internalIp = (networkData && networkData.internal_ip && String(networkData.internal_ip).trim()) ? String(networkData.internal_ip).trim() : ""; + var hasInternalIp = !!internalIp; + var ip = escHtml(internalIp || "Could not detect"); + var routerIpHelp = hasInternalIp + ? "Use this IP address as the destination/internal IP when creating each router forwarding rule." + : "Use this computer’s internal IP as the destination/internal IP when creating each router forwarding rule."; + var destinationInstruction = hasInternalIp + ? 'Set the destination/internal IP to ' + ip + '' + : 'Use this computer’s internal IP as the destination/internal IP'; var html = '

' + '⚠ Each port only needs to be forwarded once — all services share the same ports.' @@ -539,6 +545,7 @@ async function loadStep4() { html += ' Forward router traffic to this Sovran_SystemsOS computer:'; html += ' ' + ip + ''; html += ''; + html += '

' + routerIpHelp + '
'; // Required ports table html += '
'; @@ -570,8 +577,8 @@ async function loadStep4() { // Totals html += '
'; - html += 'Total port openings: 3 (without Element Calling)
'; - html += 'Total port openings: 8 (with Element Calling — 3 required + 5 optional)'; + html += 'Total port openings: 3 (without Element Call)
'; + html += 'Total port openings: 8 (with Element Call — 3 required + 5 optional)'; html += '
'; html += '
' @@ -586,7 +593,7 @@ async function loadStep4() { + '
  • Open your router\'s admin panel — usually http://192.168.1.1 or http://192.168.0.1
  • ' + '
  • Look for "Port Forwarding", "NAT", or "Virtual Server" in the settings
  • ' + '
  • Create a new rule for each port listed above
  • ' - + '
  • Set the destination/internal IP to ' + ip + '
  • ' + + '
  • ' + destinationInstruction + '
  • ' + '
  • Set both internal and external port to the same number
  • ' + '
  • Save and apply changes
  • ' + '' diff --git a/app/tests/test_service_detail_router_wording.py b/app/tests/test_service_detail_router_wording.py new file mode 100644 index 0000000..1894ab6 --- /dev/null +++ b/app/tests/test_service_detail_router_wording.py @@ -0,0 +1,171 @@ +import unittest +from pathlib import Path +from unittest.mock import mock_open, patch +import sys +import types + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + + +def _install_web_stubs(): + if "fastapi" in sys.modules: + return + + class _HTTPException(Exception): + def __init__(self, status_code=None, detail=None): + super().__init__(detail) + self.status_code = status_code + self.detail = detail + + class _FastAPI: + def __init__(self, *args, **kwargs): + pass + + def mount(self, *args, **kwargs): + return None + + def add_middleware(self, *args, **kwargs): + return None + + def __getattr__(self, _name): + def _decorator_factory(*args, **kwargs): + def _decorator(func): + return func + + return _decorator + + return _decorator_factory + + class _BaseModel: + pass + + class _StaticFiles: + def __init__(self, *args, **kwargs): + pass + + class _Jinja2Templates: + def __init__(self, *args, **kwargs): + pass + + class _BaseHTTPMiddleware: + pass + + fastapi_module = types.ModuleType("fastapi") + fastapi_module.FastAPI = _FastAPI + fastapi_module.HTTPException = _HTTPException + sys.modules["fastapi"] = fastapi_module + + responses_module = types.ModuleType("fastapi.responses") + responses_module.HTMLResponse = object + responses_module.JSONResponse = object + responses_module.RedirectResponse = object + sys.modules["fastapi.responses"] = responses_module + + staticfiles_module = types.ModuleType("fastapi.staticfiles") + staticfiles_module.StaticFiles = _StaticFiles + sys.modules["fastapi.staticfiles"] = staticfiles_module + + templating_module = types.ModuleType("fastapi.templating") + templating_module.Jinja2Templates = _Jinja2Templates + sys.modules["fastapi.templating"] = templating_module + + requests_module = types.ModuleType("fastapi.requests") + requests_module.Request = object + sys.modules["fastapi.requests"] = requests_module + + pydantic_module = types.ModuleType("pydantic") + pydantic_module.BaseModel = _BaseModel + sys.modules["pydantic"] = pydantic_module + + starlette_base_module = types.ModuleType("starlette.middleware.base") + starlette_base_module.BaseHTTPMiddleware = _BaseHTTPMiddleware + sys.modules["starlette.middleware.base"] = starlette_base_module + + starlette_middleware_module = types.ModuleType("starlette.middleware") + starlette_middleware_module.base = starlette_base_module + sys.modules["starlette.middleware"] = starlette_middleware_module + + starlette_module = types.ModuleType("starlette") + starlette_module.middleware = starlette_middleware_module + sys.modules["starlette"] = starlette_module + + +_install_web_stubs() +from sovran_systemsos_web import server + + +class ServiceDetailRouterWordingTests(unittest.IsolatedAsyncioTestCase): + async def test_livekit_service_detail_includes_internal_ip(self): + service_cfg = { + "services": [ + {"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"} + ] + } + domain_eval = { + "domain_status": {"status": "ok"}, + "domain_reachable": {"reachable": True}, + "domain_check_steps": [], + "has_issues": False, + } + + with ( + patch.object(server, "load_config", return_value=service_cfg), + patch.object(server, "_read_hub_overrides", return_value=({}, None, None)), + patch.object(server.sysctl, "is_active", return_value="active"), + patch.dict(server.SERVICE_DOMAIN_MAP, {"livekit.service": "element-call"}, clear=False), + patch.dict( + server.SERVICE_PORT_REQUIREMENTS, + {"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]}, + clear=False, + ), + patch("builtins.open", mock_open(read_data="call.example.com\n")), + patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval), + patch.object(server, "_get_internal_ip", return_value="192.168.1.44"), + patch.object(server, "_save_internal_ip"), + patch.object(server, "_get_listening_ports", return_value={"tcp": {7881}, "udp": set()}), + patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}), + ): + result = await server.api_service_detail("livekit.service") + + self.assertEqual(result["internal_ip"], "192.168.1.44") + self.assertEqual(result["extra_ports"][0]["status"], "listening") + self.assertEqual(result["domain_check_steps"][-1]["label"], "Router Setup Needed") + + async def test_livekit_router_step_uses_not_ready_yet_wording(self): + service_cfg = { + "services": [ + {"unit": "livekit.service", "icon": "element-call", "enabled": True, "type": "system"} + ] + } + domain_eval = { + "domain_status": {"status": "ok"}, + "domain_reachable": {"reachable": True}, + "domain_check_steps": [], + "has_issues": False, + } + + with ( + patch.object(server, "load_config", return_value=service_cfg), + patch.object(server, "_read_hub_overrides", return_value=({}, None, None)), + patch.object(server.sysctl, "is_active", return_value="active"), + patch.dict(server.SERVICE_DOMAIN_MAP, {"livekit.service": "element-call"}, clear=False), + patch.dict( + server.SERVICE_PORT_REQUIREMENTS, + {"livekit.service": [{"port": "7881", "protocol": "TCP", "description": "LiveKit"}]}, + clear=False, + ), + patch("builtins.open", mock_open(read_data="call.example.com\n")), + patch.object(server, "_evaluate_domain_checklist", return_value=domain_eval), + patch.object(server, "_get_internal_ip", return_value="192.168.1.44"), + patch.object(server, "_save_internal_ip"), + patch.object(server, "_get_listening_ports", return_value={"tcp": set(), "udp": set()}), + patch.object(server, "_get_firewall_allowed_ports", return_value={"tcp": set(), "udp": set()}), + ): + result = await server.api_service_detail("livekit.service") + + self.assertEqual(result["extra_ports"][0]["status"], "closed") + self.assertIn("Not ready yet", result["domain_check_steps"][-1]["detail"]) + + +if __name__ == "__main__": + unittest.main()