Merge pull request #317 from naturallaw777/copilot/update-hub-router-port-forwarding-ui
Clarify Hub router forwarding copy and surface internal IP in Element Call flows
This commit is contained in:
@@ -3001,7 +3001,7 @@ async def api_service_detail(unit: str, icon: str | None = None):
|
|||||||
"detail": (
|
"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."
|
"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
|
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."
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -154,17 +154,33 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
'</div>';
|
'</div>';
|
||||||
|
|
||||||
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) {
|
if (unit === "livekit.service" && data.extra_ports && data.extra_ports.length > 0) {
|
||||||
|
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."
|
||||||
|
: "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 <strong>' + internalIpHtml + '</strong>.'
|
||||||
|
: '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 = "";
|
var extraRows = "";
|
||||||
data.extra_ports.forEach(function(p) {
|
data.extra_ports.forEach(function(p) {
|
||||||
var statusIcon, statusClass2;
|
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";
|
statusIcon = "✅ Ready";
|
||||||
statusClass2 = "port-status-listening";
|
statusClass2 = "port-status-listening";
|
||||||
} else if (p.status === "firewall_open") {
|
} else if (p.status === "firewall_open") {
|
||||||
statusIcon = "✅ Ready";
|
statusIcon = "✅ Ready";
|
||||||
statusClass2 = "port-status-open";
|
statusClass2 = "port-status-open";
|
||||||
} else if (p.status === "closed") {
|
} else if (p.status === "closed") {
|
||||||
statusIcon = "❌ Not ready";
|
statusIcon = "❌ Not ready yet";
|
||||||
statusClass2 = "port-status-closed";
|
statusClass2 = "port-status-closed";
|
||||||
} else {
|
} else {
|
||||||
statusIcon = "— Could not check";
|
statusIcon = "— Could not check";
|
||||||
@@ -179,12 +195,15 @@ async function openServiceDetailModal(unit, name, icon) {
|
|||||||
});
|
});
|
||||||
html += '<div class="svc-detail-section">' +
|
html += '<div class="svc-detail-section">' +
|
||||||
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
'<div class="svc-detail-section-title">Ports to Forward in Your Router</div>' +
|
||||||
'<div class="svc-detail-port-note">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.</div>' +
|
'<div class="svc-detail-port-note">Forward these ports in your router to this Sovran_SystemsOS computer.</div>' +
|
||||||
|
'<div class="svc-detail-port-note"><strong>Router Forward-To IP:</strong> ' + internalIpHtml + '</div>' +
|
||||||
|
'<div class="svc-detail-port-note">' + routerIpHelp + '</div>' +
|
||||||
'<table class="svc-detail-port-table">' +
|
'<table class="svc-detail-port-table">' +
|
||||||
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
|
'<thead><tr><th>Port</th><th>Protocol</th><th>Used For</th><th>Sovran_SystemsOS Status</th></tr></thead>' +
|
||||||
'<tbody>' + extraRows + '</tbody>' +
|
'<tbody>' + extraRows + '</tbody>' +
|
||||||
'</table>' +
|
'</table>' +
|
||||||
'<div class="svc-detail-port-note">Next step: Log in to your router and forward the ports above to this Sovran_SystemsOS computer.<br>Full public port verification requires an outside internet check, so the Hub cannot fully confirm router forwarding from inside your home network.</div>' +
|
'<div class="svc-detail-port-note">The Hub can check whether Sovran_SystemsOS is ready on this computer, but full public port verification requires an outside internet check.</div>' +
|
||||||
|
'<div class="svc-detail-port-note">' + routerNextStep + '</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
} else if (data.port_statuses && data.port_statuses.length > 0) {
|
} else if (data.port_statuses && data.port_statuses.length > 0) {
|
||||||
|
|||||||
@@ -527,9 +527,16 @@ async function loadStep4() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var internalIp = (networkData && networkData.internal_ip) || "unknown";
|
var trimmedInternalIp = (networkData && networkData.internal_ip) ? String(networkData.internal_ip).trim() : "";
|
||||||
|
var internalIp = trimmedInternalIp || "";
|
||||||
var ip = escHtml(internalIp);
|
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 <strong>' + ip + '</strong>'
|
||||||
|
: 'Use this computer’s internal IP as the destination/internal IP';
|
||||||
|
|
||||||
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">'
|
var html = '<p class="onboarding-port-note" style="margin-bottom:14px;">'
|
||||||
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
|
+ '⚠ <strong>Each port only needs to be forwarded once — all services share the same ports.</strong>'
|
||||||
@@ -539,6 +546,7 @@ async function loadStep4() {
|
|||||||
html += ' <span class="onboarding-port-ip-label">Forward router traffic to this Sovran_SystemsOS computer:</span>';
|
html += ' <span class="onboarding-port-ip-label">Forward router traffic to this Sovran_SystemsOS computer:</span>';
|
||||||
html += ' <span class="port-req-internal-ip">' + ip + '</span>';
|
html += ' <span class="port-req-internal-ip">' + ip + '</span>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
html += '<div class="onboarding-port-note" style="margin:8px 0 16px;">' + routerIpHelp + '</div>';
|
||||||
|
|
||||||
// Required ports table
|
// Required ports table
|
||||||
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
html += '<div class="onboarding-port-section" style="margin-bottom:20px;">';
|
||||||
@@ -570,8 +578,8 @@ async function loadStep4() {
|
|||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
html += '<div class="onboarding-port-totals">';
|
html += '<div class="onboarding-port-totals">';
|
||||||
html += '<strong>Total port openings: 3</strong> (without Element Calling)<br>';
|
html += '<strong>Total port openings: 3</strong> (without Element Call)<br>';
|
||||||
html += '<strong>Total port openings: 8</strong> (with Element Calling — 3 required + 5 optional)';
|
html += '<strong>Total port openings: 8</strong> (with Element Call — 3 required + 5 optional)';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
|
|
||||||
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
html += '<div class="onboarding-port-warn" style="margin-bottom:16px;">'
|
||||||
@@ -586,7 +594,7 @@ async function loadStep4() {
|
|||||||
+ '<li>Open your router\'s admin panel — usually <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code></li>'
|
+ '<li>Open your router\'s admin panel — usually <code>http://192.168.1.1</code> or <code>http://192.168.0.1</code></li>'
|
||||||
+ '<li>Look for <strong>"Port Forwarding"</strong>, <strong>"NAT"</strong>, or <strong>"Virtual Server"</strong> in the settings</li>'
|
+ '<li>Look for <strong>"Port Forwarding"</strong>, <strong>"NAT"</strong>, or <strong>"Virtual Server"</strong> in the settings</li>'
|
||||||
+ '<li>Create a new rule for each port listed above</li>'
|
+ '<li>Create a new rule for each port listed above</li>'
|
||||||
+ '<li>Set the destination/internal IP to <strong>' + ip + '</strong></li>'
|
+ '<li>' + destinationInstruction + '</li>'
|
||||||
+ '<li>Set both internal and external port to the same number</li>'
|
+ '<li>Set both internal and external port to the same number</li>'
|
||||||
+ '<li>Save and apply changes</li>'
|
+ '<li>Save and apply changes</li>'
|
||||||
+ '</ol>'
|
+ '</ol>'
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user