's'
This commit is contained in:
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user