This commit is contained in:
eric
2026-03-28 16:27:42 -05:00
parent 6f79f259a1
commit 37ab25911a
16 changed files with 1176 additions and 330 deletions

View File

@@ -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 桌面FirefoxGoogle 无官方 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=

View File

@@ -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",

View File

@@ -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() {

View File

@@ -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=1Firefox 与 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 网络共享 / WiFi / 同 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" \

View File

@@ -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"

View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SRS · Live Hub</title>
<style>
:root { color-scheme: dark; }
body { font-family: system-ui, sans-serif; max-width: 42rem; margin: 2rem auto; padding: 0 1rem;
background: #0c0c0f; color: #e4e4e7; line-height: 1.5; }
h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
p { color: #a1a1aa; font-size: 0.9rem; }
ul { padding-left: 1.2rem; }
a { color: #7dd3fc; text-decoration: none; }
a:hover { text-decoration: underline; }
.card { border: 1px solid #27272a; border-radius: 0.75rem; padding: 1rem 1.25rem; margin-top: 1rem;
background: #18181b; }
code { background: #27272a; padding: 0.1em 0.35em; border-radius: 0.25rem; font-size: 0.85em; }
</style>
</head>
<body>
<h1>SRS 流媒体(本机)</h1>
<p>此页由仓库内 <code>services/srs/data/index.html</code> 提供;静态目录挂载后根路径不再 404。</p>
<div class="card">
<p><strong>常用入口</strong></p>
<ul>
<li><a id="api-versions" href="#">SRS HTTP API — 版本信息 (JSON)</a></li>
<li><a id="api-summaries" href="#">SRS — 摘要接口</a></li>
</ul>
</div>
<div class="card">
<p><strong>端口说明(默认)</strong></p>
<ul>
<li><code>1935</code> RTMP 推/拉流</li>
<li><code>1985</code> HTTP API</li>
<li><code>8080</code> 本页与 HLS/HTTP 静态</li>
<li><code>10080/udp</code> SRT</li>
</ul>
</div>
<script>
(function () {
var h = location.hostname || "live.local";
var api = "http://" + h + ":1985";
var av = document.getElementById("api-versions");
var as = document.getElementById("api-summaries");
if (av) { av.href = api + "/api/v1/versions"; }
if (as) { as.href = api + "/api/v1/summaries"; }
})();
</script>
</body>
</html>

View File

@@ -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 StudioENABLE_NEKO=1 时安装",
),
ServiceSpec(
"neko_firefox",
"Neko Firefox (第二桌面)",
"browser",
"可选:第二套 Neko 桌面Firefoxarm64/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":

View File

@@ -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 []
],
},
}

View File

@@ -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,

View File

@@ -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())

42
tools/ssh_neko_start.py Normal file
View File

@@ -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())

View File

@@ -1006,7 +1006,12 @@ export function LiveConsoleWorkspace() {
) : null}
{qlNorm.srs_http ? (
<a className="link-tile" href={qlNorm.srs_http} target="_blank" rel="noreferrer">
🎞 SRS HTTP
🎞 SRS :8080
</a>
) : null}
{qlNorm.srs_api ? (
<a className="link-tile" href={qlNorm.srs_api} target="_blank" rel="noreferrer">
🎞 SRS API :1985
</a>
) : null}
</div>

View File

@@ -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<string, unknown>) => Promise<void>;
@@ -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<string | null>(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<string | null>(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}
</div>
<div className="overflow-hidden rounded-2xl border border-violet-500/20 bg-gradient-to-b from-violet-500/[0.08] to-black/50">
<div className="flex flex-wrap gap-2 border-b border-white/10 px-3 py-2">
<button
type="button"
onClick={() => 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")}
</button>
<button
type="button"
onClick={() => 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
</button>
<a
href="https://github.com/PandaTestGrid/panda-web-scrcpy"
target="_blank"
rel="noreferrer"
className="ml-auto self-center text-[11px] text-zinc-500 underline-offset-2 hover:text-violet-300 hover:underline"
>
GitHub
</a>
</div>
{mirrorMode === "host" ? (
<div>
<div className="space-y-2 border-b border-white/5 px-4 py-3">
<p className="text-[11px] text-zinc-400">
{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.",
)}
</p>
<div className="flex flex-wrap items-center gap-2">
{adbAvailable && androidSerial ? (
<button
type="button"
disabled={lock}
onClick={() => 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
</button>
) : null}
{scrcpyUrl ? (
<a
href={scrcpyUrl}
target="_blank"
rel="noreferrer"
className="rounded-lg border border-white/15 bg-black/30 px-3 py-1.5 text-[11px] text-violet-300"
>
{t("新窗口打开 :5000", "Open :5000 tab")}
</a>
) : null}
</div>
</div>
{scrcpyUrl ? (
<iframe
title="ws-scrcpy"
src={scrcpyUrl}
className="h-[min(72vh,640px)] w-full bg-black"
allow="fullscreen; autoplay; clipboard-read; clipboard-write"
/>
) : (
<p className="px-4 py-8 text-center text-sm text-zinc-500">{t("未配置 ws-scrcpy 地址", "ws-scrcpy URL missing")}</p>
)}
</div>
) : (
<div>
<p className="border-b border-white/5 px-4 py-3 text-[11px] leading-relaxed text-zinc-400">
{t(
"「Panda / WebUSB」在浏览器里直连手机手机必须插在您正在用浏览器看页面的这台电脑的 USB 上(走 WebUSB不是板子上的 ADB。官方演示见 ",
"Panda uses WebUSB: phone must plug into the PC running this browser, not the board. Demo: ",
)}
<a className="text-cyan-300 hover:underline" href={PANDA_WEB_SCRCPY} target="_blank" rel="noreferrer">
panda-web-scrcpy
</a>
</p>
<iframe
title="panda-web-scrcpy"
src={PANDA_WEB_SCRCPY}
className="h-[min(72vh,640px)] w-full bg-black"
allow="fullscreen; usb"
/>
</div>
)}
</div>
{adbAvailable && androidSerial ? (
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/50 p-5">
@@ -672,87 +759,25 @@ export function LiveAndroidWorkstation({
</div>
) : null}
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("安装 APK设备上的路径", "Install APK (path on device)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={apkPath}
onChange={(e) => setApkPath(e.target.value)}
/>
<button
type="button"
disabled={lock || !adbAvailable || !androidSerial}
onClick={() => 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
</button>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-white">{t("实时画面ADB 截图)", "Live screen (ADB)")}</h3>
{scrcpyUrl ? (
<a
href={scrcpyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs text-violet-300 hover:text-violet-200"
>
ws-scrcpy <ArrowUpRight className="h-3 w-3" />
</a>
) : null}
</div>
<p className="mt-1 text-xs text-zinc-500">
{t(
"约每 2.5 秒刷新;全帧低延迟请用 ws-scrcpy 新窗口或本页内嵌。",
"~2.5s ADB refresh; use ws-scrcpy for low-latency stream.",
)}
</p>
{!adbAvailable ? (
<p className="mt-3 text-sm text-amber-200/90">{t("ADB 未就绪,无法截图。", "ADB unavailable.")}</p>
) : null}
{androidShotErr && adbAvailable ? <p className="mt-3 text-sm text-rose-300">{androidShotErr}</p> : null}
{androidShotUrl ? (
<img
alt="Android live"
className="mt-4 max-h-[min(60vh,520px)] w-full rounded-xl border border-white/10 bg-black object-contain"
src={androidShotUrl}
{adbAvailable && androidSerial ? (
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-6">
<h3 className="text-sm font-semibold text-white">{t("安装 APK设备上的路径", "Install APK (path on device)")}</h3>
<p className="mt-1 text-xs text-zinc-500">
{t("请先用 adb push 将 apk 推到 /sdcard/ 等路径。", "Push APK to /sdcard/ via adb first.")}
</p>
<input
className="mt-4 w-full rounded-xl border border-white/10 bg-black/40 px-4 py-3 text-sm"
value={apkPath}
onChange={(e) => setApkPath(e.target.value)}
/>
) : null}
{adbAvailable && !androidShotUrl && !androidShotErr ? (
<p className="mt-6 text-center text-sm text-zinc-500">{t("加载截图…", "Loading screenshot…")}</p>
) : null}
</div>
{scrcpyUrl ? (
<div className="overflow-hidden rounded-2xl border border-white/[0.08] bg-black/40">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-white/5 px-4 py-3">
<button
type="button"
onClick={() => setEmbedScrcpy((v) => !v)}
className="flex items-center gap-2 text-xs font-medium text-zinc-300 hover:text-white"
>
ws-scrcpy
{embedScrcpy ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
<span className="font-normal text-zinc-600">
{embedScrcpy ? t("收起内嵌", "Collapse") : t("展开内嵌(省资源)", "Expand embed")}
</span>
</button>
<a href={scrcpyUrl} target="_blank" rel="noreferrer" className="text-xs text-violet-300">
{t("新窗口打开", "Open tab")}
</a>
</div>
{embedScrcpy ? (
<iframe title="scrcpy" src={scrcpyUrl} className="h-[min(56vh,420px)] w-full bg-black" />
) : (
<p className="px-4 py-3 text-center text-[11px] text-zinc-600">
{t("默认不加载 iframe避免占用带宽与解码需要时点「展开内嵌」。", "Iframe off by default to save resources.")}
</p>
)}
<button
type="button"
disabled={lock || !adbAvailable || !androidSerial}
onClick={() => 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
</button>
</div>
) : null}
</div>

View File

@@ -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<string, unknown>;
stack?: {
hostname?: string;
mdns_host?: string;
quick_links?: Record<string, string>;
machine?: string;
kernel?: string;
python?: string;
platform?: string;
ips?: string[];
quick_links?: Record<string, string>;
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<HubDashboard["probes"]>["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<string | null>(null);
const [androidShotUrl, setAndroidShotUrl] = useState<string | null>(null);
const [androidShotErr, setAndroidShotErr] = useState<string | null>(null);
const [hubCfgSnapshot, setHubCfgSnapshot] = useState<Record<string, unknown> | 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<Record<string, unknown>>("/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}
</div>
) : null}
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
{[
{
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) => (
<motion.div
key={card.label}
@@ -589,32 +599,332 @@ export default function LiveControlApp() {
))}
</div>
{dash?.probes ? (
<div className="grid gap-3 sm:grid-cols-2">
{(["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" ? (
<div className="rounded-2xl border border-indigo-500/25 bg-gradient-to-br from-indigo-500/[0.1] via-zinc-950/70 to-violet-500/[0.08] p-6 shadow-2xl shadow-indigo-500/5">
<h3 className="text-base font-semibold text-white">
{t("系统硬件与软件能力", "Hardware & software profile")}
</h3>
<p className="mt-1 text-[11px] text-zinc-500">
{t(
"来自 hardware_probe用于直播/解码策略参考。",
"From hardware_probe; guides decode/streaming posture.",
)}
</p>
<div className="mt-5 grid gap-4 lg:grid-cols-2">
<div className="space-y-3 rounded-xl border border-white/[0.06] bg-black/30 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-indigo-300/90">
{t("平台", "Platform")}
</p>
<dl className="space-y-2 font-mono text-[11px] text-zinc-400">
<div className="flex justify-between gap-2">
<dt className="text-zinc-600">arch</dt>
<dd className="text-right text-zinc-200">{String(dash.hardware.arch ?? "—")}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-zinc-600">kernel</dt>
<dd className="text-right text-zinc-200">{String(dash.hardware.kernel ?? dash.stack?.kernel ?? "—")}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-zinc-600">Python</dt>
<dd className="text-right text-zinc-200">{String(dash.stack?.python ?? "—")}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-zinc-600">mDNS</dt>
<dd className="text-right text-cyan-200/90">{dash.stack?.mdns_host ?? "—"}</dd>
</div>
<div className="flex justify-between gap-2">
<dt className="text-zinc-600">IP</dt>
<dd className="text-right text-zinc-300">{(dash.stack?.ips || []).join(" · ") || "—"}</dd>
</div>
</dl>
</div>
<div className="space-y-3 rounded-xl border border-white/[0.06] bg-black/30 p-4">
<p className="text-[10px] font-semibold uppercase tracking-wider text-fuchsia-300/90">GPU / NPU</p>
{(() => {
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 (
<dl className="space-y-2 font-mono text-[11px] text-zinc-400">
<div>
<dt className="text-zinc-600">GPU</dt>
<dd className="mt-0.5 text-zinc-200">
{gpu?.present ? t("已检测", "present") : t("未检测", "absent")}
{gpu?.driver ? ` · ${gpu.driver}` : ""}
</dd>
</div>
<div>
<dt className="text-zinc-600">render</dt>
<dd className="mt-0.5 break-all text-zinc-500">
{(gpu?.render_nodes || []).join(", ") || "—"}
</dd>
</div>
<div>
<dt className="text-zinc-600">NPU</dt>
<dd className="mt-0.5 break-all text-zinc-500">
{npu?.present ? (npu.devices || []).join(", ") : t("—", "—")}
</dd>
</div>
</dl>
);
})()}
</div>
<div className="space-y-3 rounded-xl border border-white/[0.06] bg-black/30 p-4 lg:col-span-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-emerald-300/90">
ffmpeg /
</p>
{(() => {
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 (
<div className="space-y-2 text-[11px] text-zinc-400">
<p>
<span className="text-zinc-600">hwaccels: </span>
<span className="font-mono text-emerald-200/90">{acc.length ? acc.join(", ") : "—"}</span>
</p>
<div className="grid gap-2 sm:grid-cols-2">
<p>
<span className="text-zinc-600">{t("解码", "Decoder")}: </span>
<span className="text-zinc-200">{rec?.video_decoder ?? "—"}</span>
</p>
<p>
<span className="text-zinc-600">HDMI: </span>
<span className="text-zinc-200">{rec?.hdmi_player ?? "—"}</span>
</p>
<p>
<span className="text-zinc-600">Browser: </span>
<span className="text-zinc-200">{rec?.browser_hwaccel ?? "—"}</span>
</p>
<p>
<span className="text-zinc-600">{t("稳定模式", "Stability")}: </span>
<span className="text-amber-200/90">{rec?.stability_mode ?? "—"}</span>
</p>
</div>
{(rec?.degradation_reasons || []).length ? (
<ul className="mt-2 list-inside list-disc space-y-1 text-[10px] text-amber-200/80">
{(rec?.degradation_reasons || []).map((r) => (
<li key={r}>{r}</li>
))}
</ul>
) : null}
</div>
);
})()}
</div>
</div>
</div>
) : null}
{dash?.stack?.neko_login ? (
<div className="rounded-2xl border border-fuchsia-500/25 bg-gradient-to-r from-fuchsia-500/[0.08] to-violet-500/[0.06] p-5">
<h3 className="text-sm font-semibold text-white">Neko {t("登录口令", "passwords")}</h3>
<p className="mt-1 text-[11px] text-zinc-500">
{dash.stack.neko_login.note_zh ||
t("Chromium :9200 与 Firefox :9201若启用共用下列口令。", "Chromium :9200 and Firefox :9201 share these.")}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 font-mono text-sm">
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
<p className="text-[10px] uppercase text-zinc-500">{t("用户", "User")}</p>
<p className="mt-1 text-fuchsia-200">{dash.stack.neko_login.user_password ?? "—"}</p>
</div>
<div className="rounded-xl border border-white/10 bg-black/40 px-4 py-3">
<p className="text-[10px] uppercase text-zinc-500">{t("管理员", "Admin")}</p>
<p className="mt-1 text-violet-200">{dash.stack.neko_login.admin_password ?? "—"}</p>
</div>
</div>
<p className="mt-3 text-[10px] text-zinc-600">
{t(
"Google 无官方 ARM 桌面 ChromeNeko 使用 Chromium / Firefox 多架构镜像(非 Chrome 品牌)。",
"No official ARM desktop Chrome; Neko uses Chromium/Firefox multi-arch images.",
)}
</p>
</div>
) : null}
{dash?.network?.interfaces?.length ? (
<div className="rounded-2xl border border-cyan-500/15 bg-gradient-to-br from-cyan-500/[0.07] to-transparent p-5">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Wifi className="h-4 w-4 text-cyan-300" />
{t("网络流量(累计)", "Network I/O (cumulative)")}
</h3>
<p className="mt-1 text-[11px] text-zinc-500">
{t("自开机以来各接口收发字节;非实时速率。", "Per-interface RX/TX since boot (not a live bitrate).")}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{(dash.network.interfaces || []).slice(0, 6).map((iface) => (
<div
key={key}
className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm"
key={iface.name}
className="rounded-xl border border-white/[0.06] bg-black/25 px-4 py-3 font-mono text-[11px]"
>
<p className="text-xs font-semibold text-white">{title}</p>
<p className="mt-1 font-mono text-[11px] text-zinc-500">
:{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? ""}
<p className="text-xs font-semibold text-cyan-200/90">{iface.name}</p>
<p className="mt-2 text-zinc-500">
{formatBytes(iface.rx_bytes)} <span className="text-zinc-600">·</span>{" "}
{iface.rx_packets?.toLocaleString() ?? "—"} pkt
</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t("延迟", "RTT")}{" "}
{http?.latency_ms != null ? `${http.latency_ms} ms` : p?.tcp?.latency_ms != null ? `${p.tcp.latency_ms} ms` : "—"}
<p className="mt-0.5 text-zinc-500">
{formatBytes(iface.tx_bytes)} <span className="text-zinc-600">·</span>{" "}
{iface.tx_packets?.toLocaleString() ?? "—"} pkt
</p>
{!http?.reachable && http?.error ? (
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
) : null}
</div>
);
})}
))}
</div>
</div>
) : null}
{(dash?.live_events?.pm2_tail?.length || dash?.live_events?.process_digest?.length) ? (
<div className="grid gap-4 lg:grid-cols-2">
<div className="rounded-2xl border border-amber-500/15 bg-zinc-950/40 p-5">
<h3 className="text-sm font-semibold text-white">{t("直播 / PM2 事件", "Live / PM2 feed")}</h3>
<p className="mt-1 text-[11px] text-zinc-500">{t("PM2 合并日志尾部", "PM2 merged log tail")}</p>
<pre className="mt-3 max-h-48 overflow-auto whitespace-pre-wrap rounded-lg border border-white/[0.05] bg-black/40 p-3 font-mono text-[10px] leading-relaxed text-zinc-400">
{(dash?.live_events?.pm2_tail || []).join("\n") || t("暂无 PM2 输出", "No PM2 output")}
</pre>
</div>
<div className="rounded-2xl border border-violet-500/15 bg-zinc-950/40 p-5">
<h3 className="text-sm font-semibold text-white">{t("进程快照", "Process digest")}</h3>
<ul className="mt-3 space-y-2">
{(dash?.live_events?.process_digest || []).map((row) => (
<li
key={row.pm2}
className="flex items-center justify-between gap-2 rounded-lg border border-white/[0.05] bg-black/30 px-3 py-2 text-xs"
>
<span className="truncate text-zinc-300">{row.label || row.pm2}</span>
<span
className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium ${statusPill(row.status || "")}`}
>
{row.status || "—"}
</span>
</li>
))}
</ul>
</div>
</div>
) : null}
{dash?.probes && showStreamProbes ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{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 (
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm">
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold text-white">SRS</p>
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-semibold ${
apiHealthy
? "bg-emerald-500/20 text-emerald-200 ring-1 ring-emerald-500/30"
: "bg-rose-500/15 text-rose-200 ring-1 ring-rose-500/25"
}`}
>
{apiHealthy ? t("API 正常", "API OK") : t("API 异常", "API down")}
</span>
</div>
<p className="mt-1 font-mono text-[11px] text-zinc-500">
:{sp?.port ?? "—"} / API :{apiPort ?? "—"} · TCP {tcpOk ? "OK" : "—"} · API TCP{" "}
{tcpApiOk ? "OK" : "—"}
</p>
<p className="mt-1 font-mono text-[11px] text-zinc-500">
API HTTP {http?.http_code ?? "—"} · {t("根路径", "root")} {uiCode ?? "—"}
{uiCode === 404
? t("(无首页不影响推流)", " (no index is OK for streaming)")
: null}
</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t("延迟", "RTT")}{" "}
{http?.latency_ms != null
? `${http.latency_ms} ms`
: sp?.tcp?.latency_ms != null
? `${sp.tcp.latency_ms} ms`
: "—"}
</p>
{!apiHealthy && http?.error ? (
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
) : null}
</div>
);
})()
) : null}
{dash.probes.neko ? (
(() => {
const p = dash.probes?.neko;
const tcpOk = p?.tcp?.reachable;
const http = p?.http;
return (
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm">
<p className="text-xs font-semibold text-white">Neko Chromium</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t(
"Docker 内 Chromium非 Chrome 品牌arm64/x86 同一镜像。",
"Chromium in Docker (not Chrome branding); multi-arch.",
)}
</p>
<p className="mt-2 font-mono text-[11px] text-zinc-500">
:{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t("延迟", "RTT")}{" "}
{http?.latency_ms != null
? `${http.latency_ms} ms`
: p?.tcp?.latency_ms != null
? `${p.tcp.latency_ms} ms`
: "—"}
</p>
{!http?.reachable && http?.error ? (
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
) : null}
</div>
);
})()
) : null}
{dash.probes.neko_firefox ? (
(() => {
const p = dash.probes?.neko_firefox;
const tcpOk = p?.tcp?.reachable;
const http = p?.http;
return (
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-4 backdrop-blur-sm">
<p className="text-xs font-semibold text-white">Neko Firefox</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t(
"第二桌面ENABLE_NEKO_FF=1口令与 Chromium 相同。",
"Second desktop (ENABLE_NEKO_FF=1); same passwords as Chromium.",
)}
</p>
<p className="mt-2 font-mono text-[11px] text-zinc-500">
:{p?.port ?? "—"} · TCP {tcpOk ? "OK" : "—"} · HTTP {http?.http_code ?? "—"}
</p>
<p className="mt-1 text-[11px] text-zinc-500">
{t("延迟", "RTT")}{" "}
{http?.latency_ms != null
? `${http.latency_ms} ms`
: p?.tcp?.latency_ms != null
? `${p.tcp.latency_ms} ms`
: "—"}
</p>
{!http?.reachable && http?.error ? (
<p className="mt-1 line-clamp-2 text-[10px] text-amber-200/80">{http.error}</p>
) : null}
</div>
);
})()
) : null}
</div>
) : null}
@@ -628,48 +938,49 @@ export default function LiveControlApp() {
<OverviewCharts snapshot={dash?.snapshot ?? null} theme="dark" tr={t} />
</motion.div>
<div className="space-y-4">
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-5">
<h3 className="text-sm font-semibold text-white">{t("安卓", "Android")}</h3>
<p className="mt-2 text-xs text-zinc-500">
{dash?.android?.adb_available
? t(`已连接 ${dash.android?.devices?.length ?? 0} 台设备`, `${dash?.android?.devices?.length ?? 0} device(s)`)
: t("ADB 不可用", "ADB unavailable")}
</p>
<a
href="/android"
className="mt-4 inline-flex items-center gap-2 text-xs font-medium text-violet-300 hover:text-violet-200"
>
{t("打开画面工作室", "Open screen studio")}
<ArrowUpRight className="h-3.5 w-3.5" />
</a>
</div>
<div className="rounded-2xl border border-white/[0.07] bg-zinc-950/40 p-5">
<h3 className="text-sm font-semibold text-white">{t("直播进程", "Live processes")}</h3>
<ul className="mt-3 max-h-40 space-y-2 overflow-y-auto text-xs">
{(dash?.live?.processes || []).slice(0, 8).map((p) => (
<li key={p.pm2} className="flex justify-between gap-2 rounded-lg bg-black/30 px-3 py-2">
<span className="truncate text-zinc-300">{p.label || p.pm2}</span>
<span className={`shrink-0 rounded-md px-2 py-0.5 text-[10px] font-medium ${statusPill(p.process_status || "")}`}>
<div className="rounded-2xl border border-violet-500/20 bg-gradient-to-br from-violet-500/[0.12] via-zinc-950/50 to-fuchsia-500/[0.06] p-5 shadow-lg shadow-violet-500/5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-white">{t("直播进程", "Live processes")}</h3>
<span className="rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-medium text-zinc-300">
{dash?.live?.backend_mode || "—"}
</span>
</div>
<ul className="mt-4 space-y-2.5">
{(dash?.live?.processes || []).slice(0, 10).map((p) => (
<li
key={p.pm2}
className="group flex items-center gap-3 rounded-xl border border-white/[0.08] bg-black/35 px-3 py-2.5 transition hover:border-violet-400/25 hover:bg-black/45"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-violet-500/30 to-fuchsia-500/20 text-[10px] font-bold text-violet-100 ring-1 ring-white/10">
LIVE
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-zinc-100">{p.label || p.pm2}</p>
<p className="truncate font-mono text-[10px] text-zinc-600">{p.script || p.pm2}</p>
</div>
<span className={`shrink-0 rounded-lg px-2.5 py-1 text-[10px] font-semibold ${statusPill(p.process_status || "")}`}>
{p.process_status || "—"}
</span>
</li>
))}
{!dash?.live?.processes?.length ? (
<li className="text-zinc-600">{t("暂无数据", "No data")}</li>
<li className="rounded-xl border border-dashed border-white/10 py-8 text-center text-xs text-zinc-600">
{t("暂无直播进程", "No live processes")}
</li>
) : null}
</ul>
<div className="mt-4 grid grid-cols-2 gap-2">
<div className="mt-5 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
@@ -701,7 +1012,21 @@ export default function LiveControlApp() {
</span>
</div>
{s.detail ? (
<p className="mt-2 text-[11px] leading-snug text-amber-200/85 line-clamp-4">{s.detail}</p>
<p
className={`mt-2 text-[11px] leading-snug line-clamp-6 ${
s.status === "skipped" ? "text-slate-300/90" : "text-amber-200/85"
}`}
>
{s.detail}
</p>
) : null}
{s.service_id === "shellcrash" && s.available && !s.running ? (
<p className="mt-2 text-[11px] text-zinc-500">
{t(
"stopped 为常态:仅当需要透明代理 / 规则分流时再点 Start或 systemctl enable --now shellcrash。",
"Stopped is normal; Start only when you need the proxy (or enable shellcrash.service).",
)}
</p>
) : null}
<div className="mt-4 flex flex-wrap items-center gap-2">
{s.url ? (
@@ -716,8 +1041,14 @@ export default function LiveControlApp() {
) : null}
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("单网口无需网关", "Not needed on single NIC")
: !s.available
? s.detail || t("当前不可操作", "Unavailable")
: undefined
}
onClick={() => 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() {
</button>
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("单网口无需网关", "Not needed on single NIC")
: !s.available
? s.detail || t("当前不可操作", "Unavailable")
: undefined
}
onClick={() => 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() {
</button>
<button
type="button"
disabled={!!busy || !s.available}
title={!s.available ? s.detail || t("当前不可操作", "Unavailable") : undefined}
disabled={!!busy || !s.available || (s.service_id === "android_gateway" && s.status === "skipped")}
title={
s.service_id === "android_gateway" && s.status === "skipped"
? t("单网口无需网关", "Not needed on single NIC")
: !s.available
? s.detail || t("当前不可操作", "Unavailable")
: undefined
}
onClick={() => 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" ? (
<div className="mx-auto max-w-4xl space-y-6">
<div className="rounded-2xl border border-cyan-500/20 bg-gradient-to-br from-cyan-500/10 to-transparent p-6">
<div className="mx-auto max-w-3xl space-y-6">
<div className="rounded-2xl border border-violet-500/25 bg-gradient-to-br from-violet-500/10 via-zinc-950/40 to-fuchsia-500/5 p-6">
<h3 className="flex items-center gap-2 text-sm font-semibold text-white">
<Wifi className="h-4 w-4 text-cyan-300" />
{t("逻辑拓扑", "Topology")}
<Wifi className="h-4 w-4 text-violet-300" />
{t("ShellCrash 网络面板", "ShellCrash panel")}
</h3>
<p className="mt-2 text-xs text-zinc-400">
{t(
"此页仅用于透明代理 / 规则分流。点击下方进入 ShellCrash 专属控制台;流媒体与其它服务请在「服务」页管理。",
"Proxy rules only. Open ShellCrash below; use Services for SRS/Neko/etc.",
)}
</p>
<div className="mt-6 flex flex-col items-center gap-4 sm:flex-row sm:justify-center">
{[
{ 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) => (
<div key={n.t} className="flex items-center gap-4">
<div key={n.tx} className="flex items-center gap-4">
<div
className={`flex h-16 w-28 items-center justify-center rounded-2xl bg-gradient-to-br ${n.c} text-xs font-bold text-white shadow-lg`}
>
{n.t}
{n.tx}
</div>
{i < 3 ? <ChevronRight className="hidden h-5 w-5 text-zinc-600 sm:block" /> : null}
{i < 2 ? <ChevronRight className="hidden h-5 w-5 text-zinc-600 sm:block" /> : null}
</div>
))}
</div>
<p className="mt-6 text-center text-xs text-zinc-500">
{t("安卓可走透明代理或 WiFi 代理,由 ShellCrash 与网络脚本实现。", "Android egress via transparent or WiFi proxy.")}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<a
href="/shellcrash"
className="flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm hover:border-violet-500/30"
>
ShellCrash
<ArrowUpRight className="h-4 w-4 text-zinc-600" />
</a>
{[
["Netdata", ql.netdata],
["SRS", ql.srs_http],
["Neko", ql.neko_browser],
["WebTTY", ql.webtty],
].map(([name, href]) => (
<a
key={name}
href={href || "#"}
target="_blank"
rel="noreferrer"
className={`flex items-center justify-between rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 text-sm hover:border-violet-500/30 ${href ? "" : "pointer-events-none opacity-40"}`}
onClick={(e) => {
if (!href) {
e.preventDefault();
notify(t("仪表盘未返回该链接", "Hub did not return this link"));
}
}}
>
{name}
<ArrowUpRight className="h-4 w-4 text-zinc-600" />
</a>
))}
</div>
<p className="text-center text-xs text-zinc-500">
{t(
"若新标签页一直转圈:请先到「服务」里 Start 对应容器(如 SRS / Neko并确认防火墙未拦端口。",
"If the new tab spins: start the stack under Services (e.g. SRS/Neko) and check the firewall.",
)}
</p>
<a
href="/shellcrash"
className="flex items-center justify-between rounded-2xl border border-violet-400/30 bg-gradient-to-r from-violet-500/15 to-fuchsia-500/10 px-5 py-4 text-sm font-semibold text-violet-100 shadow-lg shadow-violet-500/10 transition hover:border-violet-400/50"
>
{t("打开 ShellCrash 控制台", "Open ShellCrash console")}
<ArrowUpRight className="h-4 w-4" />
</a>
</div>
) : null}
{view === "settings" ? (
<div className="mx-auto max-w-3xl space-y-4">
<div className="mx-auto max-w-lg space-y-6">
<p className="text-sm text-zinc-400">
{t(
"配置中心路径:/opt/live/configJSON。以下为只读快照。",
"Config root: /opt/live/config (JSON). Read-only snapshot.",
)}
{t("常用选项(本机偏好);写入 JSON 配置请用高级控制台。", "Local preferences; use Advanced console for JSON.")}
</p>
<pre className="max-h-[480px] overflow-auto rounded-2xl border border-white/10 bg-black/50 p-4 font-mono text-[11px] leading-relaxed text-zinc-400">
{hubCfgText || t("加载中…", "Loading…")}
</pre>
<div className="space-y-4 rounded-2xl border border-white/[0.08] bg-zinc-950/50 p-5">
<label className="flex cursor-pointer items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-white">{t("总览自动刷新", "Auto-refresh dashboard")}</p>
<p className="text-[11px] text-zinc-500">10s</p>
</div>
<input
type="checkbox"
className="h-5 w-5 accent-violet-500"
checked={autoRefreshEnabled}
onChange={(e) => {
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"));
}}
/>
</label>
<label className="flex cursor-pointer items-center justify-between gap-4 border-t border-white/[0.06] pt-4">
<div>
<p className="text-sm font-medium text-white">{t("总览显示 SRS / Neko 探针", "Show SRS/Neko probes")}</p>
<p className="text-[11px] text-zinc-500">{t("关闭可精简首页", "Hide stream probe cards")}</p>
</div>
<input
type="checkbox"
className="h-5 w-5 accent-violet-500"
checked={showStreamProbes}
onChange={(e) => {
const on = e.target.checked;
setShowStreamProbes(on);
try {
localStorage.setItem("livehub.showStreamProbes", on ? "1" : "0");
} catch {
/* ignore */
}
}}
/>
</label>
</div>
<div className="rounded-2xl border border-white/[0.08] bg-zinc-950/40 p-5">
<p className="text-xs font-semibold uppercase tracking-wider text-zinc-500">{t("配置快照", "Config snapshot")}</p>
<p className="mt-2 font-mono text-[11px] text-zinc-400">
{String((hubCfgSnapshot as { config_root?: string })?.config_root || "—")}
</p>
<p className="mt-3 text-sm text-zinc-300">
{t("主机名", "Hostname")}:{" "}
<span className="font-mono text-violet-200">
{(() => {
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, "") || "—";
})()}
</span>
</p>
<div className="mt-4 flex flex-wrap gap-2">
{(hubCfgSnapshot?.services as Array<{ id?: string; enabled?: boolean }> | undefined)?.slice(0, 8).map((svc) => (
<span
key={svc.id}
className={`rounded-full px-2.5 py-1 text-[10px] font-medium ${
svc.enabled ? "bg-emerald-500/15 text-emerald-200 ring-1 ring-emerald-500/25" : "bg-zinc-500/15 text-zinc-400"
}`}
>
{svc.id} {svc.enabled ? "ON" : "off"}
</span>
))}
</div>
</div>
<a
href="/console?tab=config"
className="inline-flex items-center gap-2 text-sm text-violet-300"
className="inline-flex w-full items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/[0.05] py-3 text-sm text-violet-200 transition hover:bg-white/[0.08]"
>
{t("托管文件编辑器", "Managed file editor")}
{t("高级控制台 · 文件与 JSON", "Advanced console · files / JSON")}
<ArrowUpRight className="h-4 w-4" />
</a>
</div>

View File

@@ -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")}
</button>
</div>
{douyinUrls.length > 0 ? (
<div className="mt-4 rounded-xl border border-white/5 bg-black/30 p-3">
<p className="text-xs font-semibold text-zinc-400">{t("解析到的源站链接", "Parsed Douyin URLs")}</p>
<ul className="mt-2 space-y-1 font-mono text-[11px] text-cyan-200/90">
{douyinUrls.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
</div>
) : null}
</div>
</div>
);

View File

@@ -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({
) : (
<p className="mt-4 text-xs text-zinc-600">{t("Pro 模式:请使用「当前线路」中的地址列表。", "Use lane URL list in Pro mode.")}</p>
)}
{douyinUrls.length > 0 ? (
<div className="mt-4 rounded-xl border border-white/5 bg-black/30 p-3">
<p className="text-xs font-semibold text-zinc-400">{t("解析到的源站链接", "Parsed Douyin URLs")}</p>
<ul className="mt-2 space-y-1 font-mono text-[11px] text-cyan-200/90">
{douyinUrls.map((u) => (
<li key={u}>{u}</li>
))}
</ul>
</div>
) : null}
</div>
</div>
);