's'
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" \
|
||||
|
||||
29
services/neko/docker-compose.firefox.yml
Normal file
29
services/neko/docker-compose.firefox.yml
Normal 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"
|
||||
51
services/srs/data/index.html
Normal file
51
services/srs/data/index.html
Normal 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>
|
||||
@@ -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":
|
||||
|
||||
@@ -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 []
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
62
tools/probe_board_stack.py
Normal file
62
tools/probe_board_stack.py
Normal 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
42
tools/ssh_neko_start.py
Normal 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())
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 桌面 Chrome;Neko 使用 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("安卓可走透明代理或 Wi‑Fi 代理,由 ShellCrash 与网络脚本实现。", "Android egress via transparent or Wi‑Fi 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/config(JSON)。以下为只读快照。",
|
||||
"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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user