s
This commit is contained in:
170
d2ypp2/web.py
170
d2ypp2/web.py
@@ -4,8 +4,9 @@ import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import sys
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -58,7 +59,7 @@ from src.config_governance import (
|
||||
from src.hub_kv_store import get_youtube_pro_channels, set_youtube_pro_channels
|
||||
from src.hub_routes import YoutubeProChannelsBody, configure_hub, router as hub_router
|
||||
from src.process_runtime_status import summarize_process_activity, tail_log_text
|
||||
from src.process_runtime_status import coerce_declared_process_status
|
||||
from src.process_runtime_status import coerce_declared_process_status, process_family
|
||||
from src.runtime_bootstrap import raise_nofile_limit
|
||||
from src.web_process_backend import WebProcessBackend, force_kill_ffmpeg, strip_ansi_codes
|
||||
from src.youtube_pro_runtime import (
|
||||
@@ -66,6 +67,7 @@ from src.youtube_pro_runtime import (
|
||||
ensure_youtube_pro_channel_runtime_files,
|
||||
managed_url_config_relpath_for_pm2,
|
||||
managed_youtube_ini_relpath_for_pm2,
|
||||
validate_youtube_runtime_files,
|
||||
)
|
||||
|
||||
# ---------------- 配置(根目录结构) ----------------
|
||||
@@ -217,9 +219,17 @@ async def _stop_other_hdmi_sink_processes(keep_pm2: str) -> list[str]:
|
||||
st = (info.get("process_status") or "").lower()
|
||||
if st not in ("online", "running"):
|
||||
continue
|
||||
try:
|
||||
root_pid = await process_backend.process_pid(str(name))
|
||||
except Exception:
|
||||
root_pid = 0
|
||||
try:
|
||||
root_pgid = os.getpgid(root_pid) if root_pid > 0 else None
|
||||
except OSError:
|
||||
root_pgid = None
|
||||
await process_backend.stop(name)
|
||||
if not str(name).startswith("web"):
|
||||
await force_kill_ffmpeg(str(name), BASE_DIR)
|
||||
await force_kill_ffmpeg(str(name), BASE_DIR, root_pid=root_pid, root_pgid=root_pgid)
|
||||
stopped.append(str(name))
|
||||
if stopped:
|
||||
await asyncio.sleep(0.85)
|
||||
@@ -278,19 +288,6 @@ def process_monitor_entries() -> tuple[dict[str, str], ...]:
|
||||
extras = list(youtube_pro_process_entries())
|
||||
merged = [dict(row) for row in STATIC_PROCESS_MONITOR]
|
||||
index = {str(row["pm2"]).strip(): pos for pos, row in enumerate(merged)}
|
||||
for channel in get_youtube_pro_channels():
|
||||
if not isinstance(channel, dict):
|
||||
continue
|
||||
cid = str(channel.get("id", "")).strip()
|
||||
if not cid:
|
||||
continue
|
||||
pm2 = channel_pm2_name(channel)
|
||||
if not pm2 or pm2 not in index:
|
||||
continue
|
||||
if not is_youtube_script(str(merged[index[pm2]].get("script", ""))):
|
||||
continue
|
||||
name = str(channel.get("name", "")).strip() or cid
|
||||
merged[index[pm2]]["label"] = _youtube_pro_label(name)
|
||||
for row in extras:
|
||||
pm2 = str(row.get("pm2", "")).strip()
|
||||
if not pm2:
|
||||
@@ -305,6 +302,23 @@ def _sync_youtube_pro_runtime_files(process: str) -> list[str]:
|
||||
return errors
|
||||
|
||||
|
||||
def _validate_youtube_runtime_files(process: str) -> list[str]:
|
||||
return validate_youtube_runtime_files(BASE_DIR, CONFIG_DIR, process, get_youtube_pro_channels())
|
||||
|
||||
|
||||
async def _process_runtime_identity(process: str) -> tuple[int, int | None]:
|
||||
try:
|
||||
pid = await process_backend.process_pid(process)
|
||||
except Exception:
|
||||
pid = 0
|
||||
if pid <= 0:
|
||||
return 0, None
|
||||
try:
|
||||
return pid, os.getpgid(pid)
|
||||
except OSError:
|
||||
return pid, None
|
||||
|
||||
|
||||
def _pm2_safe_segment(pm2: str) -> str:
|
||||
return re.sub(r"[^a-zA-Z0-9_.-]", "_", pm2)
|
||||
|
||||
@@ -586,19 +600,19 @@ def _newest_log_age_seconds(*paths: Optional[str]) -> float | None:
|
||||
|
||||
async def read_log_path_normalized(path: Optional[str], lines: int) -> str:
|
||||
if not path:
|
||||
return "鏃犳棩蹇楄矾寰刓n"
|
||||
return "无日志路径\n"
|
||||
norm = path.replace("\\", "/").lower()
|
||||
if "/dev/null" in norm or norm.endswith("/nul") or norm == "nul":
|
||||
return "鏃ュ織杈撳嚭琚鐢紙PM2 閰嶇疆涓烘棤鏃ュ織锛塡n"
|
||||
return "日志输出被禁用(PM2 配置为无日志)\n"
|
||||
log_file = Path(path)
|
||||
if not log_file.exists():
|
||||
return f"鏃ュ織鏂囦欢涓嶅瓨鍦? {path}\n"
|
||||
return f"日志文件不存在: {path}\n"
|
||||
try:
|
||||
text = await asyncio.to_thread(_read_log_tail_bytes, log_file)
|
||||
tailed = tail_log_text(text, lines)
|
||||
return tailed if tailed else "鏃犳棩蹇楀唴瀹筡n"
|
||||
return tailed if tailed else "无日志内容\n"
|
||||
except OSError as e:
|
||||
return f"璇诲彇鏃ュ織澶辫触 ({path}): {str(e)}\n"
|
||||
return f"读取日志失败 ({path}): {str(e)}\n"
|
||||
|
||||
|
||||
async def get_process_info(process: str) -> dict:
|
||||
@@ -1224,16 +1238,40 @@ def _coerce_local_configured_process(process: str, info: dict) -> dict:
|
||||
async def _process_monitor_snapshot(entry: dict[str, str]) -> dict[str, Any]:
|
||||
name = str(entry.get("pm2") or "").strip()
|
||||
info = await get_process_info(name)
|
||||
return await _process_monitor_snapshot_from_info(entry, info)
|
||||
|
||||
|
||||
async def _process_monitor_snapshot_from_info(
|
||||
entry: dict[str, str],
|
||||
info: dict[str, Any],
|
||||
*,
|
||||
light: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
name = str(entry.get("pm2") or "").strip()
|
||||
info = _coerce_local_configured_process(name, info)
|
||||
recent_log = await read_log_path_normalized(info.get("out_path"), 40)
|
||||
recent_error = await read_log_path_normalized(info.get("err_path"), 40)
|
||||
status = str(info.get("process_status") or "").lower()
|
||||
online = status in {"online", "running", "launching"}
|
||||
read_logs = online or not light
|
||||
log_lines = 12 if light else 40
|
||||
error_lines = 8 if light else 40
|
||||
|
||||
if read_logs:
|
||||
recent_log, recent_error = await asyncio.gather(
|
||||
read_log_path_normalized(info.get("out_path"), log_lines),
|
||||
read_log_path_normalized(info.get("err_path"), error_lines),
|
||||
)
|
||||
else:
|
||||
recent_log = ""
|
||||
recent_error = ""
|
||||
|
||||
ffmpeg_pids = await process_backend.live_ffmpeg_pids(name, info) if online else []
|
||||
activity = summarize_process_activity(
|
||||
name,
|
||||
str(info.get("process_status") or ""),
|
||||
recent_log,
|
||||
recent_error,
|
||||
log_age_seconds=_newest_log_age_seconds(info.get("out_path"), info.get("err_path")),
|
||||
ffmpeg_pids=await process_backend.live_ffmpeg_pids(name),
|
||||
ffmpeg_pids=ffmpeg_pids,
|
||||
)
|
||||
return {
|
||||
"pm2": name,
|
||||
@@ -1247,6 +1285,16 @@ async def _process_monitor_snapshot(entry: dict[str, str]) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _process_monitor_family_matches(entry: dict[str, str], family: str | None) -> bool:
|
||||
target = (family or "").strip().lower()
|
||||
if not target or target == "all":
|
||||
return True
|
||||
script = str(entry.get("script") or "").strip()
|
||||
if script:
|
||||
return process_family(Path(script).stem) == target
|
||||
return process_family(str(entry.get("pm2") or "").strip()) == target
|
||||
|
||||
|
||||
# ---------------- 进程控制 ----------------
|
||||
@app.get("/start")
|
||||
async def start(process: str = Query(..., description="进程名")):
|
||||
@@ -1255,7 +1303,7 @@ async def start(process: str = Query(..., description="进程名")):
|
||||
sc = script_for_pm2_or_pro(process)
|
||||
if not sc:
|
||||
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
|
||||
sync_errors = _sync_youtube_pro_runtime_files(process)
|
||||
sync_errors = _validate_youtube_runtime_files(process)
|
||||
if sync_errors:
|
||||
return JSONResponse({"output": "; ".join(sync_errors)}, status_code=400)
|
||||
stopped = await _stop_other_hdmi_sink_processes(process)
|
||||
@@ -1276,14 +1324,22 @@ async def stop(process: str = Query(..., description="进程名")):
|
||||
if not is_valid_control_process(process):
|
||||
return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400)
|
||||
|
||||
root_pid, root_pgid = await _process_runtime_identity(process)
|
||||
pm2_output = await process_backend.stop(process)
|
||||
|
||||
await asyncio.sleep(2.0)
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
if not process.startswith("web"):
|
||||
await force_kill_ffmpeg(process, BASE_DIR)
|
||||
await force_kill_ffmpeg(process, BASE_DIR, root_pid=root_pid, root_pgid=root_pgid)
|
||||
|
||||
await asyncio.sleep(1.0)
|
||||
if root_pid > 0:
|
||||
with suppress(ProcessLookupError, PermissionError, OSError):
|
||||
os.kill(root_pid, signal.SIGTERM)
|
||||
await asyncio.sleep(0.15)
|
||||
post_info = await get_process_info(process)
|
||||
if str(post_info.get("process_status") or "").lower() in {"online", "running"}:
|
||||
with suppress(ProcessLookupError, PermissionError, OSError):
|
||||
os.kill(root_pid, signal.SIGKILL)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -1306,13 +1362,14 @@ async def restart(process: str = Query(..., description="进程名")):
|
||||
sc = script_for_pm2_or_pro(process)
|
||||
if not sc:
|
||||
return JSONResponse({"output": "内部错误:找不到脚本映射"}, status_code=500)
|
||||
sync_errors = _sync_youtube_pro_runtime_files(process)
|
||||
sync_errors = _validate_youtube_runtime_files(process)
|
||||
if sync_errors:
|
||||
return JSONResponse({"output": "; ".join(sync_errors)}, status_code=400)
|
||||
stopped = await _stop_other_hdmi_sink_processes(process)
|
||||
output = await process_backend.restart(process, sc)
|
||||
root_pid, root_pgid = await _process_runtime_identity(process)
|
||||
if not process.startswith("web"):
|
||||
await force_kill_ffmpeg(process, BASE_DIR)
|
||||
await force_kill_ffmpeg(process, BASE_DIR, root_pid=root_pid, root_pgid=root_pgid)
|
||||
output = await process_backend.restart(process, sc)
|
||||
prefix = ""
|
||||
if stopped:
|
||||
prefix = "已停止其它占用 HDMI 链路的进程: " + "、".join(stopped) + "\n"
|
||||
@@ -1373,29 +1430,44 @@ async def status(process: str = Query(..., description="进程名")):
|
||||
|
||||
|
||||
@app.get("/process_monitor")
|
||||
async def process_monitor():
|
||||
async def process_monitor(
|
||||
family: str | None = Query(None, description="按进程族过滤:youtube/tiktok/obs/generic"),
|
||||
light: bool = Query(False, description="轻量模式:减少日志读取,仅返回监控所需状态"),
|
||||
):
|
||||
await process_backend.ensure_mode()
|
||||
entries = [dict(p) for p in process_monitor_entries()]
|
||||
family_value = family if isinstance(family, str) else None
|
||||
light_value = bool(light) if isinstance(light, bool) else False
|
||||
entries = [dict(p) for p in process_monitor_entries() if _process_monitor_family_matches(p, family_value)]
|
||||
names = [str(entry.get("pm2") or "").strip() for entry in entries if str(entry.get("pm2") or "").strip()]
|
||||
infos = await process_backend.process_infos(names)
|
||||
|
||||
async def build_snapshot(entry: dict[str, str]) -> dict[str, Any]:
|
||||
name = str(entry.get("pm2") or "").strip()
|
||||
return await _process_monitor_snapshot_from_info(entry, infos.get(name) or {}, light=light_value)
|
||||
|
||||
results = await asyncio.gather(*(build_snapshot(entry) for entry in entries), return_exceptions=True)
|
||||
snapshots: list[dict[str, Any]] = []
|
||||
for entry in entries:
|
||||
try:
|
||||
snapshots.append(await _process_monitor_snapshot(entry))
|
||||
except Exception:
|
||||
snapshots.append(
|
||||
{
|
||||
"pm2": entry["pm2"],
|
||||
"script": entry["script"],
|
||||
"label": entry["label"],
|
||||
"process_status": "error",
|
||||
"business_status": "error",
|
||||
"business_note": "Failed to inspect process state",
|
||||
"ffmpeg_active": False,
|
||||
"is_pushing": False,
|
||||
}
|
||||
)
|
||||
for entry, result in zip(entries, results):
|
||||
if not isinstance(result, Exception):
|
||||
snapshots.append(result)
|
||||
continue
|
||||
snapshots.append(
|
||||
{
|
||||
"pm2": entry["pm2"],
|
||||
"script": entry["script"],
|
||||
"label": entry["label"],
|
||||
"process_status": "error",
|
||||
"business_status": "error",
|
||||
"business_note": "Failed to inspect process state",
|
||||
"ffmpeg_active": False,
|
||||
"is_pushing": False,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"entries": snapshots,
|
||||
"process_backend": process_backend.mode,
|
||||
"family": (family_value or "all").strip().lower() or "all",
|
||||
"light": light_value,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user