diff --git a/d2ypp2/scripts/env_check.py b/d2ypp2/scripts/env_check.py index e2002f2..518a300 100644 --- a/d2ypp2/scripts/env_check.py +++ b/d2ypp2/scripts/env_check.py @@ -19,7 +19,7 @@ def _local_ips() -> list[str]: hostname = socket.gethostname() for info in socket.getaddrinfo(hostname, None, family=socket.AF_INET): ip = info[4][0] - if ip and ip != "127.0.0.1" and ip not in out: + if ip and not ip.startswith("127.") and ip not in out: out.append(ip) except OSError: pass diff --git a/d2ypp2/src/control_plane.py b/d2ypp2/src/control_plane.py index 8fdb4ed..f4a983b 100644 --- a/d2ypp2/src/control_plane.py +++ b/d2ypp2/src/control_plane.py @@ -486,6 +486,41 @@ def _env_flag(env: dict[str, str], key: str, default: bool = True) -> bool: return raw not in {"0", "false", "no", "off", "disable", "disabled"} +def _env_int(env: dict[str, str], key: str, default: int) -> int: + raw = str(env.get(key, "")).strip() + if not raw: + return default + try: + return int(raw) + except ValueError: + return default + + +def _neko_http_ports(env: dict[str, str]) -> list[int]: + return [ + _env_int(env, "NEKO_HTTP_PORT_1", _env_int(env, "NEKO_HTTP_PORT", 9200)), + _env_int(env, "NEKO_HTTP_PORT_2", 9201), + _env_int(env, "NEKO_HTTP_PORT_3", 9202), + ] + + +def _neko_instances(mdns_host: str, env: dict[str, str]) -> list[dict[str, Any]]: + instances: list[dict[str, Any]] = [] + for idx, port in enumerate(_neko_http_ports(env), start=1): + url = f"http://{mdns_host}:{port}" + instances.append( + { + "id": idx, + "label": f"Neko {idx}", + "port": port, + "url": url, + "quick_link_key": "neko_browser" if idx == 1 else f"neko_browser_{idx}", + "alias_keys": ["neko_browser_1"] if idx == 1 else [], + } + ) + return instances + + def _probe_tcp_port(host: str, port: int, timeout: float = 2.0) -> dict[str, Any]: t0 = time.monotonic() try: @@ -813,13 +848,9 @@ def recent_pm2_log_lines(max_lines: int = 14) -> list[str]: async def stack_stream_probes() -> dict[str, Any]: """本机 SRS / Neko HTTP 端口探测(供 live.local 验收)。""" env = load_stack_env() - srs_p = int(env.get("SRS_HTTP_PORT", "8080") or 8080) - srs_api_p = int(env.get("SRS_API_PORT", "1985") or 1985) - neko_ports = [ - int(env.get("NEKO_HTTP_PORT_1") or env.get("NEKO_HTTP_PORT", "9200") or 9200), - int(env.get("NEKO_HTTP_PORT_2", "9201") or 9201), - int(env.get("NEKO_HTTP_PORT_3", "9202") or 9202), - ] + srs_p = _env_int(env, "SRS_HTTP_PORT", 8080) + srs_api_p = _env_int(env, "SRS_API_PORT", 1985) + neko_ports = _neko_http_ports(env) loop = asyncio.get_event_loop() def run_srs() -> dict[str, Any]: @@ -877,9 +908,7 @@ def stack_summary() -> dict: srs_rtmp = env.get("SRS_RTMP_PORT", "1935") android_panel = env.get("ANDROID_PANEL_PORT", "5000") chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222") - neko_port = env.get("NEKO_HTTP_PORT_1") or env.get("NEKO_HTTP_PORT", "9200") - neko_port_2 = env.get("NEKO_HTTP_PORT_2", "9201") - neko_port_3 = env.get("NEKO_HTTP_PORT_3", "9202") + neko_instances = _neko_instances(mdns_host, env) redroid_port = env.get("REDROID_DEFAULT_PORT", "5555") mitmproxy_port = env.get("MITMPROXY_WEB_PORT", "8086") vscode_port = env.get("VSCODE_SERVER_PORT", "8440") @@ -900,14 +929,16 @@ def stack_summary() -> dict: "srs_api": f"http://{mdns_host}:{srs_api}/api/v1/versions", "ws_scrcpy": f"{control_url}/android", "chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}", - "neko_browser": f"http://{mdns_host}:{neko_port}", - "neko_browser_2": f"http://{mdns_host}:{neko_port_2}", - "neko_browser_3": f"http://{mdns_host}:{neko_port_3}", "redroid_adb": f"{mdns_host}:{redroid_port}", "pulseaudio_tcp": f"{mdns_host}:{pulseaudio_port}", "openclaw": f"http://{mdns_host}:{openclaw_port}", "services_console": f"{control_url}/?view=services", } + for instance in neko_instances: + quick_link_key = str(instance["quick_link_key"]) + ql[quick_link_key] = str(instance["url"]) + for alias_key in instance.get("alias_keys") or []: + ql[str(alias_key)] = str(instance["url"]) return { "hostname": hostname, "mdns_host": mdns_host, @@ -919,6 +950,7 @@ def stack_summary() -> dict: "dashboard_url": f"http://{mdns_host}" if homepage_port in {"80", ""} else f"http://{mdns_host}:{homepage_port}", "control_url": f"http://{mdns_host}:{web_port}", "stack_env_present": STACK_ENV.is_file(), + "neko_instances": neko_instances, "neko_login": { "user_login": "live", "user_password": env.get("NEKO_USER_PASS", "12345678"), diff --git a/d2ypp2/tests/test_control_plane_services.py b/d2ypp2/tests/test_control_plane_services.py index f9cf284..09af377 100644 --- a/d2ypp2/tests/test_control_plane_services.py +++ b/d2ypp2/tests/test_control_plane_services.py @@ -23,15 +23,23 @@ class ControlPlaneServiceTests(unittest.TestCase): ) def test_stack_summary_exposes_new_quick_links(self) -> None: - links = stack_summary()["quick_links"] + summary = stack_summary() + links = summary["quick_links"] for key in ( "mitmproxy", "vscode_server", "uptime_kuma", "pulseaudio_tcp", "openclaw", + "neko_browser", + "neko_browser_1", + "neko_browser_2", + "neko_browser_3", ): self.assertIn(key, links) + neko_instances = summary["neko_instances"] + self.assertEqual([item["id"] for item in neko_instances], [1, 2, 3]) + self.assertEqual(links["neko_browser"], links["neko_browser_1"]) def test_deployment_report_marks_live_local_ready_when_checks_pass(self) -> None: with tempfile.TemporaryDirectory() as tmp: diff --git a/d2ypp2/tests/test_env_check.py b/d2ypp2/tests/test_env_check.py new file mode 100644 index 0000000..e073db3 --- /dev/null +++ b/d2ypp2/tests/test_env_check.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from scripts.env_check import _local_ips + + +class EnvCheckTests(unittest.TestCase): + def test_local_ips_skips_all_loopback_aliases(self) -> None: + fake_info = [ + (None, None, None, None, ("127.0.0.1", 0)), + (None, None, None, None, ("127.0.1.1", 0)), + (None, None, None, None, ("192.168.50.10", 0)), + ] + with patch("scripts.env_check.socket.gethostname", return_value="live"): + with patch("scripts.env_check.socket.getaddrinfo", return_value=fake_info): + self.assertEqual(_local_ips(), ["192.168.50.10"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/d2ypp2/web-console/components/console/LiveConsoleWorkspace.tsx b/d2ypp2/web-console/components/console/LiveConsoleWorkspace.tsx index 8f224e5..39aaa50 100644 --- a/d2ypp2/web-console/components/console/LiveConsoleWorkspace.tsx +++ b/d2ypp2/web-console/components/console/LiveConsoleWorkspace.tsx @@ -6,6 +6,7 @@ import { type ChangeEvent, type MouseEvent, useCallback, useEffect, useMemo, use import OverviewCharts from "@/components/OverviewCharts"; import { ConfigAdvisorPanel } from "@/components/console/ConfigAdvisorPanel"; import { fetchJsonResponse, readErrorMessage } from "@/lib/fetchJson"; +import { extractNekoBrowserLinks, formatNekoPorts } from "@/lib/nekoLinks"; import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls"; type Lang = "zh" | "en"; @@ -302,6 +303,8 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: } return out; }, [stack]); + const nekoLinks = useMemo(() => extractNekoBrowserLinks(qlNorm), [qlNorm]); + const nekoPortsLabel = useMemo(() => formatNekoPorts(nekoLinks), [nekoLinks]); const rawAndroidPanelUrl = useMemo(() => { const baseUrl = @@ -1084,11 +1087,11 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }: 🧪 {tr("Chromium RemoteDebug (9222)", "Chromium RemoteDebug")} ) : null} - {qlNorm.neko_browser ? ( - - 🐱 Neko / {tr("Docker Chromium 桌面", "Docker Chromium desktop")} + {nekoLinks.map((link) => ( + + 🐱 {link.label} / {tr("Docker Chromium 桌面", "Docker Chromium desktop")} - ) : null} + ))} {qlNorm.webtty ? ( ⌨️ WebTTY @@ -1560,8 +1563,12 @@ export function LiveConsoleWorkspace({ embeddedShellCrash = false, portalLang }:

{tr( - "内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开;Neko(Chromium)在 ENABLE_NEKO=1 且容器运行后一般为 9200 端口。", - "Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab; Neko Chromium is often :9200 when ENABLE_NEKO=1 and the container is up.", + nekoPortsLabel + ? `内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开;Neko(Chromium)实例通常为 ${nekoPortsLabel}。` + : "内嵌若黑屏:多为 WebSocket 未连上或服务未启动。请先确认「系统服务」里 android_panel 在线,并尝试新标签打开;Neko(Chromium)在 ENABLE_NEKO=1 且容器运行后一般为 9200 端口。", + nekoPortsLabel + ? `Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab; Neko Chromium instances are usually on ${nekoPortsLabel}.` + : "Black iframe: often WebSocket or service down. Check android_panel in Services, try opening in a new tab; Neko Chromium is often :9200 when ENABLE_NEKO=1 and the container is up.", )}