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。
+
+
+
端口说明(默认)
+
+ 1935 RTMP 推/拉流
+ 1985 HTTP API
+ 8080 本页与 HLS/HTTP 静态
+ 10080/udp SRT
+
+
+
+
+
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}
+
+
+
setMirrorMode("host")}
+ className={`rounded-lg px-3 py-2 text-[11px] font-semibold transition ${
+ mirrorMode === "host"
+ ? "bg-violet-500/25 text-violet-100 ring-1 ring-violet-400/40"
+ : "text-zinc-500 hover:bg-white/5 hover:text-zinc-300"
+ }`}
+ >
+ {t("宿主投屏 (板子 ws-scrcpy)", "Host ws-scrcpy")}
+
+
setMirrorMode("webusb")}
+ className={`rounded-lg px-3 py-2 text-[11px] font-semibold transition ${
+ mirrorMode === "webusb"
+ ? "bg-cyan-500/20 text-cyan-100 ring-1 ring-cyan-400/35"
+ : "text-zinc-500 hover:bg-white/5 hover:text-zinc-300"
+ }`}
+ >
+ WebUSB · Panda
+
+
+ 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 ? (
+
void onAndroidAction("wake")}
+ className="rounded-lg bg-amber-500/20 px-3 py-1.5 text-[11px] text-amber-100 ring-1 ring-amber-500/30"
+ >
+ Wake
+
+ ) : null}
+ {scrcpyUrl ? (
+
+ {t("新窗口打开 :5000", "Open :5000 tab")}
+
+ ) : null}
+
+
+ {scrcpyUrl ? (
+
+ ) : (
+
{t("未配置 ws-scrcpy 地址", "ws-scrcpy URL missing")}
+ )}
+
+ ) : (
+
+
+ {t(
+ "「Panda / WebUSB」在浏览器里直连手机:手机必须插在您正在用浏览器看页面的这台电脑的 USB 上(走 WebUSB,不是板子上的 ADB)。官方演示见 ",
+ "Panda uses WebUSB: phone must plug into the PC running this browser, not the board. Demo: ",
+ )}
+
+ panda-web-scrcpy
+
+ 。
+
+
+
+ )}
+
+
{adbAvailable && androidSerial ? (
@@ -672,87 +759,25 @@ export function LiveAndroidWorkstation({
) : null}
-
-
{t("安装 APK(设备上的路径)", "Install APK (path on device)")}
-
- {t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
-
-
setApkPath(e.target.value)}
- />
-
void onAndroidAction("install_apk", { path: apkPath })}
- className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
- >
- pm install
-
-
-
-
-
-
{t("实时画面(ADB 截图)", "Live screen (ADB)")}
- {scrcpyUrl ? (
-
- ws-scrcpy
-
- ) : null}
-
-
- {t(
- "约每 2.5 秒刷新;全帧低延迟请用 ws-scrcpy 新窗口或本页内嵌。",
- "~2.5s ADB refresh; use ws-scrcpy for low-latency stream.",
- )}
-
- {!adbAvailable ? (
-
{t("ADB 未就绪,无法截图。", "ADB unavailable.")}
- ) : null}
- {androidShotErr && adbAvailable ?
{androidShotErr}
: null}
- {androidShotUrl ? (
-
+
{t("安装 APK(设备上的路径)", "Install APK (path on device)")}
+
+ {t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
+
+
setApkPath(e.target.value)}
/>
- ) : null}
- {adbAvailable && !androidShotUrl && !androidShotErr ? (
-
{t("加载截图…", "Loading screenshot…")}
- ) : null}
-
-
- {scrcpyUrl ? (
-
-
-
setEmbedScrcpy((v) => !v)}
- className="flex items-center gap-2 text-xs font-medium text-zinc-300 hover:text-white"
- >
- ws-scrcpy
- {embedScrcpy ? : }
-
- {embedScrcpy ? t("收起内嵌", "Collapse") : t("展开内嵌(省资源)", "Expand embed")}
-
-
-
- {t("新窗口打开", "Open tab")}
-
-
- {embedScrcpy ? (
-
- ) : (
-
- {t("默认不加载 iframe,避免占用带宽与解码;需要时点「展开内嵌」。", "Iframe off by default to save resources.")}
-
- )}
+
void onAndroidAction("install_apk", { path: apkPath })}
+ className="mt-3 rounded-xl bg-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-100 ring-1 ring-emerald-500/30"
+ >
+ pm install
+
) : null}
diff --git a/web-console/components/live-product/LiveControlApp.tsx b/web-console/components/live-product/LiveControlApp.tsx
index b952b54..8fa0085 100644
--- a/web-console/components/live-product/LiveControlApp.tsx
+++ b/web-console/components/live-product/LiveControlApp.tsx
@@ -6,6 +6,7 @@ import {
ArrowUpRight,
ChevronRight,
CirclePlay,
+ Clock,
Cpu,
HardDrive,
Loader2,
@@ -51,11 +52,20 @@ type HubDashboard = {
memory?: { total?: number; available?: number; free?: number };
disk?: { total?: number; free?: number; used?: number };
netdata?: { available?: boolean; version?: string };
+ uptime_seconds?: number;
};
+ hardware?: Record;
stack?: {
+ hostname?: string;
mdns_host?: string;
- quick_links?: Record;
+ machine?: string;
+ kernel?: string;
+ python?: string;
+ platform?: string;
ips?: string[];
+ quick_links?: Record;
+ neko_login?: { user_password?: string; admin_password?: string; note_zh?: string };
+ neko_firefox_enabled?: boolean;
};
services?: Array<{
service_id: string;
@@ -77,13 +87,35 @@ type HubDashboard = {
script?: string;
}>;
};
+ network?: {
+ interfaces?: Array<{
+ name?: string;
+ rx_bytes?: number;
+ tx_bytes?: number;
+ rx_packets?: number;
+ tx_packets?: number;
+ }>;
+ };
+ live_events?: {
+ pm2_tail?: string[];
+ process_digest?: Array<{ pm2?: string; label?: string; status?: string }>;
+ };
probes?: {
srs?: {
+ port?: number;
+ api_port?: number;
+ api_ok?: boolean;
+ tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
+ tcp_api?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
+ http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
+ http_ui?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
+ };
+ neko?: {
port?: number;
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
};
- neko?: {
+ neko_firefox?: {
port?: number;
tcp?: { ok?: boolean; reachable?: boolean; latency_ms?: number; error?: string };
http?: { ok?: boolean; http_code?: number; reachable?: boolean; latency_ms?: number; error?: string };
@@ -91,8 +123,21 @@ type HubDashboard = {
};
};
+type SrsProbe = NonNullable["srs"];
+
type ProcessEntry = { pm2: string; script: string; label: string };
+function formatUptime(sec?: number) {
+ if (sec == null || !Number.isFinite(sec)) return "—";
+ const s = Math.max(0, Math.floor(sec));
+ const d = Math.floor(s / 86400);
+ const h = Math.floor((s % 86400) / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ if (d > 0) return `${d}d ${h}h`;
+ if (h > 0) return `${h}h ${m}m`;
+ return `${m}m`;
+}
+
function formatBytes(value?: number) {
if (!value) return "—";
const u = ["B", "KB", "MB", "GB", "TB"];
@@ -108,6 +153,8 @@ function formatBytes(value?: number) {
function statusPill(status: string) {
if (status === "online" || status === "running")
return "bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/30";
+ if (status === "skipped")
+ return "bg-slate-500/15 text-slate-300 ring-1 ring-slate-500/30";
if (status === "stopped" || status === "not_found")
return "bg-zinc-500/15 text-zinc-400 ring-1 ring-zinc-500/25";
if (status === "error") return "bg-rose-500/15 text-rose-300 ring-1 ring-rose-500/35";
@@ -131,9 +178,9 @@ export default function LiveControlApp() {
const [douyinUrl, setDouyinUrl] = useState("");
const [apkPath, setApkPath] = useState("/sdcard/app.apk");
const [androidSerial, setAndroidSerial] = useState("");
- const [hubCfgText, setHubCfgText] = useState(null);
- const [androidShotUrl, setAndroidShotUrl] = useState(null);
- const [androidShotErr, setAndroidShotErr] = useState(null);
+ const [hubCfgSnapshot, setHubCfgSnapshot] = useState | null>(null);
+ const [autoRefreshEnabled, setAutoRefreshEnabled] = useState(true);
+ const [showStreamProbes, setShowStreamProbes] = useState(true);
const t = useCallback((zh: string, en: string) => tr(lang, zh, en), [lang]);
@@ -176,63 +223,20 @@ export default function LiveControlApp() {
}, [lang]);
useEffect(() => {
- void refreshDashboard();
- const id = window.setInterval(() => void refreshDashboard(), 10000);
- return () => clearInterval(id);
- }, [refreshDashboard]);
+ try {
+ if (localStorage.getItem("livehub.autorefresh") === "0") setAutoRefreshEnabled(false);
+ if (localStorage.getItem("livehub.showStreamProbes") === "0") setShowStreamProbes(false);
+ } catch {
+ /* ignore */
+ }
+ }, []);
useEffect(() => {
- if (view !== "android" || !androidSerial || !dash?.android?.adb_available) {
- setAndroidShotErr(null);
- setAndroidShotUrl((prev) => {
- if (prev) URL.revokeObjectURL(prev);
- return null;
- });
- return;
- }
- let cancelled = false;
- const tick = async () => {
- try {
- const res = await fetch(
- `${base}/android/screenshot?serial=${encodeURIComponent(androidSerial)}`,
- { cache: "no-store" },
- );
- const ct = res.headers.get("content-type") || "";
- if (!res.ok) {
- let msg = `HTTP ${res.status}`;
- if (ct.includes("json")) {
- try {
- const j = (await res.json()) as { error?: string };
- if (j.error) msg = j.error;
- } catch {
- /* ignore */
- }
- }
- if (!cancelled) setAndroidShotErr(msg);
- return;
- }
- const blob = await res.blob();
- if (cancelled) return;
- setAndroidShotErr(null);
- setAndroidShotUrl((prev) => {
- if (prev) URL.revokeObjectURL(prev);
- return URL.createObjectURL(blob);
- });
- } catch (e) {
- if (!cancelled) setAndroidShotErr((e as Error).message);
- }
- };
- void tick();
- const tid = window.setInterval(() => void tick(), 2500);
- return () => {
- cancelled = true;
- window.clearInterval(tid);
- setAndroidShotUrl((prev) => {
- if (prev) URL.revokeObjectURL(prev);
- return null;
- });
- };
- }, [view, androidSerial, base, dash?.android?.adb_available]);
+ void refreshDashboard();
+ if (!autoRefreshEnabled) return;
+ const id = window.setInterval(() => void refreshDashboard(), 10000);
+ return () => clearInterval(id);
+ }, [refreshDashboard, autoRefreshEnabled]);
useEffect(() => {
void (async () => {
@@ -278,9 +282,9 @@ export default function LiveControlApp() {
void (async () => {
try {
const cfg = await fetchJson>("/hub/config");
- setHubCfgText(JSON.stringify(cfg, null, 2));
+ setHubCfgSnapshot(cfg);
} catch {
- setHubCfgText("{}");
+ setHubCfgSnapshot(null);
}
})();
}, [fetchJson, view]);
@@ -545,7 +549,7 @@ export default function LiveControlApp() {
{dashErr}
) : null}
-
+
{[
{
icon: Cpu,
@@ -571,6 +575,12 @@ export default function LiveControlApp() {
value: `${onlineServices}/${totalServices || "—"}`,
sub: t("基础服务层", "Infra layer"),
},
+ {
+ icon: Clock,
+ label: t("运行时间", "Uptime"),
+ value: formatUptime(dash?.snapshot?.uptime_seconds),
+ sub: t("自开机", "Since boot"),
+ },
].map((card, i) => (
- {dash?.probes ? (
-
- {(["srs", "neko"] as const).map((key) => {
- const p = dash.probes?.[key];
- const tcpOk = p?.tcp?.reachable;
- const http = p?.http;
- const title = key === "srs" ? "SRS" : "Neko Chromium";
- return (
+ {dash?.hardware && typeof dash.hardware === "object" ? (
+
+
+ {t("系统硬件与软件能力", "Hardware & software profile")}
+
+
+ {t(
+ "来自 hardware_probe;用于直播/解码策略参考。",
+ "From hardware_probe; guides decode/streaming posture.",
+ )}
+
+
+
+
+ {t("平台", "Platform")}
+
+
+
+
arch
+ {String(dash.hardware.arch ?? "—")}
+
+
+
kernel
+ {String(dash.hardware.kernel ?? dash.stack?.kernel ?? "—")}
+
+
+
Python
+ {String(dash.stack?.python ?? "—")}
+
+
+
mDNS
+ {dash.stack?.mdns_host ?? "—"}
+
+
+
IP
+ {(dash.stack?.ips || []).join(" · ") || "—"}
+
+
+
+
+
GPU / NPU
+ {(() => {
+ const gpu = dash.hardware.gpu as
+ | { present?: boolean; driver?: string; cards?: string[]; render_nodes?: string[] }
+ | undefined;
+ const npu = dash.hardware.npu as { present?: boolean; devices?: string[] } | undefined;
+ return (
+
+
+
GPU
+
+ {gpu?.present ? t("已检测", "present") : t("未检测", "absent")}
+ {gpu?.driver ? ` · ${gpu.driver}` : ""}
+
+
+
+
render
+
+ {(gpu?.render_nodes || []).join(", ") || "—"}
+
+
+
+
NPU
+
+ {npu?.present ? (npu.devices || []).join(", ") : t("—", "—")}
+
+
+
+ );
+ })()}
+
+
+
+ ffmpeg / 建议策略
+
+ {(() => {
+ const acc = (dash.hardware.ffmpeg_hwaccels as string[] | undefined) || [];
+ const rec = dash.hardware.recommendation as
+ | {
+ video_decoder?: string;
+ hdmi_player?: string;
+ browser_hwaccel?: string;
+ stability_mode?: string;
+ degradation_reasons?: string[];
+ }
+ | undefined;
+ return (
+
+
+ hwaccels:
+ {acc.length ? acc.join(", ") : "—"}
+
+
+
+ {t("解码", "Decoder")}:
+ {rec?.video_decoder ?? "—"}
+
+
+ HDMI:
+ {rec?.hdmi_player ?? "—"}
+
+
+ Browser:
+ {rec?.browser_hwaccel ?? "—"}
+
+
+ {t("稳定模式", "Stability")}:
+ {rec?.stability_mode ?? "—"}
+
+
+ {(rec?.degradation_reasons || []).length ? (
+
+ {(rec?.degradation_reasons || []).map((r) => (
+ {r}
+ ))}
+
+ ) : null}
+
+ );
+ })()}
+
+
+
+ ) : null}
+
+ {dash?.stack?.neko_login ? (
+
+
Neko {t("登录口令", "passwords")}
+
+ {dash.stack.neko_login.note_zh ||
+ t("Chromium :9200 与 Firefox :9201(若启用)共用下列口令。", "Chromium :9200 and Firefox :9201 share these.")}
+
+
+
+
{t("用户", "User")}
+
{dash.stack.neko_login.user_password ?? "—"}
+
+
+
{t("管理员", "Admin")}
+
{dash.stack.neko_login.admin_password ?? "—"}
+
+
+
+ {t(
+ "Google 无官方 ARM 桌面 Chrome;Neko 使用 Chromium / Firefox 多架构镜像(非 Chrome 品牌)。",
+ "No official ARM desktop Chrome; Neko uses Chromium/Firefox multi-arch images.",
+ )}
+
+
+ ) : null}
+
+ {dash?.network?.interfaces?.length ? (
+
+
+
+ {t("网络流量(累计)", "Network I/O (cumulative)")}
+
+
+ {t("自开机以来各接口收发字节;非实时速率。", "Per-interface RX/TX since boot (not a live bitrate).")}
+
+
+ {(dash.network.interfaces || []).slice(0, 6).map((iface) => (
-
{title}
-
- :{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
+
{iface.name}
+
+ ↓ {formatBytes(iface.rx_bytes)} · {" "}
+ {iface.rx_packets?.toLocaleString() ?? "—"} pkt
-
- {t("延迟", "RTT")}{" "}
- {http?.latency_ms != null ? `${http.latency_ms} ms` : p?.tcp?.latency_ms != null ? `${p.tcp.latency_ms} ms` : "—"}
+
+ ↑ {formatBytes(iface.tx_bytes)} · {" "}
+ {iface.tx_packets?.toLocaleString() ?? "—"} pkt
- {!http?.reachable && http?.error ? (
-
{http.error}
- ) : null}
- );
- })}
+ ))}
+
+
+ ) : null}
+
+ {(dash?.live_events?.pm2_tail?.length || dash?.live_events?.process_digest?.length) ? (
+
+
+
{t("直播 / PM2 事件", "Live / PM2 feed")}
+
{t("PM2 合并日志尾部", "PM2 merged log tail")}
+
+ {(dash?.live_events?.pm2_tail || []).join("\n") || t("暂无 PM2 输出", "No PM2 output")}
+
+
+
+
{t("进程快照", "Process digest")}
+
+ {(dash?.live_events?.process_digest || []).map((row) => (
+
+ {row.label || row.pm2}
+
+ {row.status || "—"}
+
+
+ ))}
+
+
+
+ ) : null}
+
+ {dash?.probes && showStreamProbes ? (
+
+ {dash.probes.srs ? (
+ (() => {
+ const sp = dash.probes?.srs as SrsProbe | undefined;
+ const tcpOk = sp?.tcp?.reachable;
+ const tcpApiOk = sp?.tcp_api?.reachable;
+ const httpUi = sp?.http_ui;
+ const http = sp?.http;
+ const apiPort = sp?.api_port;
+ const apiHealthy = sp?.api_ok ?? (http?.http_code === 200);
+ const uiCode = httpUi?.http_code;
+ return (
+
+
+
SRS
+
+ {apiHealthy ? t("API 正常", "API OK") : t("API 异常", "API down")}
+
+
+
+ :{sp?.port ?? "—"} / API :{apiPort ?? "—"} · TCP {tcpOk ? "OK" : "—"} · API TCP{" "}
+ {tcpApiOk ? "OK" : "—"}
+
+
+ API HTTP {http?.http_code ?? "—"} · {t("根路径", "root")} {uiCode ?? "—"}
+ {uiCode === 404
+ ? t("(无首页不影响推流)", " (no index is OK for streaming)")
+ : null}
+
+
+ {t("延迟", "RTT")}{" "}
+ {http?.latency_ms != null
+ ? `${http.latency_ms} ms`
+ : sp?.tcp?.latency_ms != null
+ ? `${sp.tcp.latency_ms} ms`
+ : "—"}
+
+ {!apiHealthy && http?.error ? (
+
{http.error}
+ ) : null}
+
+ );
+ })()
+ ) : null}
+ {dash.probes.neko ? (
+ (() => {
+ const p = dash.probes?.neko;
+ const tcpOk = p?.tcp?.reachable;
+ const http = p?.http;
+ return (
+
+
Neko Chromium
+
+ {t(
+ "Docker 内 Chromium(非 Chrome 品牌);arm64/x86 同一镜像。",
+ "Chromium in Docker (not Chrome branding); multi-arch.",
+ )}
+
+
+ :{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
+
+
+ {t("延迟", "RTT")}{" "}
+ {http?.latency_ms != null
+ ? `${http.latency_ms} ms`
+ : p?.tcp?.latency_ms != null
+ ? `${p.tcp.latency_ms} ms`
+ : "—"}
+
+ {!http?.reachable && http?.error ? (
+
{http.error}
+ ) : null}
+
+ );
+ })()
+ ) : null}
+ {dash.probes.neko_firefox ? (
+ (() => {
+ const p = dash.probes?.neko_firefox;
+ const tcpOk = p?.tcp?.reachable;
+ const http = p?.http;
+ return (
+
+
Neko Firefox
+
+ {t(
+ "第二桌面(ENABLE_NEKO_FF=1);口令与 Chromium 相同。",
+ "Second desktop (ENABLE_NEKO_FF=1); same passwords as Chromium.",
+ )}
+
+
+ :{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
+
+
+ {t("延迟", "RTT")}{" "}
+ {http?.latency_ms != null
+ ? `${http.latency_ms} ms`
+ : p?.tcp?.latency_ms != null
+ ? `${p.tcp.latency_ms} ms`
+ : "—"}
+
+ {!http?.reachable && http?.error ? (
+
{http.error}
+ ) : null}
+
+ );
+ })()
+ ) : null}
) : null}
@@ -628,48 +938,49 @@ export default function LiveControlApp() {
-
-
{t("安卓", "Android")}
-
- {dash?.android?.adb_available
- ? t(`已连接 ${dash.android?.devices?.length ?? 0} 台设备`, `${dash?.android?.devices?.length ?? 0} device(s)`)
- : t("ADB 不可用", "ADB unavailable")}
-
-
- {t("打开画面工作室", "Open screen studio")}
-
-
-
-
-
{t("直播进程", "Live processes")}
-
- {(dash?.live?.processes || []).slice(0, 8).map((p) => (
-
- {p.label || p.pm2}
-
+
+
+
{t("直播进程", "Live processes")}
+
+ {dash?.live?.backend_mode || "—"}
+
+
+
+ {(dash?.live?.processes || []).slice(0, 10).map((p) => (
+
+
+ LIVE
+
+
+
{p.label || p.pm2}
+
{p.script || p.pm2}
+
+
{p.process_status || "—"}
))}
{!dash?.live?.processes?.length ? (
- {t("暂无数据", "No data")}
+
+ {t("暂无直播进程", "No live processes")}
+
) : null}
-
+
setView("live_youtube")}
- className="rounded-xl border border-violet-500/30 bg-violet-500/10 py-2 text-[11px] font-semibold text-violet-200"
+ className="rounded-xl border border-violet-400/35 bg-violet-500/15 py-2.5 text-[11px] font-semibold text-violet-100 shadow-inner shadow-black/20 transition hover:bg-violet-500/25"
>
YouTube
setView("live_tiktok")}
- className="rounded-xl border border-cyan-500/30 bg-cyan-500/10 py-2 text-[11px] font-semibold text-cyan-200"
+ className="rounded-xl border border-cyan-400/35 bg-cyan-500/15 py-2.5 text-[11px] font-semibold text-cyan-100 shadow-inner shadow-black/20 transition hover:bg-cyan-500/25"
>
TikTok
@@ -701,7 +1012,21 @@ export default function LiveControlApp() {
{s.detail ? (
-
{s.detail}
+
+ {s.detail}
+
+ ) : null}
+ {s.service_id === "shellcrash" && s.available && !s.running ? (
+
+ {t(
+ "stopped 为常态:仅当需要透明代理 / 规则分流时再点 Start(或 systemctl enable --now shellcrash)。",
+ "Stopped is normal; Start only when you need the proxy (or enable shellcrash.service).",
+ )}
+
) : null}
{s.url ? (
@@ -716,8 +1041,14 @@ export default function LiveControlApp() {
) : null}
void runService(s.service_id, "start")}
className="rounded-lg bg-emerald-500/20 px-3 py-1.5 text-xs font-medium text-emerald-200 ring-1 ring-emerald-500/30 disabled:opacity-30"
>
@@ -725,8 +1056,14 @@ export default function LiveControlApp() {
void runService(s.service_id, "restart")}
className="rounded-lg bg-amber-500/15 px-3 py-1.5 text-xs font-medium text-amber-200 ring-1 ring-amber-500/25 disabled:opacity-30"
>
@@ -734,8 +1071,14 @@ export default function LiveControlApp() {
void runService(s.service_id, "stop")}
className="rounded-lg bg-rose-500/15 px-3 py-1.5 text-xs font-medium text-rose-200 ring-1 ring-rose-500/25 disabled:opacity-30"
>
@@ -798,8 +1141,6 @@ export default function LiveControlApp() {
devices={dash?.android?.devices || []}
adbAvailable={!!dash?.android?.adb_available}
scrcpyUrl={scrcpyUrl}
- androidShotUrl={androidShotUrl}
- androidShotErr={androidShotErr}
apkPath={apkPath}
setApkPath={setApkPath}
onAndroidAction={(action, payload) => androidAction(action, payload ?? {})}
@@ -808,90 +1149,128 @@ export default function LiveControlApp() {
) : null}
{view === "network" ? (
-
-
+
+
-
- {t("逻辑拓扑", "Topology")}
+
+ {t("ShellCrash 网络面板", "ShellCrash panel")}
+
+ {t(
+ "此页仅用于透明代理 / 规则分流。点击下方进入 ShellCrash 专属控制台;流媒体与其它服务请在「服务」页管理。",
+ "Proxy rules only. Open ShellCrash below; use Services for SRS/Neko/etc.",
+ )}
+
{[
- { t: t("互联网", "Internet"), c: "from-cyan-400 to-blue-500" },
- { t: "ShellCrash", c: "from-violet-500 to-fuchsia-500" },
- { t: t("本机服务", "Host"), c: "from-emerald-400 to-teal-500" },
- { t: "AOSP", c: "from-amber-400 to-orange-500" },
+ { tx: t("互联网", "Internet"), c: "from-cyan-400 to-blue-500" },
+ { tx: "ShellCrash", c: "from-violet-500 to-fuchsia-500" },
+ { tx: t("本机 / 安卓", "Host / Android"), c: "from-emerald-400 to-teal-500" },
].map((n, i) => (
-
+
- {n.t}
+ {n.tx}
- {i < 3 ?
: null}
+ {i < 2 ?
: null}
))}
-
- {t("安卓可走透明代理或 Wi‑Fi 代理,由 ShellCrash 与网络脚本实现。", "Android egress via transparent or Wi‑Fi proxy.")}
-
-
-
- ShellCrash
-
-
- {[
- ["Netdata", ql.netdata],
- ["SRS", ql.srs_http],
- ["Neko", ql.neko_browser],
- ["WebTTY", ql.webtty],
- ].map(([name, href]) => (
-
{
- if (!href) {
- e.preventDefault();
- notify(t("仪表盘未返回该链接", "Hub did not return this link"));
- }
- }}
- >
- {name}
-
-
- ))}
-
-
- {t(
- "若新标签页一直转圈:请先到「服务」里 Start 对应容器(如 SRS / Neko),并确认防火墙未拦端口。",
- "If the new tab spins: start the stack under Services (e.g. SRS/Neko) and check the firewall.",
- )}
-
+
+ {t("打开 ShellCrash 控制台", "Open ShellCrash console")}
+
+
) : null}
{view === "settings" ? (
-
+
- {t(
- "配置中心路径:/opt/live/config(JSON)。以下为只读快照。",
- "Config root: /opt/live/config (JSON). Read-only snapshot.",
- )}
+ {t("常用选项(本机偏好);写入 JSON 配置请用高级控制台。", "Local preferences; use Advanced console for JSON.")}
-
- {hubCfgText || t("加载中…", "Loading…")}
-
+
+
+
+
{t("总览自动刷新", "Auto-refresh dashboard")}
+
10s
+
+ {
+ const on = e.target.checked;
+ setAutoRefreshEnabled(on);
+ try {
+ localStorage.setItem("livehub.autorefresh", on ? "1" : "0");
+ } catch {
+ /* ignore */
+ }
+ notify(on ? t("已开启刷新", "Auto refresh on") : t("已暂停刷新", "Paused"));
+ }}
+ />
+
+
+
+
{t("总览显示 SRS / Neko 探针", "Show SRS/Neko probes")}
+
{t("关闭可精简首页", "Hide stream probe cards")}
+
+ {
+ const on = e.target.checked;
+ setShowStreamProbes(on);
+ try {
+ localStorage.setItem("livehub.showStreamProbes", on ? "1" : "0");
+ } catch {
+ /* ignore */
+ }
+ }}
+ />
+
+
+
+
{t("配置快照", "Config snapshot")}
+
+ {String((hubCfgSnapshot as { config_root?: string })?.config_root || "—")}
+
+
+ {t("主机名", "Hostname")}:{" "}
+
+ {(() => {
+ const sys = hubCfgSnapshot?.system as { identity?: { hostname_alias?: string } } | undefined;
+ const a = sys?.identity?.hostname_alias?.trim();
+ if (a) return a;
+ const h = dash?.stack?.mdns_host || "";
+ return h.replace(/\.local$/i, "") || "—";
+ })()}
+
+
+
+ {(hubCfgSnapshot?.services as Array<{ id?: string; enabled?: boolean }> | undefined)?.slice(0, 8).map((svc) => (
+
+ {svc.id} {svc.enabled ? "ON" : "off"}
+
+ ))}
+
+
- {t("托管文件编辑器", "Managed file editor")}
+ {t("高级控制台 · 文件与 JSON", "Advanced console · files / JSON")}
diff --git a/web-console/components/live-product/LiveTiktokHdmiView.tsx b/web-console/components/live-product/LiveTiktokHdmiView.tsx
index 029736c..e9ed9dc 100644
--- a/web-console/components/live-product/LiveTiktokHdmiView.tsx
+++ b/web-console/components/live-product/LiveTiktokHdmiView.tsx
@@ -3,8 +3,6 @@
import { Cable, Copy, Monitor, Radio } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
-import { extractDouyinLiveUrls } from "@/lib/youtubeConfigUtils";
-
type ProcessEntry = { pm2: string; script: string; label: string };
type TFn = (zh: string, en: string) => string;
@@ -133,7 +131,6 @@ export function LiveTiktokHdmiView({
}
};
- const douyinUrls = useMemo(() => extractDouyinLiveUrls(urlConfig), [urlConfig]);
const lock = busy || urlReloading;
const presetLabel = PRESETS.find((p) => p.id === selectedPreset);
@@ -330,16 +327,6 @@ export function LiveTiktokHdmiView({
{t("保存 URL 配置", "Save URL config")}
- {douyinUrls.length > 0 ? (
-
-
{t("解析到的源站链接", "Parsed Douyin URLs")}
-
- {douyinUrls.map((u) => (
- {u}
- ))}
-
-
- ) : null}
);
diff --git a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
index 34d8e94..d41baf0 100644
--- a/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
+++ b/web-console/components/live-product/LiveYoutubeUnmannedView.tsx
@@ -5,7 +5,6 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import {
buildYoutubeIniFromFields,
- extractDouyinLiveUrls,
parseYoutubeIniFields,
parseYoutubeStreamKeySuffix,
type YoutubeIniFields,
@@ -200,9 +199,6 @@ export function LiveYoutubeUnmannedView({
return parseYoutubeStreamKeySuffix(`key = ${k}`);
}, [mode, ytFields.key, active?.streamKey]);
- const urlTextForPreview = mode === "pro" ? proUrlDraft : urlConfig;
- const douyinUrls = useMemo(() => extractDouyinLiveUrls(urlTextForPreview), [urlTextForPreview]);
-
const saveProUrlAndServer = async () => {
if (!active) return;
updateActive({ urlLines: proUrlDraft });
@@ -610,16 +606,6 @@ export function LiveYoutubeUnmannedView({
) : (
{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}
)}
- {douyinUrls.length > 0 ? (
-
-
{t("解析到的源站链接", "Parsed Douyin URLs")}
-
- {douyinUrls.map((u) => (
- {u}
- ))}
-
-
- ) : null}
);