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

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