diff --git a/d2ypp2/README.md b/d2ypp2/README.md index f8bf8a8..9fd79ea 100644 --- a/d2ypp2/README.md +++ b/d2ypp2/README.md @@ -45,6 +45,16 @@ After `install-hub.sh` or `install-all.sh`, the default service map is: - `http://live.local:8082` for File Browser with `live / 12345678` - `http://live.local:19999` for Netdata +### Install Acceptance + +After deployment, validate the real delivery path instead of only checking one port: + +- `sudo bash doctor.sh` +- `sudo bash probe-live.sh` +- `python3 tools/verify_live_console.py` with `LIVE_DEPLOY_HOST / LIVE_DEPLOY_USER / LIVE_DEPLOY_PASS` when verifying a remote node + +The control plane exposes `GET /deploy/check` for a structured report covering `live.local`, `:80`, `:8001`, LAN IP binding, exported web assets, and `system-stack.env`. + ### ShellCrash Web Control ShellCrash is no longer treated as a jump back to the terminal menu. The control plane now exposes: diff --git a/d2ypp2/docs/install-profiles.md b/d2ypp2/docs/install-profiles.md index 48e0f72..b37a728 100644 --- a/d2ypp2/docs/install-profiles.md +++ b/d2ypp2/docs/install-profiles.md @@ -10,6 +10,36 @@ sudo bash install-business.sh sudo bash install-all.sh ``` +### Acceptance After Install + +On the target Linux node, run the built-in health checks: + +```bash +sudo bash doctor.sh +sudo bash probe-live.sh +``` + +From a dev machine, you can also verify the remote node over SSH: + +```bash +LIVE_DEPLOY_HOST=192.168.x.x python3 tools/verify_live_console.py +``` + +The control plane now exposes a structured acceptance report at: + +```text +http://127.0.0.1:8001/deploy/check +``` + +It focuses on the real delivery path for this project: + +- `127.0.0.1:8001/health` +- `127.0.0.1:80/health` +- `http://live.local/health` +- `http://live.local:8001/health` +- exported `web-console/out/index.html` +- `config/system-stack.env` + ### After `git pull` (no full reinstall) 刷新依赖、重建前端、重启 `live-console` 与 `live-edge`(**不要**为此再跑 `install-all.sh`,除非你要重做整机栈): diff --git a/d2ypp2/live-platform/scripts/doctor.sh b/d2ypp2/live-platform/scripts/doctor.sh index ee1db72..9712324 100644 --- a/d2ypp2/live-platform/scripts/doctor.sh +++ b/d2ypp2/live-platform/scripts/doctor.sh @@ -2,9 +2,17 @@ # 一键体检:网络 / 端口 / adb / ffmpeg / docker / 服务状态(模块化输出) set -euo pipefail ROOT="$(cd "$(dirname "$0")/../.." && pwd)" -REPO="$(cd "$ROOT/.." && pwd)" +REPO="$ROOT" # shellcheck source=/dev/null -source "$ROOT/scripts/lib/common.sh" +source "$ROOT/live-platform/scripts/lib/common.sh" + +STACK_ENV="$REPO/config/system-stack.env" +if [[ -f "$STACK_ENV" ]]; then + set -a + # shellcheck source=/dev/null + . "$STACK_ENV" + set +a +fi echo "=== Live Platform Doctor ===" echo "arch: $(detect_arch)" @@ -14,7 +22,8 @@ section() { echo ""; echo "--- $1 ---"; } section "loopback / DNS" ping -c1 -W2 127.0.0.1 >/dev/null 2>&1 && echo "ping 127.0.0.1: ok" || echo "ping 127.0.0.1: fail" -getent hosts live.local >/dev/null 2>&1 && echo "live.local resolves: yes" || echo "live.local resolves: (optional mdns)" +MDNS_HOST="${HOSTNAME_ALIAS:-live}.local" +getent hosts "$MDNS_HOST" >/dev/null 2>&1 && echo "$MDNS_HOST resolves: yes" || echo "$MDNS_HOST resolves: fail" section "config" if [[ -f /opt/live/config/system.json ]]; then @@ -62,6 +71,41 @@ if [[ -f "$REPO/web.py" ]] && command -v curl >/dev/null 2>&1; then section "Port 80 (live-edge / Caddy)" systemctl is-active live-edge.service 2>/dev/null || echo "live-edge.service: inactive" curl -sS -m 5 -o /dev/null -w "GET http://127.0.0.1/ → http_code=%{http_code}\n" "http://127.0.0.1/" || echo "curl :80 failed" + section "Deployment Acceptance" + deploy_json="$(curl -fsS -m 10 "http://127.0.0.1:${PORT}/deploy/check" 2>/dev/null || true)" + if [[ -n "$deploy_json" ]]; then + DEPLOY_JSON="$deploy_json" python3 - <<'PY' +import json +import os +import sys + +raw = os.environ.get("DEPLOY_JSON", "").strip() +if not raw: + raise SystemExit(0) +data = json.loads(raw) +status = "READY" if data.get("ready") else "DEGRADED" +print(f"status: {status} ({data.get('pass_count', 0)}/{data.get('total_count', 0)} checks passed)") +for item in data.get("checks") or []: + mark = "OK" if item.get("ok") else "FAIL" + label = item.get("label") or item.get("id") or "check" + detail = item.get("detail") or "" + print(f"{mark:4} {label}: {detail}") +hints = data.get("hints") or [] +if hints: + print("hints:") + for line in hints: + print(f"- {line}") +urls = data.get("urls") or {} +if urls: + print("urls:") + for key in ("mdns_root", "mdns_control", "lan_root", "lan_control", "loopback_api"): + value = urls.get(key) + if value: + print(f"- {key}: {value}") +PY + else + echo "deploy/check unavailable on :$PORT" + fi fi echo "" diff --git a/d2ypp2/scripts/linux/probe_live_access.sh b/d2ypp2/scripts/linux/probe_live_access.sh index 07aefb0..a00322a 100644 --- a/d2ypp2/scripts/linux/probe_live_access.sh +++ b/d2ypp2/scripts/linux/probe_live_access.sh @@ -1,7 +1,9 @@ #!/usr/bin/env bash # 排查「live.local refused to connect」:多为 :80 无进程、服务未起、或跨机 mDNS/防火墙。 # 在设备本机执行: sudo bash scripts/linux/probe_live_access.sh -set +euo pipefail +set +e +set -u +set -o pipefail SELF_DIR="$(cd "$(dirname "$0")" && pwd)" INSTALL_TAG="probe" @@ -9,6 +11,7 @@ INSTALL_TAG="probe" . "$SELF_DIR/lib/common.sh" load_env +MDNS_HOST="${HOSTNAME_ALIAS:-live}.local" echo "=== Probe: refused to connect (no listener / firewall / wrong host) ===" echo "Note: 502 = reverse proxy up but upstream dead; refused = nothing accepting TCP." @@ -16,6 +19,7 @@ echo "" echo "--- Host / IP ---" echo "hostname: $(hostname 2>/dev/null)" +echo "mdns host: $MDNS_HOST" LAN_IP="" LAN_IP="$(detect_preferred_lan_ip || true)" echo "guess LAN IP: ${LAN_IP:-unknown}" @@ -57,11 +61,43 @@ else fi echo "" +echo "--- mDNS / LAN acceptance ---" +if getent hosts "$MDNS_HOST" >/dev/null 2>&1; then + echo "OK getent hosts $MDNS_HOST" +else + echo "FAIL getent hosts $MDNS_HOST → check avahi-daemon / libnss-mdns" +fi + +deploy_json="$(curl -fsS -m 10 "http://127.0.0.1:${PORT}/deploy/check" 2>/dev/null || true)" +if [ -n "$deploy_json" ]; then + DEPLOY_JSON="$deploy_json" python3 - <<'PY' +import json +import os + +raw = os.environ.get("DEPLOY_JSON", "").strip() +if not raw: + raise SystemExit(0) +data = json.loads(raw) +status = "READY" if data.get("ready") else "DEGRADED" +print(f"deployment: {status} ({data.get('pass_count', 0)}/{data.get('total_count', 0)})") +for item in data.get("failing_checks") or []: + label = item.get("label") or item.get("id") or "check" + detail = item.get("detail") or item.get("error") or "" + print(f"- FAIL {label}: {detail}") +for hint in data.get("hints") or []: + print(f"- hint: {hint}") +PY +else + echo "deploy/check unavailable on :${PORT}" +fi +echo "" + echo "--- From your phone/PC browser ---" echo "1) Same Wi‑Fi/LAN as this box." echo "2) Try: http://${LAN_IP}:${PORT}/ (direct control plane, bypasses :80 and mDNS)" -echo "3) live.local needs mDNS (Android often OK; Windows may need Bonjour or use IP)." -echo "4) If OK on IP but not live.local: Avahi on this host — systemctl status avahi-daemon" +echo "3) Try: http://${MDNS_HOST}/ (edge proxy) or http://${MDNS_HOST}:${PORT}/ (direct API)." +echo "4) live.local needs mDNS (Android often OK; Windows may need Bonjour or use IP)." +echo "5) If OK on IP but not live.local: Avahi on this host — systemctl status avahi-daemon" echo "" if ! systemctl is-active live-console.service >/dev/null 2>&1; then diff --git a/d2ypp2/src/control_plane.py b/d2ypp2/src/control_plane.py index 6cac087..8fdb4ed 100644 --- a/d2ypp2/src/control_plane.py +++ b/d2ypp2/src/control_plane.py @@ -479,6 +479,13 @@ def load_stack_env() -> dict[str, str]: return values +def _env_flag(env: dict[str, str], key: str, default: bool = True) -> bool: + raw = str(env.get(key, "1" if default else "0")).strip().lower() + if raw in {"", "default"}: + return default + return raw not in {"0", "false", "no", "off", "disable", "disabled"} + + def _probe_tcp_port(host: str, port: int, timeout: float = 2.0) -> dict[str, Any]: t0 = time.monotonic() try: @@ -524,6 +531,207 @@ def _probe_http_get(url: str, timeout: float = 2.5) -> dict[str, Any]: } +def _resolve_host_ipv4s(host: str) -> list[str]: + ips: list[str] = [] + try: + for info in socket.getaddrinfo(host, None, family=socket.AF_INET): + ip = str(info[4][0]).strip() + if ip and ip not in ips: + ips.append(ip) + except OSError: + return [] + return ips + + +def _deployment_file_check(check_id: str, label: str, path: Path, detail: str) -> dict[str, Any]: + return { + "id": check_id, + "label": label, + "ok": path.is_file(), + "kind": "file", + "target": str(path), + "detail": detail, + } + + +def _deployment_http_check( + check_id: str, + label: str, + url: str, + *, + expected_codes: tuple[int, ...] = (200,), + timeout: float = 2.5, +) -> dict[str, Any]: + probe = _probe_http_get(url, timeout=timeout) + code = int(probe.get("http_code") or 0) + ok = bool(probe.get("reachable")) and code in expected_codes + detail = f"HTTP {code}" if code else (probe.get("error") or "unreachable") + if probe.get("latency_ms") is not None: + detail = f"{detail} · {probe['latency_ms']} ms" + return { + "id": check_id, + "label": label, + "ok": ok, + "kind": "http", + "url": url, + "http_code": code, + "latency_ms": probe.get("latency_ms"), + "reachable": bool(probe.get("reachable")), + "detail": detail, + "error": str(probe.get("error") or ""), + } + + +def deployment_report() -> dict[str, Any]: + env = load_stack_env() + summary = stack_summary() + mdns_host = str(summary.get("mdns_host") or "live.local") + web_port = str(env.get("PORT", os.environ.get("PORT", "8001")) or "8001") + lan_ips = list(summary.get("ips") or []) + primary_lan_ip = lan_ips[0] if lan_ips else "" + live_edge_enabled = _env_flag(env, "ENABLE_LIVE_EDGE_PROXY", True) + mdns_enabled = _env_flag(env, "ENABLE_MDNS", True) + + control_assets = BASE_DIR / "web-console" / "out" / "index.html" + checks: list[dict[str, Any]] = [ + _deployment_file_check( + "stack_env", + "system-stack.env", + STACK_ENV, + "Live stack environment file", + ), + _deployment_file_check( + "console_assets", + "web-console/out/index.html", + control_assets, + "Exported web control panel assets", + ), + _deployment_http_check( + "api_loopback", + "FastAPI loopback", + f"http://127.0.0.1:{web_port}/health", + ), + ] + + if primary_lan_ip: + checks.append( + _deployment_http_check( + "api_lan", + "FastAPI LAN bind", + f"http://{primary_lan_ip}:{web_port}/health", + ) + ) + + if live_edge_enabled: + checks.append( + _deployment_http_check( + "edge_loopback", + "live-edge loopback", + "http://127.0.0.1/health", + ) + ) + if primary_lan_ip: + checks.append( + _deployment_http_check( + "edge_lan", + "live-edge LAN bind", + f"http://{primary_lan_ip}/health", + ) + ) + + mdns_ips = _resolve_host_ipv4s(mdns_host) if mdns_enabled else [] + checks.append( + { + "id": "mdns_resolution", + "label": "mDNS resolution", + "ok": bool(mdns_ips) if mdns_enabled else True, + "kind": "dns", + "host": mdns_host, + "ips": mdns_ips, + "detail": ", ".join(mdns_ips) if mdns_ips else ("disabled" if not mdns_enabled else "unresolved"), + } + ) + + if mdns_enabled and mdns_ips: + checks.append( + _deployment_http_check( + "mdns_api", + "live.local direct API", + f"http://{mdns_host}:{web_port}/health", + ) + ) + if live_edge_enabled: + checks.append( + _deployment_http_check( + "mdns_edge", + "live.local edge proxy", + f"http://{mdns_host}/health", + ) + ) + + required_ids = { + "stack_env", + "console_assets", + "api_loopback", + } + if primary_lan_ip: + required_ids.add("api_lan") + if live_edge_enabled: + required_ids.add("edge_loopback") + if primary_lan_ip: + required_ids.add("edge_lan") + if mdns_enabled: + required_ids.add("mdns_resolution") + if mdns_ips: + required_ids.add("mdns_api") + if live_edge_enabled: + required_ids.add("mdns_edge") + + pass_count = sum(1 for item in checks if item.get("ok")) + total_count = len(checks) + failing = [item for item in checks if item["id"] in required_ids and not item.get("ok")] + hints: list[str] = [] + failed_ids = {item["id"] for item in failing} + if "api_loopback" in failed_ids: + hints.append("live-console service is unhealthy on 127.0.0.1; inspect systemd logs or scripts/launch.py.") + if "api_lan" in failed_ids: + hints.append("The control plane answers on loopback but not on the LAN IP; check HOST binding and local firewall rules.") + if "edge_loopback" in failed_ids or "edge_lan" in failed_ids: + hints.append("Port 80 reverse proxy is unhealthy; restart live-edge.service or rerun scripts/linux/reinstall_live_edge.sh.") + if "mdns_resolution" in failed_ids: + hints.append("live.local does not resolve locally; check avahi-daemon, libnss-mdns, and same-LAN mDNS visibility.") + if "mdns_api" in failed_ids and "mdns_resolution" not in failed_ids: + hints.append("live.local resolves but :PORT is not reachable through that hostname; verify mDNS points to the correct LAN IP.") + if "mdns_edge" in failed_ids and "mdns_resolution" not in failed_ids: + hints.append("live.local resolves but port 80 proxy is unhealthy; verify live-edge.service and the upstream PORT value.") + if "console_assets" in failed_ids: + hints.append("The exported web console is missing; run upgrade-live.sh or rebuild web-console before relying on live.local.") + + urls = { + "loopback_api": f"http://127.0.0.1:{web_port}/health", + "mdns_control": f"http://{mdns_host}:{web_port}/", + "mdns_root": f"http://{mdns_host}/", + "lan_control": f"http://{primary_lan_ip}:{web_port}/" if primary_lan_ip else "", + "lan_root": f"http://{primary_lan_ip}/" if primary_lan_ip else "", + } + return { + "ready": not failing, + "pass_count": pass_count, + "total_count": total_count, + "required_total": len(required_ids), + "hostname": summary.get("hostname"), + "mdns_host": mdns_host, + "primary_lan_ip": primary_lan_ip, + "port": web_port, + "live_edge_enabled": live_edge_enabled, + "mdns_enabled": mdns_enabled, + "checks": checks, + "failing_checks": failing, + "hints": hints, + "urls": urls, + } + + def _iface_speed_mbps(name: str) -> int | None: path = Path("/sys/class/net") / name / "speed" if not path.is_file(): diff --git a/d2ypp2/tests/test_control_plane_services.py b/d2ypp2/tests/test_control_plane_services.py index 8093c59..f9cf284 100644 --- a/d2ypp2/tests/test_control_plane_services.py +++ b/d2ypp2/tests/test_control_plane_services.py @@ -1,8 +1,11 @@ from __future__ import annotations import unittest +import tempfile +from pathlib import Path +from unittest.mock import patch -from src.control_plane import SERVICE_SPECS, stack_summary +from src.control_plane import SERVICE_SPECS, deployment_report, stack_summary class ControlPlaneServiceTests(unittest.TestCase): @@ -30,6 +33,83 @@ class ControlPlaneServiceTests(unittest.TestCase): ): self.assertIn(key, links) + def test_deployment_report_marks_live_local_ready_when_checks_pass(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + base_dir = Path(tmp) + console_index = base_dir / "web-console" / "out" / "index.html" + console_index.parent.mkdir(parents=True, exist_ok=True) + console_index.write_text("ok", encoding="utf-8") + stack_env = base_dir / "config" / "system-stack.env" + stack_env.parent.mkdir(parents=True, exist_ok=True) + stack_env.write_text("PORT=8001\nENABLE_LIVE_EDGE_PROXY=1\nENABLE_MDNS=1\n", encoding="utf-8") + + def probe(url: str, timeout: float = 2.5) -> dict[str, object]: + return { + "reachable": True, + "http_code": 200, + "latency_ms": 3.1, + "error": "", + } + + with patch("src.control_plane.BASE_DIR", base_dir): + with patch("src.control_plane.STACK_ENV", stack_env): + with patch("src.control_plane.load_stack_env", return_value={ + "PORT": "8001", + "ENABLE_LIVE_EDGE_PROXY": "1", + "ENABLE_MDNS": "1", + }): + with patch("src.control_plane.stack_summary", return_value={ + "hostname": "live", + "mdns_host": "live.local", + "ips": ["192.168.1.10"], + }): + with patch("src.control_plane._probe_http_get", side_effect=probe): + with patch("src.control_plane._resolve_host_ipv4s", return_value=["192.168.1.10"]): + report = deployment_report() + + self.assertTrue(report["ready"]) + ids = {item["id"] for item in report["checks"]} + self.assertIn("api_loopback", ids) + self.assertIn("edge_loopback", ids) + self.assertIn("mdns_edge", ids) + self.assertEqual(report["urls"]["mdns_root"], "http://live.local/") + + def test_deployment_report_explains_edge_and_mdns_failures(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + base_dir = Path(tmp) + console_index = base_dir / "web-console" / "out" / "index.html" + console_index.parent.mkdir(parents=True, exist_ok=True) + console_index.write_text("ok", encoding="utf-8") + stack_env = base_dir / "config" / "system-stack.env" + stack_env.parent.mkdir(parents=True, exist_ok=True) + stack_env.write_text("PORT=8001\nENABLE_LIVE_EDGE_PROXY=1\nENABLE_MDNS=1\n", encoding="utf-8") + + def probe(url: str, timeout: float = 2.5) -> dict[str, object]: + if "127.0.0.1:8001/health" in url: + return {"reachable": True, "http_code": 200, "latency_ms": 2.0, "error": ""} + return {"reachable": False, "http_code": 0, "latency_ms": 2.0, "error": "connection refused"} + + with patch("src.control_plane.BASE_DIR", base_dir): + with patch("src.control_plane.STACK_ENV", stack_env): + with patch("src.control_plane.load_stack_env", return_value={ + "PORT": "8001", + "ENABLE_LIVE_EDGE_PROXY": "1", + "ENABLE_MDNS": "1", + }): + with patch("src.control_plane.stack_summary", return_value={ + "hostname": "live", + "mdns_host": "live.local", + "ips": ["192.168.1.10"], + }): + with patch("src.control_plane._probe_http_get", side_effect=probe): + with patch("src.control_plane._resolve_host_ipv4s", return_value=[]): + report = deployment_report() + + self.assertFalse(report["ready"]) + joined = "\n".join(report["hints"]) + self.assertIn("live.local does not resolve locally", joined) + self.assertIn("reverse proxy is unhealthy", joined) + if __name__ == "__main__": unittest.main() diff --git a/d2ypp2/tools/deploy_live_paramiko.py b/d2ypp2/tools/deploy_live_paramiko.py index 5158e54..a2ccaf0 100644 --- a/d2ypp2/tools/deploy_live_paramiko.py +++ b/d2ypp2/tools/deploy_live_paramiko.py @@ -66,12 +66,16 @@ FILES = [ "config/system-stack.env.example", "ecosystem.config.cjs", "docs/install-profiles.md", + "README.md", "douyin_youtube_ffplay.py", + "probe-live.sh", "scripts/linux/install_hub.sh", + "scripts/linux/probe_live_access.sh", "scripts/linux/upgrade_ffmpeg_newer.sh", "scripts/linux/reinstall_live_edge.sh", "scripts/linux/service_ctl.sh", "scripts/linux/lib/common.sh", + "live-platform/scripts/doctor.sh", "services/srs/docker-compose.yml", "services/srs/data/index.html", "services/neko/docker-compose.yml", @@ -102,6 +106,7 @@ FILES = [ "web-console/lib/youtubeProChannels.ts", "web-console/lib/youtubeConfigUtils.ts", "web-console/components/console/LiveConsoleWorkspace.tsx", + "tools/verify_live_console.py", ] FILES = sorted(set(FILES + _discover_repo_files())) diff --git a/d2ypp2/tools/verify_live_console.py b/d2ypp2/tools/verify_live_console.py index a2b4e96..326d39a 100644 --- a/d2ypp2/tools/verify_live_console.py +++ b/d2ypp2/tools/verify_live_console.py @@ -1,20 +1,61 @@ #!/usr/bin/env python3 -"""重启 live-console 并验收 HTTP(Paramiko)。""" +"""通过 Paramiko 远程验收 live-console / live-edge / live.local。""" from __future__ import annotations +import argparse +import json +import os import sys import time from pathlib import Path -import paramiko +try: + import paramiko +except ImportError: + print("请先安装: pip install paramiko", file=sys.stderr) + raise SystemExit(1) -HOST = "192.168.21.105" -USER = "live" -PASS = "12345678" +HOST = os.environ.get("LIVE_DEPLOY_HOST", os.environ.get("LIVE_VERIFY_HOST", "192.168.21.105")) +USER = os.environ.get("LIVE_DEPLOY_USER", os.environ.get("LIVE_VERIFY_USER", "live")) +PASS = os.environ.get("LIVE_DEPLOY_PASS", os.environ.get("LIVE_VERIFY_PASS", "12345678")) +PORT = os.environ.get("LIVE_VERIFY_PORT", os.environ.get("PORT", "8001")) +MDNS_HOST = os.environ.get("LIVE_VERIFY_DOMAIN", f"{os.environ.get('LIVE_VERIFY_ALIAS', 'live')}.local") LOG = Path(__file__).resolve().parent.parent / ".deploy_logs" / "verify_live_console.log" +def _safe_print(text: str) -> None: + try: + print(text) + except UnicodeEncodeError: + data = text.encode("utf-8", errors="replace") + sys.stdout.buffer.write(data + b"\n") + + +def summarize_deploy_check(raw: str) -> list[str]: + try: + data = json.loads(raw) + except json.JSONDecodeError: + return ["deploy/check: invalid JSON payload"] + lines = [ + f"deploy/check: {'READY' if data.get('ready') else 'DEGRADED'}" + f" ({data.get('pass_count', 0)}/{data.get('total_count', 0)})", + ] + for item in data.get("checks") or []: + mark = "OK" if item.get("ok") else "FAIL" + label = item.get("label") or item.get("id") or "check" + detail = item.get("detail") or item.get("error") or "" + lines.append(f"{mark:4} {label}: {detail}") + for hint in data.get("hints") or []: + lines.append(f"hint: {hint}") + return lines + + def main() -> int: + parser = argparse.ArgumentParser(description="远程验收 live-console / live-edge / live.local") + parser.add_argument("--no-restart", action="store_true", help="只验收,不先重启服务") + parser.add_argument("--wait", type=float, default=5.0, help="重启后等待秒数,默认 5") + args = parser.parse_args() + LOG.parent.mkdir(parents=True, exist_ok=True) c = paramiko.SSHClient() c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) @@ -31,23 +72,31 @@ def main() -> int: block = f"\n$ {cmd}\nexit={code}\n--- out ---\n{out}\n--- err ---\n{err}\n" with LOG.open("a", encoding="utf-8", errors="replace") as fh: fh.write(block) - tail = "\n".join(block.splitlines()[-60:]) - print(tail) + _safe_print("\n".join(block.splitlines()[-80:])) return code, out, err try: - run( - f"echo '{PASS}' | sudo -S systemctl restart live-console.service", - timeout=120, - ) - time.sleep(5) + if not args.no_restart: + run( + f"echo '{PASS}' | sudo -S systemctl restart live-console.service && " + f"(echo '{PASS}' | sudo -S systemctl restart live-edge.service || true)", + timeout=150, + ) + time.sleep(max(0.0, args.wait)) + run("systemctl is-active live-console.service", timeout=20) - run("ss -tlnp 2>/dev/null | grep 8001 || true", timeout=20) + run("systemctl is-active live-edge.service || true", timeout=20) + run(f"getent hosts {MDNS_HOST} || true", timeout=20) + run(f"ss -tlnp 2>/dev/null | grep -E ':80\\b|:{PORT}\\b' || true", timeout=20) + run(f"curl -sS -m 10 http://127.0.0.1:{PORT}/health", timeout=30) + run("curl -sS -m 10 -o /dev/null -w '127.0.0.1:80 /health -> HTTP %{http_code}\\n' http://127.0.0.1/health || true", timeout=30) run( - "curl -sS -m 15 http://127.0.0.1:8001/health && echo " - "&& curl -sS -m 15 http://127.0.0.1:8001/server_info | head -c 1200", - timeout=40, + f"curl -sS -m 10 -o /dev/null -w '{MDNS_HOST} /health -> HTTP %{{http_code}}\\n' http://{MDNS_HOST}/health || true", + timeout=30, ) + code, deploy_out, _ = run(f"curl -sS -m 12 http://127.0.0.1:{PORT}/deploy/check", timeout=40) + if code == 0: + _safe_print("\n".join(summarize_deploy_check(deploy_out))) return 0 finally: c.close() diff --git a/d2ypp2/web-console/components/live-product/LiveYoutubeUnmannedView.tsx b/d2ypp2/web-console/components/live-product/LiveYoutubeUnmannedView.tsx index dca1631..c2fdab6 100644 --- a/d2ypp2/web-console/components/live-product/LiveYoutubeUnmannedView.tsx +++ b/d2ypp2/web-console/components/live-product/LiveYoutubeUnmannedView.tsx @@ -21,7 +21,9 @@ import { loadYoutubeProChannels, newChannelId, pushYoutubeProChannelsRemote, + resolveYoutubeProChannelPm2, saveYoutubeProChannels, + sanitizeYoutubePm2Name, type YoutubeProChannel, } from "@/lib/youtubeProChannels"; import { normalizeBrowserReachableHttpUrl } from "@/lib/panelUrls"; @@ -120,14 +122,32 @@ function preferredSingleProcessPm2FromRuntime(entries: ProcessEntry[], liveRows: } function resolveChannelPm2(channel: YoutubeProChannel): string { - return (channel.pm2Name ?? "").trim() || defaultPm2NameForChannel(channel.id); + return resolveYoutubeProChannelPm2(channel); } const MODE_KEY = "live-hub-youtube-mode-v1"; +const PRO_PM2_RESERVED = new Set(["tiktok", "obs", "web"]); /** UI 暂时隐藏「多频道 Pro」切换(DOM 与逻辑保留,改 false 即可恢复) */ const HIDE_YOUTUBE_MULTI_PRO_TOGGLE = false; +type QuickLinkMap = Record; +type ProLaneDraft = { + name: string; + streamKey: string; + pm2Name: string; + notes: string; +}; + +function laneDraftFromChannel(channel: YoutubeProChannel): ProLaneDraft { + return { + name: channel.name, + streamKey: channel.streamKey ?? "", + pm2Name: channel.pm2Name ?? defaultPm2NameForChannel(channel.id), + notes: channel.notes ?? "", + }; +} + type LiveProcRow = { pm2: string; script?: string; @@ -171,6 +191,17 @@ function businessStatusBadgeClass(row: LiveProcRow | undefined): string { return "bg-zinc-600/20 text-zinc-400 ring-zinc-500/25"; } +function nonCommentUrlLinesOf(text: string): string[] { + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); +} + +function shouldPreserveLocalUrlDraft(localText: string, remoteText: string): boolean { + return nonCommentUrlLinesOf(localText).length > 0 && nonCommentUrlLinesOf(remoteText).length === 0; +} + function parseProcBusy(busy: string | null): { action: string; pm2: string } | null { if (!busy?.startsWith("proc:")) return null; const rest = busy.slice(5); @@ -281,6 +312,7 @@ type Props = { liveProcesses: LiveProcRow[]; notify: (msg: string) => void; onNavigate?: (target: PortalNavTarget) => void; + quickLinks?: QuickLinkMap; }; export function LiveYoutubeUnmannedView({ @@ -297,6 +329,7 @@ export function LiveYoutubeUnmannedView({ liveProcesses, notify, onNavigate, + quickLinks, }: Props) { const ytEntries = useMemo(() => entries.filter(isYoutubeProcess), [entries]); const staticYoutubeEntries = useMemo(() => ytEntries.filter((entry) => isStaticYoutubeControlEntry(entry)), [ytEntries]); @@ -326,6 +359,9 @@ export function LiveYoutubeUnmannedView({ const [urlReloading, setUrlReloading] = useState(false); const [urlOptimizing, setUrlOptimizing] = useState(false); const [urlFetchNote, setUrlFetchNote] = useState(null); + const [laneDrafts, setLaneDrafts] = useState>({}); + const [laneSaving, setLaneSaving] = useState(false); + const [laneStatus, setLaneStatus] = useState(null); const urlListDirtyRef = useRef(false); useEffect(() => { @@ -342,6 +378,11 @@ export function LiveYoutubeUnmannedView({ if (urlListDirtyRef.current) return; if (mode === "single") setUrlConfig(c); else { + const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? ""; + if (shouldPreserveLocalUrlDraft(localDraft, c)) { + setProUrlDraft(localDraft); + return; + } setProUrlDraft(c); if (activeCh) { setChannels((prev) => { @@ -354,7 +395,7 @@ export function LiveYoutubeUnmannedView({ } catch { /* ignore */ } - }, [activeCh, currentProc, fetchJson, mode, setUrlConfig]); + }, [activeCh, channels, currentProc, fetchJson, mode, setUrlConfig]); useEffect(() => { if (!currentProc || !urlAutoRefresh) return; @@ -381,6 +422,12 @@ export function LiveYoutubeUnmannedView({ } if (mode === "single") setUrlConfig(c); else { + const localDraft = channels.find((row) => row.id === activeCh)?.urlLines ?? ""; + if (shouldPreserveLocalUrlDraft(localDraft, c)) { + setProUrlDraft(localDraft); + setUrlFetchNote(t("服务器暂无已保存 URL,已保留本地草稿", "Server has no saved URLs, kept the local draft")); + return; + } setProUrlDraft(c); if (activeCh) { setChannels((prev) => { @@ -480,21 +527,134 @@ export function LiveYoutubeUnmannedView({ } }, [currentProc, mode, preferredSingleProc, rowForPm2, setCurrentProc, singleProcessOptions]); + const applyChannelsLocal = useCallback((next: YoutubeProChannel[]) => { + setChannels(next); + saveYoutubeProChannels(next); + }, []); + + const syncChannelsRemote = useCallback( + async (next: YoutubeProChannel[]) => { + await pushYoutubeProChannelsRemote(fetchJson, next); + }, + [fetchJson], + ); + const persistChannels = useCallback( (next: YoutubeProChannel[]) => { - setChannels(next); - saveYoutubeProChannels(next); - void pushYoutubeProChannelsRemote(fetchJson, next).catch((e) => { + applyChannelsLocal(next); + void syncChannelsRemote(next).catch((e) => { notify( `${t("多频道列表未同步到服务器", "Channel list not saved on server")}: ${(e as Error).message}`, ); }); }, - [fetchJson, notify, t], + [applyChannelsLocal, notify, syncChannelsRemote, t], ); const active = channels.find((c) => c.id === activeCh) || channels[0]; const activeSavedUrlDraft = useMemo(() => (active?.urlLines ?? "").trim(), [active?.urlLines]); + const activeLaneDraft = useMemo( + () => (active ? laneDrafts[active.id] ?? laneDraftFromChannel(active) : null), + [active, laneDrafts], + ); + + useEffect(() => { + if (!active) return; + setLaneDrafts((prev) => { + if (prev[active.id]) return prev; + return { ...prev, [active.id]: laneDraftFromChannel(active) }; + }); + }, [active]); + + useEffect(() => { + setLaneStatus(null); + }, [activeCh]); + + const setLaneDraftField = useCallback( + (key: keyof ProLaneDraft, value: string) => { + if (!active) return; + setLaneDrafts((prev) => ({ + ...prev, + [active.id]: { + ...(prev[active.id] ?? laneDraftFromChannel(active)), + [key]: value, + }, + })); + setLaneStatus(null); + }, + [active], + ); + + const activeSavedPm2 = active ? resolveChannelPm2(active) : ""; + const activeDraftPm2 = active && activeLaneDraft + ? resolveYoutubeProChannelPm2({ id: active.id, pm2Name: activeLaneDraft.pm2Name }) + : ""; + const activePm2PreviewChanged = !!active && !!activeLaneDraft && activeDraftPm2 !== activeSavedPm2; + + const activeLaneValidation = useMemo(() => { + if (!active || !activeLaneDraft) { + return { + ok: false, + message: t("请选择线路", "Pick a lane"), + normalizedName: "", + normalizedPm2: "", + normalizedStreamKey: "", + normalizedNotes: "", + }; + } + const normalizedName = activeLaneDraft.name.trim() || `${t("线路", "Ch")} ${channels.findIndex((row) => row.id === active.id) + 1}`; + const normalizedPm2 = resolveYoutubeProChannelPm2({ id: active.id, pm2Name: activeLaneDraft.pm2Name }); + if (!normalizedPm2) { + return { + ok: false, + message: t("进程名不能为空", "PM2 name is required"), + normalizedName, + normalizedPm2, + normalizedStreamKey: activeLaneDraft.streamKey.trim(), + normalizedNotes: activeLaneDraft.notes.trim(), + }; + } + if (PRO_PM2_RESERVED.has(normalizedPm2)) { + return { + ok: false, + message: t("该进程名保留给系统模块,请换一个", "This PM2 name is reserved"), + normalizedName, + normalizedPm2, + normalizedStreamKey: activeLaneDraft.streamKey.trim(), + normalizedNotes: activeLaneDraft.notes.trim(), + }; + } + const duplicate = channels.find((row) => row.id !== active.id && resolveChannelPm2(row) === normalizedPm2); + if (duplicate) { + return { + ok: false, + message: t("该进程名已被其它线路占用", "Another lane already uses this PM2 name"), + normalizedName, + normalizedPm2, + normalizedStreamKey: activeLaneDraft.streamKey.trim(), + normalizedNotes: activeLaneDraft.notes.trim(), + }; + } + return { + ok: true, + message: "", + normalizedName, + normalizedPm2, + normalizedStreamKey: activeLaneDraft.streamKey.trim(), + normalizedNotes: activeLaneDraft.notes.trim(), + }; + }, [active, activeLaneDraft, channels, t]); + + const activeLaneDirty = useMemo(() => { + if (!active || !activeLaneDraft) return false; + const savedDraft = laneDraftFromChannel(active); + return ( + activeLaneDraft.name !== savedDraft.name || + activeLaneDraft.streamKey !== savedDraft.streamKey || + sanitizeYoutubePm2Name(activeLaneDraft.pm2Name) !== sanitizeYoutubePm2Name(savedDraft.pm2Name) || + activeLaneDraft.notes !== savedDraft.notes + ); + }, [active, activeLaneDraft]); const setModePersist = useCallback( (m: "single" | "pro") => { @@ -574,6 +734,11 @@ export function LiveYoutubeUnmannedView({ ); if (cancelled || urlListDirtyRef.current) return; const c = data.content ?? ""; + const localDraft = channels.find((row) => row.id === targetChannelId)?.urlLines ?? ""; + if (shouldPreserveLocalUrlDraft(localDraft, c)) { + setProUrlDraft(localDraft); + return; + } setProUrlDraft(c); urlListDirtyRef.current = false; setChannels((prev) => { @@ -590,7 +755,7 @@ export function LiveYoutubeUnmannedView({ return () => { cancelled = true; }; - }, [activeCh, activeSavedUrlDraft, currentProc, fetchJson, mode]); + }, [activeCh, activeSavedUrlDraft, channels, currentProc, fetchJson, mode]); const loadYoutubeIni = useCallback(async () => { if (!currentProc) return; @@ -615,10 +780,80 @@ export function LiveYoutubeUnmannedView({ void loadYoutubeIni(); }, [currentProc, loadYoutubeIni]); - const updateActive = (patch: Partial) => { + const persistActiveLaneDraft = useCallback( + async () => { + if (!active || !activeLaneDraft) { + throw new Error(t("请选择线路", "Pick a lane")); + } + if (!activeLaneValidation.ok) { + throw new Error(activeLaneValidation.message); + } + const next = channels.map((row) => + row.id === active.id + ? { + ...row, + name: activeLaneValidation.normalizedName, + streamKey: activeLaneValidation.normalizedStreamKey, + pm2Name: activeLaneValidation.normalizedPm2, + notes: activeLaneValidation.normalizedNotes, + urlLines: proUrlDraft, + } + : row, + ); + applyChannelsLocal(next); + await syncChannelsRemote(next); + setLaneDrafts((prev) => ({ + ...prev, + [active.id]: { + name: activeLaneValidation.normalizedName, + streamKey: activeLaneValidation.normalizedStreamKey, + pm2Name: activeLaneValidation.normalizedPm2, + notes: activeLaneValidation.normalizedNotes, + }, + })); + return { + next, + savedLane: + next.find((row) => row.id === active.id) ?? + ({ + ...active, + name: activeLaneValidation.normalizedName, + streamKey: activeLaneValidation.normalizedStreamKey, + pm2Name: activeLaneValidation.normalizedPm2, + notes: activeLaneValidation.normalizedNotes, + urlLines: proUrlDraft, + } satisfies YoutubeProChannel), + pm2: activeLaneValidation.normalizedPm2, + }; + }, + [ + active, + activeLaneDraft, + activeLaneValidation, + applyChannelsLocal, + channels, + proUrlDraft, + syncChannelsRemote, + t, + ], + ); + + const saveProLaneSettings = useCallback(async () => { if (!active) return; - persistChannels(channels.map((c) => (c.id === active.id ? { ...c, ...patch } : c))); - }; + setLaneSaving(true); + setLaneStatus(null); + try { + const saved = await persistActiveLaneDraft(); + setCurrentProc(saved.pm2); + setLaneStatus(t("线路设置已保存", "Lane settings saved")); + } catch (e) { + const message = (e as Error).message; + setLaneStatus(message); + notify(message); + } finally { + setLaneSaving(false); + } + }, [active, notify, persistActiveLaneDraft, setCurrentProc, t]); const saveYoutubeIniToServer = async (content: string, processPm2 = currentProc) => { const targetPm2 = processPm2.trim(); @@ -682,18 +917,24 @@ export function LiveYoutubeUnmannedView({ const keySuffixUi = useMemo(() => { if (mode === "single") return parseYoutubeStreamKeySuffix(`key = ${ytFields.key}`); - const k = active?.streamKey ?? ""; + const k = activeLaneDraft?.streamKey ?? active?.streamKey ?? ""; return parseYoutubeStreamKeySuffix(`key = ${k}`); - }, [mode, ytFields.key, active?.streamKey]); + }, [mode, ytFields.key, active?.streamKey, activeLaneDraft?.streamKey]); const saveProUrlAndServer = async () => { if (!active) return; - updateActive({ urlLines: proUrlDraft }); try { - await Promise.resolve(onSaveUrlConfig(proUrlDraft, resolveChannelPm2(active))); + setLaneSaving(true); + setLaneStatus(null); + const saved = await persistActiveLaneDraft(); + setCurrentProc(saved.pm2); + await Promise.resolve(onSaveUrlConfig(proUrlDraft, saved.pm2)); urlListDirtyRef.current = false; + setLaneStatus(t("线路与地址已保存", "Lane settings and URLs saved")); } catch { /* parent 已 toast */ + } finally { + setLaneSaving(false); } }; @@ -725,7 +966,6 @@ export function LiveYoutubeUnmannedView({ if (mode === "single") setUrlConfig(next); else { setProUrlDraft(next); - if (active) updateActive({ urlLines: next }); } urlListDirtyRef.current = true; setUrlFetchNote(data.message || t("已去重当前 URL 列表", "Deduplicated current URL list")); @@ -737,76 +977,102 @@ export function LiveYoutubeUnmannedView({ }; const pushProStreamKeyToServer = async () => { - if (!active) return; + if (!active || !activeLaneDraft) return; const base = parseYoutubeIniFields(youtubeIniText || buildYoutubeIniFromFields(ytFields)); const f: YoutubeIniFields = { - key: (active.streamKey ?? "").trim(), + key: activeLaneDraft.streamKey.trim(), rtmps: base.rtmps, bitrate: base.bitrate, fastAudio: base.fastAudio, }; const body = buildYoutubeIniFromFields(f); - await saveYoutubeIniToServer(body, resolveChannelPm2(active)); - updateActive({ streamKey: f.key }); + setLaneSaving(true); + setLaneStatus(null); + try { + const saved = await persistActiveLaneDraft(); + setCurrentProc(saved.pm2); + await saveYoutubeIniToServer(body, saved.pm2); + setLaneStatus(t("线路设置与 youtube.ini 已保存", "Lane settings and youtube.ini saved")); + } catch (e) { + const message = (e as Error).message; + setLaneStatus(message); + notify(message); + } finally { + setLaneSaving(false); + } }; - const nonCommentUrlLines = useCallback( - (text: string) => - text - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line && !line.startsWith("#")), - [], + const currentUrlDraft = mode === "single" ? urlConfig : proUrlDraft; + const currentUrlCount = useMemo(() => nonCommentUrlLinesOf(currentUrlDraft).length, [currentUrlDraft]); + const currentDouyinCount = useMemo(() => extractDouyinLiveUrls(currentUrlDraft).length, [currentUrlDraft]); + const laneQuickLinks = useMemo( + () => + [ + quickLinks?.neko_browser, + quickLinks?.chromium_remote_debug, + quickLinks?.srs_api, + ].filter((value): value is string => !!value), + [quickLinks], ); const flushProChannelsBeforeRun = useCallback( async (targetPm2: string) => { - let next = channels; + const next = channels; const activePm2 = active ? resolveChannelPm2(active) : ""; if (active && targetPm2 === activePm2) { - next = channels.map((row) => (row.id === active.id ? { ...row, urlLines: proUrlDraft } : row)); + const saved = await persistActiveLaneDraft(); + return saved; } - setChannels(next); - saveYoutubeProChannels(next); - await pushYoutubeProChannelsRemote(fetchJson, next); - return next; + applyChannelsLocal(next); + await syncChannelsRemote(next); + return { + next, + savedLane: active ?? null, + pm2: targetPm2, + }; }, - [active, channels, fetchJson, proUrlDraft], + [active, applyChannelsLocal, channels, persistActiveLaneDraft, syncChannelsRemote], ); const runProProcess = useCallback( async (action: "start" | "stop" | "restart", targetPm2: string) => { + const targetMatchesActiveLane = !!active && resolveChannelPm2(active) === targetPm2; if (action !== "stop") { let next = channels; + let effectivePm2 = targetPm2; try { - next = await flushProChannelsBeforeRun(targetPm2); + const saved = await flushProChannelsBeforeRun(targetPm2); + next = saved.next; + effectivePm2 = saved.pm2; } catch (e) { notify( - `${t("多频道列表未同步到服务器", "Channel list not saved on server")}: ${(e as Error).message}`, + `${t("当前线路保存失败", "Failed to save the active lane")}: ${(e as Error).message}`, ); return; } const target = next.find( - (row) => resolveChannelPm2(row) === targetPm2, + (row) => resolveChannelPm2(row) === effectivePm2, ); - const activePm2 = active ? resolveChannelPm2(active) : ""; - const urlText = targetPm2 === activePm2 ? proUrlDraft : target?.urlLines ?? ""; + const urlText = target?.urlLines ?? ""; if (!((target?.streamKey ?? "").trim())) { notify(t("请先填写当前线路的推流密钥", "Fill the stream key before starting")); return; } - if (!nonCommentUrlLines(urlText).length) { + if (!nonCommentUrlLinesOf(urlText).length) { notify(t("请先填写当前线路的直播地址", "Fill the source URLs before starting")); return; } + targetPm2 = effectivePm2; + } + if (targetMatchesActiveLane) { + setCurrentProc(targetPm2); } - setCurrentProc(targetPm2); onRunProcess(action, targetPm2); }, - [active, channels, flushProChannelsBeforeRun, nonCommentUrlLines, notify, onRunProcess, proUrlDraft, setCurrentProc, t], + [active, channels, flushProChannelsBeforeRun, notify, onRunProcess, setCurrentProc, t], ); - const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing; + const lock = !!busy || ytIniLoading || urlReloading || urlOptimizing || laneSaving; const singleRow = rowForPm2(currentProc); const singleOnline = pm2StatusOnline(statusForPm2(currentProc)); @@ -869,7 +1135,7 @@ export function LiveYoutubeUnmannedView({ label: "Neko", sub: t("Chromium 工作室", "Chromium"), gradient: "from-emerald-400 to-teal-500", - openUrl: normalizeBrowserReachableHttpUrl("http://live.local:9200/"), + openUrl: normalizeBrowserReachableHttpUrl(quickLinks?.neko_browser || "http://live.local:9200/"), }, ]} /> @@ -920,6 +1186,41 @@ export function LiveYoutubeUnmannedView({ + {laneQuickLinks.length ? ( +
+ {quickLinks?.neko_browser ? ( + + {t("打开 Neko 工作室", "Open Neko studio")} + + ) : null} + {quickLinks?.chromium_remote_debug ? ( + + {t("打开 Chromium 调试", "Open Chromium debug")} + + ) : null} + {quickLinks?.srs_api ? ( + + {t("打开 SRS API", "Open SRS API")} + + ) : null} +
+ ) : null} +
@@ -1001,7 +1302,9 @@ export function LiveYoutubeUnmannedView({ const pm = resolveChannelPm2(c); const row = rowForPm2(pm); const st = statusForPm2(pm); - const dUrl = extractDouyinLiveUrls((c.urlLines ?? "").trim())[0]; + const laneUrls = nonCommentUrlLinesOf((c.urlLines ?? "").trim()); + const douyinUrls = extractDouyinLiveUrls((c.urlLines ?? "").trim()); + const dUrl = douyinUrls[0]; const suf = parseYoutubeStreamKeySuffix(`key = ${c.streamKey ?? ""}`); const sel = activeCh === c.id; const rowOnline = pm2StatusOnline(st); @@ -1035,6 +1338,14 @@ export function LiveYoutubeUnmannedView({ {pm} + · + + URL {laneUrls.length} + + · + + DY {douyinUrls.length} +
{dUrl ? ( · {active.name} ) : null} + {activeLaneDirty ? ( + + {t("有未保存修改", "Unsaved")} + + ) : null} {active ? (

- PM2: {(active.pm2Name ?? "").trim() || defaultPm2NameForChannel(active.id)} · key {keySuffixUi || "—"} + PM2: {activeSavedPm2} + {activePm2PreviewChanged ? ` → ${activeDraftPm2}` : ""} + {" · "} + key {keySuffixUi || "—"}

) : null}
@@ -1258,50 +1577,73 @@ export function LiveYoutubeUnmannedView({
updateActive({ name: e.target.value })} + value={activeLaneDraft?.name ?? ""} + onChange={(e) => setLaneDraftField("name", e.target.value)} placeholder={t("线路名称", "Lane name")} /> updateActive({ streamKey: e.target.value })} + value={activeLaneDraft?.streamKey ?? ""} + onChange={(e) => setLaneDraftField("streamKey", e.target.value)} placeholder="xxxx-xxxx… / rtmp(s)://…" />
+ + > + {t("写入 youtube.ini", "Write youtube.ini")} +
+ {!activeLaneValidation.ok ? ( +

{activeLaneValidation.message}

+ ) : null}
{t("高级:进程名", "Advanced: process name")} updateActive({ pm2Name: e.target.value })} + value={activeLaneDraft?.pm2Name ?? defaultPm2NameForChannel(active.id)} + onChange={(e) => setLaneDraftField("pm2Name", e.target.value)} spellCheck={false} placeholder="youtube2__…" /> +

+ {t("实际生效", "Effective")}: {activeDraftPm2 || "—"} +

+ {activeLaneValidation.message && !activeLaneValidation.ok ? ( +

{activeLaneValidation.message}

+ ) : null}