diff --git a/config/system-stack.env.example b/config/system-stack.env.example index 9fc6c60..0232679 100644 --- a/config/system-stack.env.example +++ b/config/system-stack.env.example @@ -57,10 +57,19 @@ DISABLE_IPV6=1 # 可选:Docker Neko 浏览器桌面(m1k1o/neko),与 SRS 8080 错开端口 # 策略/启动参数对齐 x86 参考机:油猴强制安装、扩展白名单、用户数据持久化目录见 NEKO_PROFILE_HOST_DIR # WebRTC 画面:NEKO_WEBRTC_NAT1TO1 必须为本机在局域网中的 IP(如 192.168.x.x),与参考机填网关 IP 同理 -ENABLE_NEKO=0 +# 设为 1 才会安装/启动 Neko 容器;不需要浏览器桌面可改回 0 +ENABLE_NEKO=1 NEKO_HTTP_PORT=9200 +# Neko 浏览器登录口令(用户 / 管理) NEKO_USER_PASS=live NEKO_ADMIN_PASS=admin +# 第二套 Neko 桌面:Firefox(Google 无官方 ARM Chrome;此为多架构 Firefox)。与 Chromium 共用上面口令。 +ENABLE_NEKO_FF=1 +NEKO_FF_HTTP_PORT=9201 +NEKO_FF_WEBRTC_EPR=52200-52300 +NEKO_FF_WEBRTC_UDP_RANGE=52200-52300 +NEKO_FF_DESKTOP_SCREEN=1920x1080@30 +NEKO_FF_PROFILE_HOST_DIR= # 持久化 Chromium 配置(油猴、YouTube 登录、脚本);可从参考机 rsync 整个目录覆盖以迁移 NEKO_PROFILE_HOST_DIR= NEKO_SHM_SIZE=2gb @@ -71,7 +80,7 @@ NEKO_WEBRTC_ICELITE=1 NEKO_WEBRTC_NAT1TO1= NEKO_PLUGINS_ENABLED=true -# Android gateway mode(第二网卡接安卓板时配置下行口,否则控制台显示「不可用」属正常) +# Android gateway mode(仅双网口直连安卓板时需要;单网口留空 ANDROID_DOWNLINK_IF 即可,控制台为「跳过」) ANDROID_GATEWAY_NAME=live-android-gateway ANDROID_UPLINK_IF=eth0 ANDROID_DOWNLINK_IF= diff --git a/live-platform/config/defaults/services/neko.json b/live-platform/config/defaults/services/neko.json index a7fd048..981df0a 100644 --- a/live-platform/config/defaults/services/neko.json +++ b/live-platform/config/defaults/services/neko.json @@ -1,14 +1,20 @@ { "id": "neko", "layer": "infra", - "enabled": false, + "enabled": true, "manager": "docker", "ports": { "http": 9200 }, "env": { - "ENABLE_NEKO": "0", + "ENABLE_NEKO": "1", "NEKO_HTTP_PORT": "9200", "NEKO_USER_PASS": "live", "NEKO_ADMIN_PASS": "admin", + "ENABLE_NEKO_FF": "1", + "NEKO_FF_HTTP_PORT": "9201", + "NEKO_FF_WEBRTC_EPR": "52200-52300", + "NEKO_FF_WEBRTC_UDP_RANGE": "52200-52300", + "NEKO_FF_DESKTOP_SCREEN": "1920x1080@30", + "NEKO_FF_PROFILE_HOST_DIR": "", "NEKO_PROFILE_HOST_DIR": "", "NEKO_SHM_SIZE": "2gb", "NEKO_DESKTOP_SCREEN": "1920x1080@30", diff --git a/scripts/linux/lib/common.sh b/scripts/linux/lib/common.sh index 4017d92..f20aa1e 100644 --- a/scripts/linux/lib/common.sh +++ b/scripts/linux/lib/common.sh @@ -452,6 +452,18 @@ install_neko_browser() { fi export NEKO_PLUGINS_ENABLED="${NEKO_PLUGINS_ENABLED:-true}" compose_in_service_dir "$neko_dir/docker-compose.yml" up -d + if [ "${ENABLE_NEKO_FF:-0}" = "1" ] && [ -f "$neko_dir/docker-compose.firefox.yml" ]; then + log "Installing Neko Firefox (second desktop, m1k1o/neko)" + local ff_prof="${NEKO_FF_PROFILE_HOST_DIR:-$neko_dir/data/firefox-profile}" + mkdir -p "$ff_prof" + chmod 777 "$ff_prof" 2>/dev/null || true + export NEKO_FF_HTTP_PORT="${NEKO_FF_HTTP_PORT:-9201}" + export NEKO_FF_DESKTOP_SCREEN="${NEKO_FF_DESKTOP_SCREEN:-1920x1080@30}" + export NEKO_FF_WEBRTC_EPR="${NEKO_FF_WEBRTC_EPR:-52200-52300}" + export NEKO_FF_WEBRTC_UDP_RANGE="${NEKO_FF_WEBRTC_UDP_RANGE:-52200-52300}" + export NEKO_FF_PROFILE_HOST_DIR="$ff_prof" + compose_in_service_dir "$neko_dir/docker-compose.firefox.yml" up -d + fi } configure_adb_udev() { diff --git a/scripts/linux/service_ctl.sh b/scripts/linux/service_ctl.sh index f1c503d..ef4d21a 100644 --- a/scripts/linux/service_ctl.sh +++ b/scripts/linux/service_ctl.sh @@ -12,6 +12,7 @@ SRS_COMPOSE="$ROOT_DIR/services/srs/docker-compose.yml" HOMEPAGE_COMPOSE="$ROOT_DIR/services/homepage/docker-compose.yml" FILEBROWSER_COMPOSE="$ROOT_DIR/services/filebrowser/docker-compose.yml" NEKO_COMPOSE="$ROOT_DIR/services/neko/docker-compose.yml" +NEKO_FF_COMPOSE="$ROOT_DIR/services/neko/docker-compose.firefox.yml" BROWSER_LAUNCHER="$ROOT_DIR/scripts/linux/launch_browser.sh" [ -f "$STACK_ENV" ] && set -a && . "$STACK_ENV" && set +a @@ -554,12 +555,89 @@ service_neko_stop() { kv message "Neko stack stopped" } -service_android_gateway_status() { - if ! have nmcli || [ -z "$ANDROID_DOWNLINK_IF" ]; then +service_neko_firefox_status() { + if [ "${ENABLE_NEKO_FF:-0}" != "1" ]; then kv available 0 kv running 0 kv status unavailable - kv detail "透明网关需第二块网卡:在 system-stack.env 设置 ANDROID_DOWNLINK_IF(如 eth1),并安装 NetworkManager(nmcli)" + kv detail "第二桌面未启用:在 config/system-stack.env 设置 ENABLE_NEKO_FF=1(Firefox 与 Chromium 错开端口,均为 arm64 多架构镜像)" + return 0 + fi + if [ ! -f "$NEKO_FF_COMPOSE" ]; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "docker-compose.firefox.yml missing" + return 0 + fi + if ! have docker; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "docker not installed" + return 0 + fi + local state + state="$(docker_container_status live-neko-firefox || true)" + kv available 1 + kv running "$([[ "$state" == running* ]] && echo 1 || echo 0)" + kv status "$([[ "$state" == running* ]] && echo online || echo stopped)" + kv host "${HOSTNAME_ALIAS}.local" + kv port "${NEKO_FF_HTTP_PORT:-9201}" + kv url "http://${HOSTNAME_ALIAS}.local:${NEKO_FF_HTTP_PORT:-9201}" + kv detail "Neko Firefox (第二浏览器); 口令与 Chromium 实例相同(NEKO_USER_PASS / NEKO_ADMIN_PASS)" +} + +service_neko_firefox_start() { + if [ "${ENABLE_NEKO_FF:-0}" != "1" ]; then + kv message "请先在 config/system-stack.env 设置 ENABLE_NEKO_FF=1" + exit 1 + fi + if [ ! -f "$NEKO_FF_COMPOSE" ]; then + kv message "Neko Firefox compose missing" + exit 1 + fi + local prof="${NEKO_FF_PROFILE_HOST_DIR:-$ROOT_DIR/services/neko/data/firefox-profile}" + mkdir -p "$prof" + chmod 777 "$prof" 2>/dev/null || true + export NEKO_FF_HTTP_PORT="${NEKO_FF_HTTP_PORT:-9201}" + export NEKO_FF_DESKTOP_SCREEN="${NEKO_FF_DESKTOP_SCREEN:-1920x1080@30}" + export NEKO_FF_WEBRTC_EPR="${NEKO_FF_WEBRTC_EPR:-52200-52300}" + export NEKO_FF_WEBRTC_UDP_RANGE="${NEKO_FF_WEBRTC_UDP_RANGE:-52200-52300}" + export NEKO_USER_PASS="${NEKO_USER_PASS:-live}" + export NEKO_ADMIN_PASS="${NEKO_ADMIN_PASS:-admin}" + export NEKO_FF_PROFILE_HOST_DIR="$prof" + export NEKO_WEBRTC_ICELITE="${NEKO_WEBRTC_ICELITE:-1}" + if [ -z "${NEKO_WEBRTC_NAT1TO1:-}" ]; then + export NEKO_WEBRTC_NAT1TO1="$(hostname -I 2>/dev/null | awk '{print $1}')" + fi + compose_in_service_dir "$NEKO_FF_COMPOSE" up -d + kv message "Neko Firefox started" +} + +service_neko_firefox_stop() { + if [ ! -f "$NEKO_FF_COMPOSE" ]; then + kv message "Neko Firefox compose missing" + return 0 + fi + compose_in_service_dir "$NEKO_FF_COMPOSE" down + kv message "Neko Firefox stopped" +} + +service_android_gateway_status() { + if ! have nmcli; then + kv available 0 + kv running 0 + kv status unavailable + kv detail "需要 NetworkManager(nmcli) 才能创建共享连接" + return 0 + fi + if [ -z "${ANDROID_DOWNLINK_IF:-}" ]; then + kv available 1 + kv running 0 + kv status skipped + kv detail "单网口或未配置下行口:无需透明网关。安卓可经 USB 网络共享 / Wi‑Fi / 同 LAN 访问板子。仅当板载第二以太网直连安卓板时,在 system-stack.env 设置 ANDROID_DOWNLINK_IF=eth1(或实际接口名)" + kv url "" return 0 fi if [ ! -d "/sys/class/net/$ANDROID_DOWNLINK_IF" ]; then @@ -581,9 +659,9 @@ service_android_gateway_status() { } service_android_gateway_start() { - if [ -z "$ANDROID_DOWNLINK_IF" ]; then - kv message "ANDROID_DOWNLINK_IF is not configured" - exit 1 + if [ -z "${ANDROID_DOWNLINK_IF:-}" ]; then + kv message "未配置 ANDROID_DOWNLINK_IF:单网口场景无需启动安卓网关" + exit 0 fi if ! nmcli connection show "$ANDROID_GATEWAY_NAME" >/dev/null 2>&1; then nmcli connection add type ethernet ifname "$ANDROID_DOWNLINK_IF" con-name "$ANDROID_GATEWAY_NAME" \ diff --git a/services/neko/docker-compose.firefox.yml b/services/neko/docker-compose.firefox.yml new file mode 100644 index 0000000..f417f9e --- /dev/null +++ b/services/neko/docker-compose.firefox.yml @@ -0,0 +1,29 @@ +# 第二套 Neko 桌面(Firefox,多架构含 arm64)。与 Chromium 实例错开端口与 UDP 段。 +# 启用:ENABLE_NEKO_FF=1 后 service_ctl neko_firefox start +version: "3.3" +services: + neko-firefox: + image: ghcr.io/m1k1o/neko/firefox:latest + container_name: live-neko-firefox + restart: unless-stopped + shm_size: "1gb" + cap_add: + - SYS_ADMIN + environment: + DISPLAY: ":99.0" + NEKO_DESKTOP_SCREEN: "${NEKO_FF_DESKTOP_SCREEN}" + NEKO_SERVER_BIND: ":8080" + NEKO_PASSWORD: "${NEKO_USER_PASS}" + NEKO_PASSWORD_ADMIN: "${NEKO_ADMIN_PASS}" + NEKO_MEMBER_PROVIDER: "multiuser" + NEKO_MEMBER_MULTIUSER_USER_PASSWORD: "${NEKO_USER_PASS}" + NEKO_MEMBER_MULTIUSER_ADMIN_PASSWORD: "${NEKO_ADMIN_PASS}" + NEKO_WEBRTC_EPR: "${NEKO_FF_WEBRTC_EPR}" + NEKO_WEBRTC_ICELITE: "${NEKO_WEBRTC_ICELITE}" + NEKO_WEBRTC_NAT1TO1: "${NEKO_WEBRTC_NAT1TO1}" + NEKO_PLUGINS_ENABLED: "false" + ports: + - "${NEKO_FF_HTTP_PORT}:8080" + - "${NEKO_FF_WEBRTC_UDP_RANGE}:52000-52100/udp" + volumes: + - "${NEKO_FF_PROFILE_HOST_DIR}:/home/neko/.mozilla" diff --git a/services/srs/data/index.html b/services/srs/data/index.html new file mode 100644 index 0000000..577ffb1 --- /dev/null +++ b/services/srs/data/index.html @@ -0,0 +1,51 @@ + + + + + + SRS · Live Hub + + + +

SRS 流媒体(本机)

+

此页由仓库内 services/srs/data/index.html 提供;静态目录挂载后根路径不再 404。

+
+

常用入口

+ +
+
+

端口说明(默认)

+ +
+ + + diff --git a/src/control_plane.py b/src/control_plane.py index a3f65b0..88647d0 100644 --- a/src/control_plane.py +++ b/src/control_plane.py @@ -11,8 +11,8 @@ import sys import time from dataclasses import dataclass from pathlib import Path -from typing import Any, Iterable -from urllib.error import URLError +from typing import Any, Iterable, Optional +from urllib.error import HTTPError, URLError from urllib.request import urlopen BASE_DIR = Path(__file__).resolve().parent.parent @@ -59,7 +59,12 @@ class ServiceSpec: SERVICE_SPECS: tuple[ServiceSpec, ...] = ( ServiceSpec("mdns", "mDNS / live.local", "system", "Hostname and mDNS discovery"), ServiceSpec("wifi", "Wi-Fi Auto Connect", "system", "Default Wi-Fi onboarding profile"), - ServiceSpec("shellcrash", "ShellCrash", "network", "Transparent proxy and rule engine"), + ServiceSpec( + "shellcrash", + "ShellCrash", + "network", + "透明代理/规则引擎;stopped 时一般不影响本机服务,需要翻墙/分流时点 Start", + ), ServiceSpec("srs", "SRS", "stream", "RTMP/SRT relay for HDMI and live workflows"), ServiceSpec("webtty", "WebTTY", "access", "Browser terminal for live user"), ServiceSpec("cockpit", "Cockpit", "admin", "System administration dashboard"), @@ -80,11 +85,17 @@ SERVICE_SPECS: tuple[ServiceSpec, ...] = ( "browser", "可选:Docker 内嵌 Chromium 桌面,适合远程开 YouTube Studio;ENABLE_NEKO=1 时安装", ), + ServiceSpec( + "neko_firefox", + "Neko Firefox (第二桌面)", + "browser", + "可选:第二套 Neko 桌面(Firefox,arm64/x86 多架构);ENABLE_NEKO_FF=1;口令与 Chromium 相同", + ), ServiceSpec( "android_gateway", "Android Gateway", "network", - "Transparent gateway mode for Android boards", + "双网口时下行共享给安卓板;单网口无需启用(状态为 skipped)", ), ) @@ -480,6 +491,16 @@ def _probe_http_get(url: str, timeout: float = 2.5) -> dict[str, Any]: "reachable": True, "error": "", } + except HTTPError as exc: + # 404/502 等仍表示 TCP/HTTP 栈可达,需保留真实状态码(避免误报 HTTP 0) + ms = (time.monotonic() - t0) * 1000 + return { + "ok": exc.code < 500, + "http_code": int(exc.code), + "latency_ms": round(ms, 2), + "reachable": True, + "error": "", + } except (URLError, OSError, ValueError) as exc: ms = (time.monotonic() - t0) * 1000 return { @@ -491,23 +512,105 @@ def _probe_http_get(url: str, timeout: float = 2.5) -> dict[str, Any]: } +def network_iface_stats() -> dict[str, Any]: + """Linux /proc/net/dev 累计流量(自开机或计数器复位以来)。""" + dev = Path("/proc/net/dev") + interfaces: list[dict[str, int | str]] = [] + if sys.platform == "win32" or not dev.is_file(): + return {"interfaces": interfaces} + skip_prefixes = ("lo", "docker", "veth", "br-", "virbr", "tun", "tap") + try: + lines = dev.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError: + return {"interfaces": interfaces} + for line in lines[2:]: + if ":" not in line: + continue + name, rest = line.split(":", 1) + name = name.strip() + if not name or name.startswith(skip_prefixes): + continue + parts = rest.split() + if len(parts) < 16: + continue + try: + interfaces.append( + { + "name": name, + "rx_bytes": int(parts[0]), + "rx_packets": int(parts[1]), + "tx_bytes": int(parts[8]), + "tx_packets": int(parts[9]), + } + ) + except (ValueError, IndexError): + continue + return {"interfaces": interfaces} + + +def recent_pm2_log_lines(max_lines: int = 14) -> list[str]: + """PM2 合并日志尾部(无 pm2 或超时时返回空)。""" + if not shutil.which("pm2"): + return [] + try: + proc = subprocess.run( + ["pm2", "logs", "--nostream", "--lines", str(max_lines)], + capture_output=True, + text=True, + timeout=12, + errors="replace", + ) + except (OSError, subprocess.TimeoutExpired): + return [] + raw = (proc.stdout or "") + ("\n" + proc.stderr if proc.stderr else "") + lines = [ln.rstrip() for ln in raw.splitlines() if ln.strip()] + return lines[-max_lines:] if len(lines) > max_lines else lines + + 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_p = int(env.get("NEKO_HTTP_PORT", "9200") or 9200) + neko_ff_on = env.get("ENABLE_NEKO_FF", "0") == "1" + neko_ff_p = int(env.get("NEKO_FF_HTTP_PORT", "9201") or 9201) loop = asyncio.get_event_loop() def run_srs() -> dict[str, Any]: tcp = _probe_tcp_port("127.0.0.1", srs_p) - http = _probe_http_get(f"http://127.0.0.1:{srs_p}/", timeout=2.5) - return {"port": srs_p, "tcp": tcp, "http": http} + tcp_api = _probe_tcp_port("127.0.0.1", srs_api_p) + # 8080 根路径常因空 data 挂载返回 404;以 1985 /api/v1/versions 作为「服务正常」判定 + http = _probe_http_get(f"http://127.0.0.1:{srs_api_p}/api/v1/versions", timeout=2.5) + http_ui = _probe_http_get(f"http://127.0.0.1:{srs_p}/", timeout=2.5) + api_ok = int(http.get("http_code") or 0) == 200 + return { + "port": srs_p, + "api_port": srs_api_p, + "tcp": tcp, + "tcp_api": tcp_api, + "http": http, + "http_ui": http_ui, + "api_ok": api_ok, + } def run_neko() -> dict[str, Any]: tcp = _probe_tcp_port("127.0.0.1", neko_p) http = _probe_http_get(f"http://127.0.0.1:{neko_p}/", timeout=2.5) return {"port": neko_p, "tcp": tcp, "http": http} + def run_neko_ff() -> dict[str, Any]: + tcp = _probe_tcp_port("127.0.0.1", neko_ff_p) + http = _probe_http_get(f"http://127.0.0.1:{neko_ff_p}/", timeout=2.5) + return {"port": neko_ff_p, "tcp": tcp, "http": http} + + if neko_ff_on: + srs_d, neko_d, neko_ff_d = await asyncio.gather( + loop.run_in_executor(None, run_srs), + loop.run_in_executor(None, run_neko), + loop.run_in_executor(None, run_neko_ff), + ) + return {"srs": srs_d, "neko": neko_d, "neko_firefox": neko_ff_d} srs_d, neko_d = await asyncio.gather( loop.run_in_executor(None, run_srs), loop.run_in_executor(None, run_neko), @@ -525,9 +628,25 @@ def stack_summary() -> dict: fb = env.get("FILEBROWSER_PORT", "8082") netd = env.get("NETDATA_PORT", "19999") srs_http = env.get("SRS_HTTP_PORT", "8080") + srs_api = env.get("SRS_API_PORT", "1985") android_panel = env.get("ANDROID_PANEL_PORT", "5000") chrome_dbg = env.get("BROWSER_REMOTE_DEBUG_PORT", "9222") neko_port = env.get("NEKO_HTTP_PORT", "9200") + neko_ff_port = env.get("NEKO_FF_HTTP_PORT", "9201") + neko_ff_on = env.get("ENABLE_NEKO_FF", "0") == "1" + ql: dict[str, str] = { + "control_plane": f"http://{mdns_host}:{web_port}", + "webtty": f"http://{mdns_host}:{webtty}", + "filebrowser": f"http://{mdns_host}:{fb}", + "netdata": f"http://{mdns_host}:{netd}", + "srs_http": f"http://{mdns_host}:{srs_http}/", + "srs_api": f"http://{mdns_host}:{srs_api}/api/v1/versions", + "ws_scrcpy": f"http://{mdns_host}:{android_panel}", + "chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}", + "neko_browser": f"http://{mdns_host}:{neko_port}", + } + if neko_ff_on: + ql["neko_firefox_browser"] = f"http://{mdns_host}:{neko_ff_port}" return { "hostname": hostname, "mdns_host": mdns_host, @@ -539,16 +658,13 @@ 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(), - "quick_links": { - "control_plane": f"http://{mdns_host}:{web_port}", - "webtty": f"http://{mdns_host}:{webtty}", - "filebrowser": f"http://{mdns_host}:{fb}", - "netdata": f"http://{mdns_host}:{netd}", - "srs_http": f"http://{mdns_host}:{srs_http}", - "ws_scrcpy": f"http://{mdns_host}:{android_panel}", - "chromium_remote_debug": f"http://{mdns_host}:{chrome_dbg}", - "neko_browser": f"http://{mdns_host}:{neko_port}", + "neko_login": { + "user_password": env.get("NEKO_USER_PASS", "live"), + "admin_password": env.get("NEKO_ADMIN_PASS", "admin"), + "note_zh": "浏览器进入 Neko 时的登录口令(可在 system-stack.env 修改 NEKO_USER_PASS / NEKO_ADMIN_PASS)", }, + "neko_firefox_enabled": neko_ff_on, + "quick_links": ql, } @@ -571,6 +687,17 @@ def _read_meminfo() -> dict[str, int]: return info +def _read_uptime_seconds() -> Optional[float]: + up = Path("/proc/uptime") + if not up.is_file(): + return None + try: + first = up.read_text(encoding="utf-8", errors="replace").split() + return float(first[0]) if first else None + except (OSError, ValueError, IndexError): + return None + + def system_snapshot() -> dict: disk = shutil.disk_usage(BASE_DIR.anchor if sys.platform == "win32" else "/") meminfo = _read_meminfo() @@ -583,6 +710,7 @@ def system_snapshot() -> dict: "available": meminfo.get("MemAvailable", 0), "free": meminfo.get("MemFree", 0), }, + "uptime_seconds": _read_uptime_seconds(), "netdata": {"available": False}, } if sys.platform != "win32": diff --git a/src/hub_routes.py b/src/hub_routes.py index ff7b303..7bc8d50 100644 --- a/src/hub_routes.py +++ b/src/hub_routes.py @@ -8,7 +8,15 @@ from fastapi import APIRouter from pydantic import BaseModel from src.android_control import adb_available, list_android_devices -from src.control_plane import query_services, stack_stream_probes, stack_summary, system_snapshot +from src.control_plane import ( + load_hardware_profile, + network_iface_stats, + query_services, + recent_pm2_log_lines, + stack_stream_probes, + stack_summary, + system_snapshot, +) from src.live_config import business_config, load_hub_config_snapshot, services_config_list from src.web_process_backend import WebProcessBackend @@ -66,8 +74,13 @@ async def hub_dashboard() -> dict[str, Any]: } ) + pm2_tail = recent_pm2_log_lines(16) + net_stats = network_iface_stats() + hardware = await load_hardware_profile(False) + return { "snapshot": snap, + "hardware": hardware, "stack": stack, "probes": probes, "services": services, @@ -77,6 +90,18 @@ async def hub_dashboard() -> dict[str, Any]: }, "live": live_status, "netdata": snap.get("netdata"), + "network": net_stats, + "live_events": { + "pm2_tail": pm2_tail, + "process_digest": [ + { + "pm2": str(p.get("pm2", "")), + "label": str(p.get("label", "") or p.get("pm2", "")), + "status": str(p.get("process_status", "") or ""), + } + for p in live_status.get("processes") or [] + ], + }, } diff --git a/tools/deploy_live_paramiko.py b/tools/deploy_live_paramiko.py index 00800f9..58fe635 100644 --- a/tools/deploy_live_paramiko.py +++ b/tools/deploy_live_paramiko.py @@ -25,6 +25,7 @@ REMOTE_BASE = os.environ.get("LIVE_DEPLOY_PATH", "/home/live/douyinyoutube").rst REPO_ROOT = Path(__file__).resolve().parent.parent FILES = [ + "src/android_control.py", "src/control_plane.py", "src/web_process_backend.py", "src/hub_routes.py", @@ -39,7 +40,9 @@ FILES = [ "scripts/linux/service_ctl.sh", "scripts/linux/lib/common.sh", "services/srs/docker-compose.yml", + "services/srs/data/index.html", "services/neko/docker-compose.yml", + "services/neko/docker-compose.firefox.yml", "services/neko/chromium.conf", "services/neko/policies/policies.json", "services/neko/.gitignore", @@ -280,6 +283,25 @@ def main() -> int: "done; echo curl_health_failed; exit 0", timeout=120, ) + # 若健康检查失败,再尝试 reset-failed + start(部分板子 restart 后 unit 未 active) + recover_console = ( + f"echo '{PASS}' | sudo -S bash -c '" + "systemctl reset-failed live-console.service 2>/dev/null; " + "systemctl start live-console.service 2>/dev/null; " + "sleep 5; " + "systemctl is-active live-console.service || true; " + "journalctl -u live-console.service -n 25 --no-pager 2>/dev/null || true'" + ) + run_logged(client, log_path, "recover_live_console_if_needed", recover_console, timeout=90) + run_logged( + client, + log_path, + "curl_health_after_recover", + "for i in $(seq 1 12); do " + "if curl -sf -m 4 http://127.0.0.1:8001/health >/dev/null 2>&1; then echo health_ok_recover; exit 0; fi; " + "sleep 2; done; echo still_failed; exit 0", + timeout=60, + ) run_logged( client, log_path, diff --git a/tools/probe_board_stack.py b/tools/probe_board_stack.py new file mode 100644 index 0000000..6c96174 --- /dev/null +++ b/tools/probe_board_stack.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""SSH 到板子:打印 ENABLE_NEKO 与 Docker 中与 srs/neko 相关的容器。 + +环境变量与 deploy_live_paramiko.py 一致: + LIVE_DEPLOY_HOST(默认 192.168.21.105) + LIVE_DEPLOY_USER(默认 live) + LIVE_DEPLOY_PASS(默认 12345678) +""" +from __future__ import annotations + +import os +import sys + +try: + import paramiko +except ImportError: + print("请先执行: pip install paramiko", file=sys.stderr) + sys.exit(1) + +HOST = os.environ.get("LIVE_DEPLOY_HOST", "192.168.21.105") +USER = os.environ.get("LIVE_DEPLOY_USER", "live") +PASS = os.environ.get("LIVE_DEPLOY_PASS", "12345678") + +REMOTE_SH = r""" +echo "--- ENABLE_NEKO in known env files ---" +for f in /opt/live/config/system-stack.env /opt/live/generated/system-stack.env /home/live/douyinyoutube/config/system-stack.env; do + if [ -f "$f" ]; then + echo "=== $f ===" + grep -E '^ENABLE_NEKO=' "$f" 2>/dev/null || echo '(no ENABLE_NEKO line)' + fi +done +echo "--- docker: srs / neko (running or all) ---" +docker ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | grep -iE 'neko|srs' || echo '(no container name matching neko|srs)' +echo "--- docker ps -a (first 25) ---" +docker ps -a --format '{{.Names}} {{.Status}}' 2>/dev/null | head -25 +""" + + +def main() -> int: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + c.connect(HOST, username=USER, password=PASS, timeout=20) + except Exception as e: + print(f"SSH_CONNECT_FAILED {HOST} {type(e).__name__}: {e}", file=sys.stderr) + return 2 + _, stdout, stderr = c.exec_command( + "export LC_ALL=C.UTF-8 LANG=C.UTF-8; " + REMOTE_SH, + timeout=90, + ) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + c.close() + print(out, end="") + if err.strip(): + print(err, file=sys.stderr, end="") + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/ssh_neko_start.py b/tools/ssh_neko_start.py new file mode 100644 index 0000000..230276e --- /dev/null +++ b/tools/ssh_neko_start.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""SSH:在板子上执行 service_ctl.sh neko start(拉镜像可能较久)。""" +from __future__ import annotations + +import os +import sys + +import paramiko + +HOST = os.environ.get("LIVE_DEPLOY_HOST", "192.168.21.105") +USER = os.environ.get("LIVE_DEPLOY_USER", "live") +PASS = os.environ.get("LIVE_DEPLOY_PASS", "12345678") +REMOTE_BASE = os.environ.get("LIVE_DEPLOY_PATH", "/home/live/douyinyoutube").rstrip("/") + +CMD = ( + f"export LC_ALL=C.UTF-8 LANG=C.UTF-8; " + f"cd {REMOTE_BASE} && bash scripts/linux/service_ctl.sh neko start" +) + + +def main() -> int: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + c.connect(HOST, username=USER, password=PASS, timeout=25) + except Exception as e: + print(f"SSH failed: {e}", file=sys.stderr) + return 2 + _, stdout, stderr = c.exec_command(CMD, timeout=600) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + c.close() + print(out, end="") + if err.strip(): + print("--- stderr ---", file=sys.stderr) + print(err, file=sys.stderr, end="") + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web-console/components/console/LiveConsoleWorkspace.tsx b/web-console/components/console/LiveConsoleWorkspace.tsx index ee275d0..24180af 100644 --- a/web-console/components/console/LiveConsoleWorkspace.tsx +++ b/web-console/components/console/LiveConsoleWorkspace.tsx @@ -1006,7 +1006,12 @@ export function LiveConsoleWorkspace() { ) : null} {qlNorm.srs_http ? ( - 🎞️ SRS HTTP + 🎞️ SRS :8080 + + ) : null} + {qlNorm.srs_api ? ( + + 🎞️ SRS API :1985 ) : null} diff --git a/web-console/components/live-product/LiveAndroidWorkstation.tsx b/web-console/components/live-product/LiveAndroidWorkstation.tsx index 83d0039..e663b9d 100644 --- a/web-console/components/live-product/LiveAndroidWorkstation.tsx +++ b/web-console/components/live-product/LiveAndroidWorkstation.tsx @@ -1,15 +1,6 @@ "use client"; -import { - ArrowUpRight, - ChevronDown, - ChevronUp, - Copy, - Layers, - Loader2, - Package, - Terminal, -} from "lucide-react"; +import { Copy, Layers, Loader2, Package, Terminal } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; type DeviceRow = { serial: string; model?: string; state: string }; @@ -58,8 +49,6 @@ type Props = { devices: DeviceRow[]; adbAvailable: boolean; scrcpyUrl: string; - androidShotUrl: string | null; - androidShotErr: string | null; apkPath: string; setApkPath: (v: string) => void; onAndroidAction: (action: string, payload?: Record) => Promise; @@ -76,8 +65,6 @@ export function LiveAndroidWorkstation({ devices, adbAvailable, scrcpyUrl, - androidShotUrl, - androidShotErr, apkPath, setApkPath, onAndroidAction, @@ -96,9 +83,11 @@ export function LiveAndroidWorkstation({ const [uiErr, setUiErr] = useState(null); const [openUrl, setOpenUrl] = useState("https://"); const [adbInputText, setAdbInputText] = useState(""); - const [embedScrcpy, setEmbedScrcpy] = useState(false); + const [mirrorMode, setMirrorMode] = useState<"host" | "webusb">("host"); const [pending, setPending] = useState(null); + const PANDA_WEB_SCRCPY = "https://pandatestgrid.github.io/panda-web-scrcpy/"; + const lock = !!(busy || pending); const loadOverview = useCallback(async () => { @@ -280,6 +269,104 @@ export function LiveAndroidWorkstation({ ) : null} +
+
+ + + + GitHub + +
+ {mirrorMode === "host" ? ( +
+
+

+ {t( + "手机接在 live 板子 USB 时使用本页:先在 ws-scrcpy 里选设备并连接;黑屏多为未连 WebSocket / 屏幕休眠——可先 Wake 再刷新或新窗口打开面板。", + "USB to the board: pick device in ws-scrcpy; black screen often means WS down or screen off—Wake, refresh, or open panel in a new tab.", + )} +

+
+ {adbAvailable && androidSerial ? ( + + ) : null} + {scrcpyUrl ? ( + + {t("新窗口打开 :5000", "Open :5000 tab")} + + ) : null} +
+
+ {scrcpyUrl ? ( +