From 6ec28b1ad75817977d7da9d50383b24747f1cd88 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 24 Jun 2026 22:14:35 +0000
Subject: [PATCH 1/3] Initial plan
From bd3dbcb05784bfb6c8c06aea84f5efef79b0a6f3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 24 Jun 2026 22:17:13 +0000
Subject: [PATCH 2/3] fix: clarify router forwarding IP guidance
---
app/sovran_systemsos_web/server.py | 2 +-
.../static/js/service-detail.js | 26 ++-
app/sovran_systemsos_web/static/onboarding.js | 19 +-
.../test_service_detail_router_wording.py | 171 ++++++++++++++++++
4 files changed, 207 insertions(+), 11 deletions(-)
create mode 100644 app/tests/test_service_detail_router_wording.py
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 += '
';
@@ -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()
From c2f3f048b98a8ee5a41313781bda0d7c42ca63e4 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Wed, 24 Jun 2026 22:19:14 +0000
Subject: [PATCH 3/3] fix: simplify internal IP copy handling
---
app/sovran_systemsos_web/static/js/service-detail.js | 3 ++-
app/sovran_systemsos_web/static/onboarding.js | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/app/sovran_systemsos_web/static/js/service-detail.js b/app/sovran_systemsos_web/static/js/service-detail.js
index 627f9a4..036e90f 100644
--- a/app/sovran_systemsos_web/static/js/service-detail.js
+++ b/app/sovran_systemsos_web/static/js/service-detail.js
@@ -154,7 +154,8 @@ 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 trimmedInternalIp = data.internal_ip ? String(data.internal_ip).trim() : "";
+ var internalIp = trimmedInternalIp || "";
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."
diff --git a/app/sovran_systemsos_web/static/onboarding.js b/app/sovran_systemsos_web/static/onboarding.js
index b703b4d..f6b5868 100644
--- a/app/sovran_systemsos_web/static/onboarding.js
+++ b/app/sovran_systemsos_web/static/onboarding.js
@@ -527,7 +527,8 @@ async function loadStep4() {
return;
}
- var internalIp = (networkData && networkData.internal_ip && String(networkData.internal_ip).trim()) ? String(networkData.internal_ip).trim() : "";
+ var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : "";
+ var internalIp = trimmedInternalIp || "";
var hasInternalIp = !!internalIp;
var ip = escHtml(internalIp || "Could not detect");
var routerIpHelp = hasInternalIp